Rubik's Cube Timer

Keith Vetter 2016-05-22 : There are probably a gazillion Rubik Cube timer programs out there for all platforms, but here's my version with a few tweaks for exactly what I wanted. For example, you can display it in a minimalistic version or add more panels with more functionality; you can select different categories to time, e.g. 3x3x3 or 4x4x4, or even add your own categories.

RubiksTimerScreenShot

##+##########################################################################
#
# RubiksTimer.tcl -- rubik's cube timer
# by Keith Vetter 2016-05-04
#
package require Tk
package require Img

set S(title) "Rubik's Cube Timer"
set S(font) {Helvetica 124 bold}
set S(display,text) ""
set S(scramble) ""
set S(scrambles,old) {}
set S(state) idle

proc DoDisplay {} {
    global S

    wm title . $S(title)

    frame .left -bg navyblue -bd 2m
    frame .left.bottom -bg navyblue
    pack .left -side left -fill both -expand 1
    pack .left.bottom -side bottom -fill both -expand 1

    ::ttk::frame .history -borderwidth 5 -relief raised
    ::History::DoDisplay .history

    if {"displayFont" in [font names]} { font delete displayFont }
    font create displayFont {*}[font actual $S(font)]
    set S(display,text) [PrettyTenths 0 1]
    label .ticks -font displayFont -textvariable S(display,text) -background cyan
    pack .ticks -in .left -side top -fill x

    label .scramble -textvariable S(scramble) -bd 2 -relief ridge
    lappend S(scrambles,old) $S(scramble)
    set S(scramble) [Scramble]
    pack .scramble -in .left -side top -fill x -pady {0 2m}

    button .start -text "Start" -command ToggleTimer -font {Helvetica 48 bold}
    pack .start -in .left.bottom -side left -expand 1 -fill x -padx 1i -pady 1m

    button .showStart -image ::bmp::chevrons_down -padx 1m -pady 1m -command ToggleStartButton
    button .hideStart -image ::bmp::chevrons_up -padx 1m -pady 1m -command ToggleStartButton
    button .showHistory -image ::bmp::chevrons -padx 1m -command ToggleHistoryPanel
    place .showStart -in .ticks       -relx 1 -rely 1 -x -2m -y -2m -anchor se
    place .hideStart -in .left.bottom -relx 1 -rely 0 -x -2m -anchor ne
    place .showHistory -in .left.bottom -relx 1 -rely 1 -x -2m -y -2m -anchor se

    ToggleHistoryPanel
    bind .start <Button-1> {DoButton down}
    bind .ticks <Button-1> {DoButton down}
    bind .ticks <ButtonRelease-1> {DoButton up}
    bind all <Key-Escape><Key-Escape><Key-Escape> { ::History::Erase 0 }

    focus .start
}
proc PrettyTenths {tenths {long_format 0}} {
    if {$tenths eq ""} { return "" }
    set minutes [expr {$tenths / 600}]
    set tenths [expr {$tenths % 600}]
    set seconds [expr {$tenths / 10}]
    set tenths [expr {$tenths % 10}]

    if {$long_format} {
        return [format "%02d:%02d.%d" $minutes $seconds $tenths]
    }
    if {$minutes > 0} {
        return [format "%d:%02d.%d" $minutes $seconds $tenths]
    }
    return [format "%d.%d" $seconds $tenths]
}
proc DoButton {how} {
    global S

    if {$how eq "down" && $S(state) eq "idle"} {
        ResetTimer
    }
    if {$how eq "up"} {
        ToggleTimer
    }
}
proc ToggleTimer {} {
    global S
    focus .start
    if {$S(state) eq "idle"} {
        set S(start) [clock milliseconds]
        set S(state) "timing"
        .start config -text "Stop"
        set S(aid) [after idle Timer]
    } else {
        after cancel $S(aid)
        set S(state) "idle"
        set S(scramble) [Scramble]
        .start config -text "Start"
        ::History::AddTime $S(tenths)
    }
}
proc ResetTimer {} {
    set ::S(start) [clock milliseconds]
    set ::S(display,text) [PrettyTenths 0 1]
}
proc Timer {} {
    global S
    if {$S(state) ne "timing"} return
    set S(now) [clock milliseconds]
    set S(tenths) [expr {($S(now) - $S(start)) / 100}]
    set S(display,text) [PrettyTenths $S(tenths) 1]

    set S(aid) [after 100 Timer]
}
proc Scramble {{length 25}} {
    set MOVES {R L U D F B}
    set OPPOSITES {"" "" R L L R U D D U F B B F}

    set scramble {}
    set last ""
    set last2 ""
    for {set i 0} {$i < $length} {incr i} {
        while {1} {
            set move [lindex $MOVES [expr {int(rand() * 6)}]]
            if {$move eq $last} continue
            if {$move eq [dict get $OPPOSITES $last] && $move eq $last2} continue

            set last2 $last
            set last $move
            break
        }
        set modifier [lindex {"" "\u2019" "\uB2"} [expr {int(rand() * 3)}]]
        lappend scramble $move$modifier
    }
    return $scramble
}
proc ToggleHistoryPanel {} {
    lower .showStart
    if {[winfo ismapped .history]} {
        pack forget .history
        raise .hideStart
        raise .showHistory
    } else {
        pack .history -side left -fill y
        lower .hideStart
        lower .showHistory
    }
}
proc ToggleStartButton {} {
    if {[winfo ismapped .left.bottom]} {
        pack forget .left.bottom
        raise .showStart
    } else {
        pack .left.bottom -fill x
        lower .showStart
    }
}
proc ToggleErasePanel {} {
    set f .eraseFrame
    if {[winfo exists $f] && [winfo ismapped $f]} {
        grid forget $f
        grid .history.hideHistory -row 100 -column 0 -pady 1m -padx 1m -sticky w
        grid .history.showErase -row 100 -column 1 -pady 1m -padx 1m -sticky e
        foreach w [winfo child .history.lastTimes] {
            $w config -borderwidth 1 -relief flat
            destroy $w.x
        }
    } else {
        foreach w [winfo child .history.lastTimes] {
            regexp {[0-9]+$} $w who
            $w config -borderwidth 1 -relief solid
            label $w.x -image ::img::x -bd 1 -relief solid
            bind $w.x <ButtonRelease-1> [list ::History::Erase $who]

            place $w.x -relx 1 -y 0 -anchor ne
        }
        grid forget .history.hideHistory
        grid forget .history.showErase
        grid $f -in .history -row 1 -column 2 -rowspan 102 -sticky ns -padx 1m
    }
    focus .start
}
proc UniqueTrace {var func} {
    foreach old [trace info variable $var] {
        trace remove variable $var {*}$old
    }
    if {$func ne ""} {
        trace variable $var w $func
    }
}

