Version 2 of timebox

Updated 2011-04-19 22:57:01 by AMG

AMG: Here's my [timebox] widget. It used to live on my web site, but I don't see any reason why it can't just be on the Wiki. See [timeentry] for its predecessors. There's a bit of documentation at [L1 ], which I will move here when I get a chance.

timebox screenshot

# timebox.tcl
#
# Andy Goth <[email protected]>
#
# This code is available under the same license as Tcl/Tk, available online at
# http://www.tcl.tk/software/tcltk/license.html for your review.

package require Tcl 8.5a6

namespace eval timebox {
    namespace export widget
    namespace path {::tcl::mathop ::tcl::mathfunc}

    # Format field code definition table.  Columns: (0) code character, (1)
    # field width, (2) time unit, (3) type, (4) list of valid strings.  Codes
    # are sorted in order of decreasing significance, i.e. years to seconds.
    variable Codes {
        {Y 4 year   int}
        {b 3 month  str {Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec}}
        {m 2 month  int}
        {a 3 day    other}
        {d 2 day    int}
        {P 2 ampm   str {am pm}}
        {p 2 ampm   str {AM PM}}
        {H 2 hour   int}
        {I 2 hour   int}
        {M 2 minute int}
        {S 2 second int}
    }

    # Do nothing.
    proc Noop {args} {}

    # Scan a time string into an integer.
    proc Scan {win time} {
        variable ${win}::fmt
        variable ${win}::base
        variable ${win}::timezone

        set arguments [list -format $fmt]
        if {$base ne "now"} {
            lappend arguments -base $base
        }
        if {$timezone ne ""} {
            lappend arguments -timezone $timezone
        }
        clock scan $time {*}$arguments
    }

    # Format an integer into a time string.
    proc Format {win time} {
        variable ${win}::fmt
        variable ${win}::timezone
        clock format $time -format $fmt -timezone $timezone
    }

    # Perform clock arithmetic.
    proc Add {win time args} {
        variable ${win}::timezone
        if {$timezone ne ""} {
            lappend args -timezone $timezone
        }
        clock add $time {*}$args
    }

    # Create traces for linked variables.
    proc Create_variable_traces {win} {
        variable ${win}::numvar
        variable ${win}::textvar

        # Create traces for the numeric and textual variables, if they exist.
        foreach {var type} [list $numvar integer $textvar string] {
            if {$var ne ""} {
                trace add variable $var {write unset}\
                        [list [namespace current]::Trace_variable $win $type]
            }
        }
    }

    # Delete traces for linked variables.
    proc Delete_variable_traces {win} {
        variable ${win}::numvar
        variable ${win}::textvar

        # Delete traces for the numeric and textual variables.
        foreach {var type} [list $numvar integer $textvar string] {
            if {$var ne ""} {
                trace remove variable $var {write unset}\
                        [list [namespace current]::Trace_variable $win $type]
            }
        }
    }

    # Variable write/unset handler.
    proc Trace_variable {win type varname _ op} {
        upvar #0 $varname var

        # Handle the variable operation.  Determine if this operation is valid.
        set reject false
        if {$op eq "write"} {
            # Validate on write.  Convert the time to an integer so the time
            # string can be regenerated below.
            if {$type eq "integer"} {
                if {![string is integer -strict $var]} {
                    set reject true
                } else {
                    set time $var
                }
            } elseif {[catch {Scan $win $var} time]} {
                set reject true
            }
        } elseif {$op eq "unset"} {
            # Prevent unsetting the variable.
            set reject true
        }

        # If the variable change is rejected, restore the old value.  If the
        # variable change is accepted, update the widget text to match.
        if {$reject} {
            if {$type eq "integer"} {
                set var [Scan $win [$win get]]
            } else {
                set var [$win get]
            }
        } else {
            # Update the widget, using the correct time format.
            $win set [Format $win $time]

            # Ensure string variables are canonicalized.
            if {$type eq "string"} {
                set var [$win get]
            }
        }
    }

