Window manager for CloudTk

sbron (2023-12-03) When running a CloudTk application without a window manager, popup windows and confirmation dialogs will look unfamiliar to the user due to the lack of window decorations. The code shown below can be used to add window decorations using only plain Tcl/Tk. The existing code doesn't need to be changed, meaning it will also work for built-in dialogs such as tk_messageBox, tk_chooseColor, and tk fontchooser.

At the moment, this is just a simple proof of concept. There are several limitations, including but not limited to:

  • Keyboard bindings have not been implemented
  • No support for resizing windows
  • A grab on a toplevel window disables interaction with the window decoration (moving, closing via the X).

Feel free to add any improvements directly to the presented code.

# A simple window manager in Tcl/Tk for use with CloudTk
# Copyright (c) Schelte Bron.  Freely redistributable.

namespace eval wm {
    package require Tk

    # Move the original toplevel and wm commands into the wm namespace
    if {[namespace which toplevel] eq "::toplevel"} {
        rename toplevel toplevel
    }
    if {[namespace which wm] eq "::wm"} {
        rename wm wm
    }
    # Get rid of the tk::toplevel command
    catch {rename ::tk::toplevel {}}

    # Create a new wm command
    namespace ensemble create -unknown ::wm::unknown -subcommands {
        deiconify geometry iconphoto maxsize minsize
        overrideredirect stackorder title withdraw
    }

    namespace export toplvl

    variable map {}

    # A default icon
    variable icon [image create photo -data {
        iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABy0lEQVQ4y42TzWvT
        cBjHv8kvMUvS6nBrY23pVhCEefLgkIEd7KB0h122m3p2HgQd/gdzd8d69OBfIAju
        INSefIHpYC+ttbPsfUZsFtKmaWaa7OfFw+hSkuf2vH2+38PzEAQHc/FSdHpwIPo4
        nRoca1l/t1zXMxE2YrHLM/mXz9T3b57S10vTNDYgPz/b54KWF+Zn87dGiNJuqrjA
        MfC8U+bsDNtrWZbFyYUXT/JjN/uVanUXHZfi08q+qhv22zAAOTs++ih7O62UNkow
        Gg7cjodSVfsK4CgQIMnixMP7uQlT30Plp46hpIjNH/VWuaq/AmAFApJXlTs3rscj
        5e874AjA88ByoVbUjo1i96wf4Eoul50SeQflioprw1FsVuqtb2tH59R7AYgk8rKm
        aTAtF2Ifi+VC7YPRsIp+brsBDCEkEYlI5PefNgTeRW1HP/m8sv0OoFYYgJBKJR7c
        vZeN7f7qgOc5SJJAWJYdAdDnByBdudtoNNfb7ZNRz6PDHbuJvUPz4+ra/twppYYf
        wO8SM4XCFzGZVEyOMDg4rIuu52UAqGHPvx9AQhCExXg8vggg8b+GsA4MAAal1LRt
        G0HKPZ/JcZyS4zjpILv/AO1ascYM+PVRAAAAAElFTkSuQmCC
    }]
}

# Create a new toplevel command
proc wm::toplvl {name args} {
    variable toplvl
    variable map
    variable icon
    if {[dict exists $args -class]} {
        if {[dict get $args -class] in {ComboboxPopdown}} {
            tailcall toplevel $name {*}$args
        }
    }
    # Create a temporary frame to do all of the error checking and be able
    # to use the winfo command to obtain useful bits of information
    frame $name
    set parent [winfo parent $name]
    set child [winfo name $name]
    destroy $name
    set w [toplevel $parent.toplvl-[incr toplvl]]
    label $w.icon -image $icon
    label $w.title -text $child -font TkCaptionFont
    label $w.close -text \u274C
    frame $w.frame -container 1
    decorate $w
    toplevel $name {*}$args -use [winfo id $w.frame]
    frame $name.__wm__canary -class WmCanary
    wm overrideredirect $w 1
    bindtags $w.title [list $w.title WmTitlebar $w all]
    bindtags $w.close [list $w.close WmClose $w all]
    dict set map $name $w
    return $name
}

namespace import wm::toplvl
rename toplvl toplevel

proc wm::window {window} {
    variable map
    if {[dict exists $map $window]} {
        return [dict get $map $window]
    } else {
        return $window
    }
}

proc wm::unmanage {win} {
    # Grid
    set slaves [grid slaves $win]
    if {[llength $slaves]} {grid forget {*}$slaves}
    lassign [grid size $win] cols rows
    for {set i 0} {$i < $rows} {incr i} {
        grid rowconfigure $win $i -minsize 0 -pad 0 -uniform {} -weight 0
    }
    for {set i 0} {$i < $rows} {incr i} {
        grid columnconfigure $win $i -minsize 0 -pad 0 -uniform {} -weight 0
    }
    grid propagate $win 1
    # Pack
    set slaves [pack slaves $win]
    if {[llength $slaves]} {pack forget {*}$slaves}
    pack propagate $win 1
    # Place
    set slaves [place slaves $win]
    if {[llength $slaves]} {place forget {*}$slaves}
}