image create bitmap ::bmp::chevrons -data {
    #define chevron_width 14
    #define chevron_height 9
    static char chevron_bits = {
        0x33, 0x03, 0x66, 0x06, 0xcc, 0x0c, 0x98, 0x19, 0x30,
        0x33, 0x98, 0x19, 0xcc, 0x0c, 0x66, 0x06, 0x33, 0x03
    }
}
image create bitmap ::bmp::chevrons_left -data {
    #define chevron_width 14
    #define chevron_height 9
    static char chevron_bits = {
        0x30, 0x33, 0x98, 0x19, 0xcc, 0x0c, 0x66, 0x06, 0x33,
        0x03, 0x66, 0x06, 0xcc, 0x0c, 0x98, 0x19, 0x30, 0x33
    }
}
image create bitmap ::bmp::chevrons_up -data {
    #define chevrons_up_width 9
    #define chevrons_up_height 14
    static char chevrons_up_bits = {
        0x10, 0x00, 0x38, 0x00, 0x6c, 0x00, 0xc6, 0x00, 0x93, 0x01,
        0x39, 0x01, 0x6c, 0x00, 0xc6, 0x00, 0x93, 0x01, 0x39, 0x01,
        0x6c, 0x00, 0xc6, 0x00, 0x83, 0x01, 0x01, 0x01
    }
}
image create bitmap ::bmp::chevrons_down -data {
    #define chevrons_down_width 9
    #define chevrons_down_height 14
    static char chevrons_down_bits = {
        0x01, 0x01, 0x83, 0x01, 0xc6, 0x00, 0x6c, 0x00, 0x39, 0x01,
        0x93, 0x01, 0xc6, 0x00, 0x6c, 0x00, 0x39, 0x01, 0x93, 0x01,
        0xc6, 0x00, 0x6c, 0x00, 0x38, 0x00, 0x10, 0x00
    }
}
image create photo ::img::x -data {
    iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAAGzVWdFAAAABGdBTUEAAYagMeiWXwAAADFJREFUCJljYG
    Bg+M+ADP6jMP6jS/3HUIeu9D8jsiwTsgEwDiOMQDYEhc+IzUVE6QQAxBwP/TlB3jEAAAAASUVORK5CYII=
}
proc About {} {
    tk_messageBox -message "$::S(title)" -detail "by Keith Vetter\nMay 2016" -parent . \
        -title "About $::S(title)"
}
namespace eval ::History {
    variable times
    variable H
    variable rc_file "~/.rubikstimer_rc"
    variable categories {3x3x3 2x2x2 4x4x4 Cross F2L}
    variable category 3x3x3
    variable undo {}
    variable minimums