    # Command rename/delete handler.
    proc Trace_command {old_win new_win op} {
        # Strip namespace qualifiers from the old and new command names.
        set old_win [namespace tail $old_win]
        set new_win [namespace tail $new_win]

        # Delete the old variable traces.
        Delete_variable_traces $old_win

        # Create new traces if the widget is being renamed not deleted, and
        # create a new namespace for the state variables.  IMPORTANT: Tk itself
        # doesn't seem to properly support widget renaming, so things are likely
        # to break anyway.
        if {$op eq "rename"} {
            # Copy variables into the new namespace.
            namespace eval $new_win
            foreach var [info vars ${old_win}::*] {
                set [namespace current]::${new_win}::[namespace tail $var]\
                        [set $var]
            }

            # Create variable traces.
            Create_variable_traces $new_win
        }

        # Delete the old namespace.
        namespace delete $old_win
    }

    # Separate internal options from options for the spinbox.
    proc Scan_options {args} {
        # Set some defaults.
        dict set options timebox -base 0
        dict set options timebox -timezone ""
        dict set options timebox -command [namespace current]::Noop
        dict set options spinbox -width 0
        dict set options spinbox -font Courier

        # Separate time-specific options from generic spinbox options.
        foreach {option value} $args {
            if {$option in {-variable -textvariable -format -value
                            -base -timezone -command -help}} {
                dict set options timebox $option $value
            } else {
                dict set options spinbox $option $value
            }
        }

        # If requested, return a help string.
        if {[dict exists $options timebox -help]} {
            error "timebox-specific options:\
                 \n  -variable    : Specify linked integer time variable.\
                 \n  -textvariable: Specify linked string time variable.\
                 \n  -format      : Set format string.\
                 \n  -value       : Set initial value.\
                 \n  -base        : Base time when date fields are not used.\
                 \n  -timezone    : Time zone.\
                 \n  -command     : Script to execute on value change.\
                 \n  -help        : Print this message.\
                 \nAll other options are passed through to spinbox.\
                 \nFormat codes:\
                 \n  %Y: four-digit year    (2007)\
                 \n  %b: month of year      (Jan-Dec)\
                 \n  %m: month of year      (01-12)\
                 \n  %a: day of week        (Sun-Sat)\
                 \n  %d: day of month       (01-31)\
                 \n  %P: half-day indicator (am,pm)\
                 \n  %p: half-day indicator (AM,PM)\
                 \n  %H: military hour      (00-23)\
                 \n  %I: civilian hour      (12-11)\
                 \n  %M: minute of hour     (00-59)\
                 \n  %S: second of minute   (00-59)\
                 \n  %%: literal %"
        }

        # Require -format.
        if {![dict exists $options timebox -format]} {
            error "-format required but not specified"
        }

        # Validate -timezone.
        set timezone [dict get $options timebox -timezone]
        if {[catch {clock format 0 -timezone $timezone} error]} {
            error $error
        }

        # Return the internal and generic spinbox options.
        return $options
    }

    # Expand the format string.
    proc Expand_format {fmt} {
        variable Codes

        # Ensure at least one format code is given.
        if {![regexp {(?:^|[^%])(?:%%)*%([^%])} $fmt]} {
            error "invalid format string \"$fmt\": must contain at least\
                   one format code"
        }

        # Iterate through all format codes in the format string.
        set old_end 0
        set index 0
        set seen_ampm false
        set seen_12hr false
        foreach {beg end} [concat {*}[regexp -inline -indices -all %. $fmt] x] {
            if {$beg ne "x"} {
                # Get the format character.
                set char [string range $fmt [+ $beg 1] $end]

                # Skip %%.
                if {$char eq "%"} {
                    continue
                }

                # Keep track of %p, %P, and %I.
                if {$char in {p P}} {
                    set seen_ampm true
                } elseif {$char eq "I"} {
                    set seen_12hr true
                }
            }

            # If literal text precedes this code or is at the end of the format
            # string, add it to the expanded form.
            if {($beg eq "x" && $old_end < [string length $fmt])
             || ($beg ne "x" && $beg > $old_end)} {
                if {$beg eq "x"} {
                    set str [string range $fmt $old_end end]
                } else {
                    set str [string range $fmt $old_end [- $beg 1]]
                }
                set str [string map {%% %} $str]
                set len [string length $str]
                lappend expfmt [list $index [+ $index $len -1] literal $str]
                incr index $len
            }

            # If not at the end of the string, add this code to the result.
            if {$beg ne "x"} {
                set char [string index $fmt [+ $beg 1]]
                set code [lsearch -exact -index 0 $Codes $char]
                if {$code < 0} {
                    foreach code $Codes {
                        lappend valid %[lindex $code 0]
                    }
                    set valid [join [lsort $valid] ", "]
                    error "unsupported format code \"%$char\": must be one of\
                           $valid, or %%"
                }
                set len [lindex $Codes $code 1]
                lappend expfmt [list $index [+ $index $len -1] field $code]
                incr index $len

                # Track the ending index of this format code in order to detect
                # sequences of literal text.
                set old_end [+ $end 1]
            }
        }

        # It's illegal to have a 12-hour clock without AM/PM.
        if {$seen_12hr && !$seen_ampm} {
            error "invalid format string \"$fmt\": %I cannot be specified\
                   without also using %p or %P"
        }

        # Return the expanded format.
        return $expfmt
    }