proc wm::decorate {win} {
    unmanage $win
    grid $win.icon $win.title $win.close -sticky ew
    grid $win.icon $win.close -padx 4 -pady 4
    grid $win.frame - - -padx 4 -pady {0 4} -sticky nsew
    grid rowconfigure $win $win.frame -weight 1
    grid columnconfigure $win $win.title -weight 1
}

proc wm::strip {win} {
    unmanage $win
    grid $win.frame -sticky nsew
    grid rowconfigure $win $win.frame -weight 1
    grid columnconfigure $win $win.frame -weight 1
}

# wm command implementation
proc wm::unknown {cmd sub args} {
    # Forward any unknown subcommands to the original wm command
    return [list wm::wm $sub]
}

proc wm::deiconify {window} {
    wm deiconify [window $window]
}

proc wm::geometry {window {geometry ""}} {
    if {[llength [info level 0]] > 2} {
        tailcall wm geometry [window $window] $geometry
    } else {
        tailcall wm geometry [window $window]
    }
}

proc wm::iconphoto {window args} {
    if {[lindex $args 0] eq "-default"} {
        variable icon
        set args [lrange $args 1 end]
    } else {
        set icon ""
    }
    # Find the first matching icon
    foreach img $args {
        if {[image width $img] <= 24 && [image height $img] <= 24} {
            set icon $img
            break
        }
    }
    set win [window $window]
    if {$win ne $window} {
        $win.icon configure -image $icon
    } else {
        tailcall wm {*}[info level 0]
    }
}

proc wm::maxsize {window {width ""} {height ""}} {
    # Not yet implemented
    tailcall wm {*}[info level 0]
}

proc wm::minsize {window {width ""} {height ""}} {
    # Not yet implemented
    tailcall wm {*}[info level 0]
}

proc wm::overrideredirect {window {boolean ""}} {
    set win [window $window]
    if {$win ne $window} {
        if {$boolean eq ""} {
            lassign [grid size $win] cols rows
            return [expr {$cols == 1 && $rows == 1}]
        } elseif {$boolean} {
            strip $win
        } else {
            decorate $win
        }
    } else {
        tailcall wm {*}[info level 0]
    }
}

proc wm::stackorder {window args} {
    variable map
    set revmap {}
    dict for {k v} $map {dict set revmap $v $k}
    return [lmap w [wm stackorder $window {*}$args] {
        if {[dict exists $revmap $w]} {dict get $revmap $w} else {set w}
    }]
}

proc wm::title {window {string ""}} {
    variable map
    if {[dict exists $map $window]} {
        set w [dict get $map $window]
        if {[llength [info level 0]] > 2} {
            $w.title configure -text $string
        } else {
            return [$w.title cget -text]
        }
    } else {
        tailcall wm {*}[info level 0]
    }
}

proc wm::withdraw {window} {
    wm withdraw [window $window]
}

# Bindings
proc wm::Select {w x y} {
    variable State
    set top [winfo toplevel $w]
    raise $top
    set State(toplevel) $top
    set State(pressX) $x
    set State(pressY) $y
    set State(x) [winfo rootx $top]
    set State(y) [winfo rooty $top]
    set State(pressed) 1
    set State(cursor) [$w cget -cursor]
    set State(id) [after 500 [list $w configure -cursor fleur]]
}

proc wm::Drag {w x y} {
    variable State
    if {!$State(pressed)} return

    $w configure -cursor fleur
    set dx [expr {$x - $State(pressX)}]
    set dy [expr {$y - $State(pressY)}]
    set State(pressX) $x
    set State(pressY) $y
    set x [incr State(x) $dx]
    set y [incr State(y) $dy]
    wm geometry $State(toplevel) [format +%d+%d $x $y]
}

proc wm::Release {w x y} {
    variable State
    after cancel $State(id)
    $w configure -cursor $State(cursor)
    set State(pressed) 0
}

proc wm::Close {w} {
    variable map
    set top [winfo toplevel $w]
    destroy $top
}

proc wm::Destroy {w} {
    variable map
    set top [winfo toplevel $w]
    if {[dict exists $map $top]} {
        set win [dict get $map $top]
        dict unset map $top
        # Destroying immediately causes a BadWindow X error
        after idle [list destroy $win]
    }
}

bind WmTitlebar <Button-1> {wm::Select %W %X %Y}
bind WmTitlebar <B1-Motion> {wm::Drag %W %X %Y}
bind WmTitlebar <ButtonRelease-1> {wm::Release %W %X %Y}
bind WmClose <Button-1> {wm::Close %W}
bind WmCanary <Destroy> {wm::Destroy %W}