    unset -nocomplain times
    if {$::tcl_interactive} { lappend categories debug }
    foreach i $categories { set times($i) {} }
    set minimums(3x3x3) 10
    set minimums(debug) 10

    unset -nocomplain H
    set H(best) ?
    set H(5,ave) ?
    set H(5,times) ?
    set H(lifetime,ave) ?
    set H(last,count) 10
}
proc ::History::DoDisplay {f} {
    variable H

    set args {-borderwidth 2 -relief sunken -anchor c -width 5}

    tk_optionMenu $f.category ::History::category {*}$::History::categories

    ::ttk::label $f.l_best -text Best: -anchor e
    ::ttk::label $f.best -textvariable ::History::H(best) {*}$args
    ::ttk::label $f.l_lifetime -text Average:
    ::ttk::label $f.lifetime -textvariable ::History::H(lifetime,ave) {*}$args
    ::ttk::label $f.l_5 -text "Last 5: "
    ::ttk::label $f.5 -textvariable ::History::H(5,ave) {*}$args
    ::ttk::label $f.l_drop -text "Drop hi/lo: "
    ::ttk::label $f.drop -textvariable ::History::H(drop,ave) {*}$args
    ::ttk::frame $f.lastTimes -borderwidth 2 -relief sunken

    for {set i 0} {$i < $H(last,count)} {incr i} {
        set w $f.lastTimes.$i
        ::ttk::label $w -textvariable ::History::H(last,$i) -anchor c -borderwidth 1 -relief flat
        bind $w <Double-1>  [list ::History::Erase $i]
        grid $w -row [expr {$i / 2}] -column [expr {$i % 2}] -sticky ew
    }
    grid columnconfigure $f.lastTimes all -weight 1 -uniform same

    button $f.showErase -image ::bmp::chevrons -padx 1m -command ToggleErasePanel
    button $f.hideHistory -image ::bmp::chevrons_left -padx 1m -command ToggleHistoryPanel

    grid $f.category - - -sticky ew -pady {1m 2m}
    grid $f.l_best $f.best
    grid $f.l_lifetime $f.lifetime
    grid $f.l_5 $f.5
    grid $f.l_drop $f.drop
    grid $f.lastTimes - -pady 2m -sticky ew
    grid rowconfigure $f 99 -weight 1
    grid $f.hideHistory -row 100 -column 0 -pady 1m -padx 1m -sticky w
    grid $f.showErase -row 100 -column 1 -pady 1m -padx 1m -sticky e

    UniqueTrace ::History::category ::History::Tracer
    UniqueTrace ::History::undo ::History::Tracer

    set ff .eraseFrame
    ::ttk::frame $ff

    ::ttk::button $ff.about -text About -command About
    ::ttk::button $ff.erase_last -text "Erase Last" -command {::History::Erase 0}
    ::ttk::button $ff.erase_all -text "Erase All" -command {::History::Erase all}
    ::ttk::button $ff.undo -text "Undo Erase" -command ::History::Undo -state disabled
    grid $ff.about -sticky ew
    grid $ff.erase_last -sticky ew
    grid $ff.erase_all -sticky ew
    grid $ff.undo -sticky ew

    button $ff.hideEraseFrame -image ::bmp::chevrons_left -padx 1m -command ToggleErasePanel
    grid rowconfigure $ff 99 -weight 1
    grid $ff.hideEraseFrame - -row 100 -pady 1m -padx 1m -sticky e
}
proc ::History::Tracer {var1 var2 op} {
    if {$var1 eq "::History::category"} {
        wm title . "$::S(title) -- $::History::category"
        ::History::ComputeStats
        return
    }
    if {$var1 eq "undo" && [winfo exists .eraseFrame.undo]} {
        .eraseFrame.undo config -state [expr {$::History::undo eq "" ? "disabled" : "normal"}]
        return
    }
}
proc ::History::Erase {which} {
    variable times
    variable category
    variable undo

    if {$which eq "last"} {
        set which 0
    }

    lappend undo [list $category $times($category)]
    if {$which eq "all"} {
        set times($category) {}
    } else {
        set times($category) [lreplace $times($category) end-$which end-$which]
    }
    ::History::ComputeStats
    after idle ::History::SaveStats
}
proc ::History::Undo {} {
    variable times
    variable undo

    if {$undo eq {}} return
    lassign [lindex $undo end] category data
    set undo [lrange $undo 0 end-1]
    set times($category) $data
    ::History::ComputeStats
    after idle ::History::SaveStats
}
proc ::History::AddTime {tenths} {
    variable times
    variable category
    variable minimums

    set minimum 0
    if {[info exists minimums($category)]} {
        set minimum $minimums($category)
    }

    if {[string is double -strict $minimum] && $tenths < 10*$minimum} return
    lappend times($category) $tenths
    ::History::ComputeStats
    after idle ::History::SaveStats
}
proc ::History::ComputeStats {} {
    variable times
    variable H
    variable category

    if {$times($category) eq ""} {
        set H(best) [set H(lifetime,ave) [set H(5,ave) [PrettyTenths 0]]]
    } else {
        set H(best) [PrettyTenths [lindex [lsort -integer $times($category)] 0]]
        set H(lifetime,ave) [PrettyTenths [expr ([join $times($category) +]) / [llength $times($category)]]]
        set last_5 [lrange $times($category) end-4 end]
        set H(5,ave) [PrettyTenths [expr round(([join $last_5 +]) / 5.0)]]
        if {[llength $last_5] < 5} {
            set H(drop,ave) -
        } else {
            set mid_3 [lrange [lsort -integer $last_5] 1 end-1]
            set H(drop,ave) [PrettyTenths [expr round(([join $mid_3 +]) / 3.0)]]
        }
    }

    for {set i 0} {$i < $H(last,count)} {incr i} {
        set tenths [lindex $times($category) end-$i]
        set H(last,$i) [PrettyTenths $tenths]
    }
}
proc ::History::NewMode {newMode} {
    variable categories
    variable times

    if {$newMode in $categories} return
    lappend categories $newMode
    set w [winfo child .history.category]
    $w add radiobutton -label $newMode -variable [$w entrycget 0 -variable]
    set times($newMode) {}
}
proc ::History::ReadStatsFromRCFile {} {
    variable times
    variable categories

    if {! [file exists $::History::rc_file]} return
    if {[catch {set fin [open $::History::rc_file r]}]} return

    set lines [split [string trim [read $fin]] \n]
    close $fin

    foreach line $lines {
        if {[regexp {^current: (.*)$} $line . category]} {
            ::History::NewMode $category
            set ::History::category $category
        } elseif {[regexp {^([a-zA-Z0-9_-]+): ?([0-9 ]+)$} $line . category data]} {
            ::History::NewMode $category
            set times($category) [string trim $data]
        }
    }
}
proc ::History::SaveStats {} {
    variable times

    set output {}
    foreach category [lsort -dictionary [array names times]] {
        if {$times($category) ne {}} {
            lappend output "$category: $times($category)"
        }
    }
    if {$output eq ""} {
        file delete $::History::rc_file
    } else {
        set n [catch {set fout [open $::History::rc_file w]}]
        if {! $n} {
            puts $fout [join $output \n]
            puts $fout "current: $::History::category"
            close $fout
        }
    }
}

################################################################

DoDisplay
::History::ReadStatsFromRCFile
if {$tcl_interactive} { set ::History::category debug }
::History::ComputeStats
return