    # Return the index of the least significant field within a subrange.
    proc Lsig_field {expfmt {beg 0} {end inf}} {
        variable Codes

        # Search through the specified subrange of the expanded format for the
        # highest-numbered (i.e. least significant) format code.
        set lsig_code -1
        set lsig_index -1
        set index 0
        foreach {field_beg field_end type code} [concat {*}$expfmt] {
            if {$type eq "field" && $code > $lsig_code
             && $beg <= $field_end && $field_beg <= $end} {
                set lsig_code $code
                set lsig_index $index
            }
            incr index
        }

        # Return the index of the least significant format code in the expanded
        # format specification.  Return -1 if no format codes appeared in the
        # specified subrange.
        return $lsig_index
    }

    # timebox validator.  This procedure gives the timebox its clock behavior.
    proc Validate {win validate edit current edit_beg action command} {
        variable Codes
        variable ${win}::fmt
        variable ${win}::expfmt
        variable ${win}::numvar
        variable ${win}::textvar

        # Make the action string a little bit more palatable.
        if {$action in {-1 0 1}} {
            set action [lindex {forced delete insert} [+ $action 1]]
        }

        # Forced validation always succeeds.
        if {$action eq "forced"} {
            return true
        }

        # Handle delete simply by backing up the cursor.
        if {$action eq "delete"} {
            $win icursor $edit_beg
            return false
        }

        # Determine how many characters are being edited.
        set edit_len [string length $edit]
        set edit_end [+ $edit_beg $edit_len -1]

        # Identify the current field.  If a selection is present and using the
        # spinners, use the least significant field within the selection.
        # Otherwise, use the nearest time field, if not already inside one.
        if {$action in {up down} && [$win selection present]} {
            # Use the least significant field in the selection, or if that
            # fails, use the least significant field in the whole widget.
            set field_idx [Lsig_field $expfmt [$win index sel.first]\
                                              [$win index sel.last]]
            if {$field_idx == -1} {
                set field_idx [Lsig_field $expfmt]
            }
            set event seek_lsig_field
        } else {
            # Find the time field nearest the cursor.  Skip over literals.  When
            # doing an insert, also skip over fields of type "other".
            set field_idx 0
            set seek_forward false
            set valid_cond {($type ne "literal" && ($action ne "insert"
                            || [lindex $Codes $code 3] ne "other"))}
            foreach {field_beg field_end type code} [concat {*}$expfmt] {
                # Check to see if this field is valid.
                if $valid_cond {
                    # If seeking the first or next valid field, stop now.
                    # Otherwise remember that this field is valid so that it
                    # later will be possible to rewind to it.
                    if {$seek_forward} {
                        break
                    } else {
                        set valid_idx $field_idx
                    }
                }

                # Check to see if this is the field in which the cursor lies.
                if {$field_beg <= $edit_beg && $edit_beg <= $field_end} {
                    # Also check the validity of this field.
                    if $valid_cond {
                        # The cursor is inside a valid field.  Stop now.
                        set event inside_field
                        break
                    } else {
                        # The cursor is inside an invalid field, so rewind to
                        # the previous valid field, or if this is the first
                        # field, advance to the first valid field.  If
                        # performing an insert, always advance.
                        set event seek_near_field
                        if {$action ne "insert" && [info exists valid_idx]} {
                            set field_idx $valid_idx
                            break
                        } else {
                            set seek_forward true
                        }
                    }
                }
                incr field_idx 1
            }

            # If the cursor is at the end of the string, it won't be inside any
            # time fields, so rewind to the last valid time field.
            if {$field_idx == [llength $expfmt]} {
                for {incr field_idx -1} {$field_idx > 0} {incr field_idx -1} {
                    lassign [lindex $expfmt $field_idx] field_beg\
                                                        field_end type code
                    if $valid_cond {
                        break
                    }
                }
                set event seek_near_field
            }
        }

        # Get information about the current field.
        lassign [lindex $expfmt $field_idx] field_beg field_end _ code
        lassign [lindex $Codes $code] char width unit type valid

        # Adjust the edit beginning and ending positions based on the insertion
        # cursor's relationship to the current field.
        if {$event eq "seek_lsig_field"} {
            # Select entire field.
            set edit_beg $field_beg
            set edit_end $field_end
        } elseif {$event eq "seek_near_field"} {
            # Determine whether it was necessary to seek forward or backward.
            if {$edit_beg < $field_beg} {
                # Advance.
                set edit_beg $field_beg
                set edit_end [+ $field_beg $edit_len -1]
            } else {
                # Don't allow inserting after the end of the field.
                if {$action eq "insert"} {
                    return false
                }

                # Rewind.
                set edit_beg [- $field_end $edit_len -1]
                set edit_end $field_end
            }
        }

        # Figure out the new text after the edit.
        if {$action in {up down}} {
            # Forbid years greater than 9999.  (Y10K bug oh no!)
            if {$action eq "up" && [lindex $Codes $code 0] eq "Y"
             && [string range $current $field_beg $field_end] eq "9999"} {
                return false
            }

            # Determine the step value.
            if {$unit eq "ampm"} {
                # Special-case AM/PM to get less surprising behavior.
                if {[string index $current $field_beg] in {a A}} {
                    set step {12 hour}
                } else {
                    set step {-12 hour}
                }
            } else {
                # Use negative step for down and positive step for up.
                set step [list [dict get {down -1 up 1} $action] $unit]
            }

            # Add the step value to the current time.
            set time [Add $win [Scan $win $current] {*}$step]

            # Generate a new time string with the correct format.
            set new [Format $win $time]
        } else {
            # Possibly try repeatedly.
            set chances 1
            set orig $current
            for {set round 0} {$round < $chances} {incr round} {
                # Perform the change.
                set new [string replace $current $edit_beg $edit_end $edit]

                # For string fields, use the first valid string with this
                # prefix.
                if {$type eq "str"} {
                    # Get the prefix.
                    set pre [string range $new $field_beg $edit_end]
                    set len [- $edit_end $field_beg -1]
                    set found false

                    # Search the valid string list for this prefix.
                    foreach str $valid {
                        if {[string equal -nocase -length $len $pre $str]} {
                            set new [string replace $new\
                                    $field_beg $field_end $str]
                            set found true
                            break
                        }
                    }

                    # If no match was found, and if inserting, fail.
                    if {!$found} {
                        return false
                    }
                }

                # Canonicalize the time string.
                if {[catch {Scan $win $new} time]} {
                    # Abort if the format is bad.
                    return false
                }
                set new [Format $win $time]

                # If this results in the edit itself being mutated, try again
                # but with the other fields zeroed out.
                if {![string equal -nocase\
                        [string range $new $edit_beg $edit_end] $edit]} {
                    # If this is the second failure, abort.
                    if {$chances == 2} {
                        return false
                    }

                    # Zero stuff which may be interfering.
                    if {$type eq "int"} {
                        # Replace subsequent digits of this field with zeroes.
                        set current [string replace $orig\
                                [+ $edit_end 1] $field_end\
                                [string repeat 0 [- $field_end $edit_end]]]
                    } else {
                        # Replace the day-of-month field(s) with 01.
                        set current $orig
                        foreach {day_beg day_end day_type day_code}\
                                [concat {*}$expfmt] {
                            if {$day_type eq "field"
                             && [lindex $Codes $day_code 0] eq "d"} {
                                set current [string replace $current\
                                        $day_beg $day_end 01]
                            }
                        }
                    }

                    # Now try again.
                    incr chances
                }
            }
        }

        # Write the new time string to the widget.
        $win set $new

        # Sync linked variables.  Temporarily disable variable traces while
        # writing to the linked variables.
        if {$numvar ne "" || $textvar ne ""} {
            Delete_variable_traces $win
            foreach {var value} [list $numvar $time $textvar $new] {
                if {$var ne ""} {
                    set $var $value
                }
            }
            Create_variable_traces $win
        }

        # When using insert mode, advance the cursor past the separator.  When
        # using the spinners, highlight the current field.
        if {$action eq "insert"} {
            # Figure out where the cursor should be moved.
            set cursor [+ $edit_end 1]

            # If the cursor moved past the end of the field, move it to the
            # beginning of the next field, skipping literals.
            if {$cursor > $field_end} {
                set found false
                foreach {field_beg field_end type code} [concat {*}$expfmt] {
                    if {$found && $type eq "field"} {
                        set cursor $field_beg
                        break
                    }
                    if {$field_beg <= $cursor && $cursor <= $field_end} {
                        if {$type eq "field"} {
                            break
                        } else {
                            set found true
                        }
                    }
                }
            }
            
            # Move the cursor.
            $win selection clear
            $win icursor $cursor
        } elseif {$action in {up down}} {
            # Highlight (select) the current field.
            focus $win
            $win selection range $field_beg [+ $field_end 1]
            $win icursor [+ $field_end 1]
        }

        # Reinstall the validator.
        $win config -validate $validate

        # Invoke the on-update command.
        uplevel #0 $command $time

        # Don't allow Tk to set the widget value; it's already done.
        return false
    }

    # timebox widget command.
    proc widget {win args} {
        # Get options, divided into timebox and spinbox groups.  Make all
        # spinbox options into local variables.
        set options [Scan_options {*}$args]
        set sbopts [dict get $options spinbox]
        set myopts [dict get $options timebox]

        # Expand the format string.
        set fmt [dict get $myopts -format]
        set expfmt [Expand_format $fmt]

        # Get linked variables.
        foreach {option var} {-variable numvar -textvariable textvar} {
            if {[dict exists $myopts $option]} {
                set $var ::[dict get $myopts $option]
            } else {
                set $var ""
            }
        }

        # Get other options.
        set base [dict get $options timebox -base]
        set timezone [dict get $options timebox -timezone]
        set cmd [dict get $options timebox -command]

        # Create a namespace in which to store widget state.
        namespace eval $win {}
        foreach var {fmt expfmt numvar textvar base timezone} {
            set [namespace current]::${win}::$var [set $var]
        }

        # Prepare to delete the namespace if anything bad happens.
        if {[catch {
            # Determine the initial time value.
            if {[dict exists $myopts -value]} {
                set initval [dict get $myopts -value]
            } elseif {$numvar ne "" && [info exists $numvar]} {
                set initval [set $numvar]
            } elseif {$textvar ne "" && [info exists $textvar]} {
                set initval [set $textvar]
            } else {
                set initval 0
            }
            if {![string is integer -strict $initval]} {
                set initval [Scan $win $initval]
            }
            if {$numvar ne ""} {
                set $numvar $initval
            }
            if {$textvar ne ""} {
                set $textvar [Format $win $initval]
            }

            # Create the spinbox widget.
            set validate [string map {% %%} [namespace current]::Validate]
            set lcmd [list $cmd]
            spinbox $win {*}$sbopts -validate key -command "[list $validate] %W\
                    \[%W cget -validate\] {} %s \[%W index insert\] %d $lcmd"\
                    -validatecommand [list $validate %W %v %S %s %i %d $cmd]

            # Set traces on linked variables.
            Create_variable_traces $win

            # Arrange for the traces to be updated or removed when the widget is
            # renamed or deleted.
            trace add command $win {rename delete}\
                    [namespace current]::Trace_command

            # Set the spinbox's initial text, and position the cursor at the
            # start of the least significant field.
            $win set [Format $win $initval]
            $win icursor [lindex $expfmt [Lsig_field $expfmt] 0]
        } result err_opts]} {
            # On error, delete the namespace and abort.
            namespace delete $win
            return -options $err_opts $result
        }

        # Return the widget path to the caller.
        return $win
    }
}

# Make the timebox widget command available under the name "timebox".
interp alias {} timebox {} timebox::widget

# Example!
proc p {args} {puts $args}
package require Tk
pack [timebox .t -variable foo -format "%a %b %d %I:%M:%S %p %Y" -timezone :UTC]
pack [timebox .t2 -variable foo -format "%Y-%m-%d %H:%M:%S" -command p] -fill x
pack [entry .e -textvariable foo] -fill x
wm resizable . false false

# vim: set ts=4 sts=4 sw=4 tw=80 et ft=tcl: