Posted to tcl by hardkorebob at Fri Aug 07 17:59:10 GMT 2026view pretty
package require Tk
namespace eval Core {
variable scriptfile [file normalize [info script]]
variable plugindir [file join [file dirname $scriptfile] plugins]
variable current_buffer "scratch"
variable buffer_list {}
variable buffer_path [dict create] ;# buffer name -> absolute file path on disk
variable buffer_from_path [dict create] ;# reverse: path -> buffer name
variable buffer_widget [dict create] ;# buffer name -> internal widget id (buf1, buf2, ...)
variable buffer_counter 0
variable active_widget ""
variable minibuffer_mode ""
variable minibuffer_prompt ""
variable plugin_state [dict create] ;# plugin name -> {file .. commands .. binds .. hooks .. modes}
variable _reloading_plugin "" ;# name of the plugin currently being (re)sourced
# === Hook system ===
variable hooks [dict create] ;# hookName -> list of scripts
# === Extensible minibuffer modes ===
variable minibuffer_modes [dict create] ;# modeName -> handler command
# Builtin modes are registered in Core::Init after procedures are defined
file mkdir $plugindir
}
# ---- Hook API ----
proc Core::AddHook {hookName script} {
variable hooks
dict lappend hooks $hookName $script
}
proc Core::RunHook {hookName args} {
variable hooks
if {![dict exists $hooks $hookName]} return
foreach script [dict get $hooks $hookName] {
catch { uplevel #0 [list {*}$script {*}$args] }
}
}
proc Core::RunHookWithReturn {hookName args} {
variable hooks
if {![dict exists $hooks $hookName]} { return "" }
foreach script [dict get $hooks $hookName] {
if {[catch { uplevel #0 [list {*}$script {*}$args] } result]} continue
if {$result ne "" && $result ne "continue"} {
return $result
}
}
return ""
}
# ---- Extensible minibuffer modes API ----
proc Core::RegisterMinibufferMode {mode handler} {
variable minibuffer_modes
dict set minibuffer_modes $mode $handler
}
proc Core::UnregisterMinibufferMode {mode} {
variable minibuffer_modes
catch { dict unset minibuffer_modes $mode }
}
# ---- Builtin minibuffer mode handlers ----
proc Core::HandleEvalMode {input} {
if {[catch {uplevel #0 $input} result]} {
Core::Message "Error: $result"
} else {
Core::Message "=> $result"
}
}
proc Core::HandleFindFileMode {input} {
Core::OpenFile [string trim $input]
}
proc Core::HandleSwitchBufferMode {input} {
set target [string trim $input]
if {$target in $Core::buffer_list} {
Core::SwitchToBuffer $target
} else {
Core::Message "No such buffer: $target"
}
}
proc Core::HandleSaveAsMode {filename} {
set filename [string trim $filename]
if {$filename eq ""} {
Core::Message "Save cancelled"
return
}
Core::SaveBufferToFile $filename
}
# ---- UI Construction ----
proc Core::BuildUI {} {
wm title . "tclme"
# Global defaults flat, no borders
option add *Background "#F5F5F5"
option add *Foreground "#2C2C2C"
option add *Font {Consolas 12}
option add *borderWidth 0
option add *relief flat
option add *highlightThickness 0
# Workspace invisible background because buffers cover it,
# but you can see it if no buffer is open (scratch appears).
frame .workspace -bg "#F5F5F5"
pack .workspace -fill both -expand true
# Thin separator line between workspace and status
frame .separator1 -bg "#CCCCCC" -height 1
pack .separator1 -fill x
# Status frame no border, just a background
frame .status_frame -bg "#E0E0E0"
pack .status_frame -fill x
label .status -text " tclme " -anchor w \
-bg "#E0E0E0" -fg "#555555" -font {Consolas 10 bold}
pack .status -fill x
# Thin separator between status and minibuffer
frame .separator2 -bg "#CCCCCC" -height 1
pack .separator2 -fill x
# Minibuffer flat, no highlight border
entry .minibuffer -bg "#F0F0F0" -fg "#2C2C2C" \
-insertbackground "#2C2C2C" -bd 0
pack .minibuffer -fill x
bind .minibuffer <Return> { Core::ExecuteMinibuffer }
bind .minibuffer <Escape> { Core::CancelMinibuffer }
bind .minibuffer <Control-g> { Core::CancelMinibuffer }
}
# ---- Buffer management ----
proc Core::SwitchToBuffer {name} {
variable current_buffer
variable buffer_list
variable active_widget
variable buffer_widget
variable buffer_counter
if {[dict exists $buffer_widget $name]} {
set wid [dict get $buffer_widget $name]
} else {
set wid "buf[incr buffer_counter]"
dict set buffer_widget $name $wid
}
set container ".workspace.$wid"
set txt "$container.txt"
if {![winfo exists $container]} {
frame $container -bg "#000000" -bd 0
text $txt -undo true -wrap word -padx 5 -pady 5 -highlightbackground "#000000" \
-insertbackground "#000000" -bg "#EEEEEE" -fg "#000000" \
-font {Consolas 12} -yscrollcommand "$container.vs set"
scrollbar "$container.vs" -orient vertical -command "$txt yview" \
-bg "#2d2d2d" -troughcolor "#1e1e1e"
pack "$container.vs" -side right -fill y
pack $txt -side left -fill both -expand true
bindtags $txt [list $txt CoreText Text [winfo toplevel $txt] all]
bind $txt <<Modified>> { Core::RefreshStatus }
bind $txt <KeyRelease> { Core::RefreshStatus }
bind $txt <ButtonRelease-1> { Core::RefreshStatus }
lappend buffer_list $name
}
foreach child [winfo children .workspace] { pack forget $child }
pack $container -fill both -expand true
set current_buffer $name
set active_widget $txt
Core::RefreshStatus
focus $txt
Core::RunHook switch-buffer-hook $name
}
proc Core::FindBufferForPath {full} {
variable buffer_from_path
if {[dict exists $buffer_from_path $full]} {
return [dict get $buffer_from_path $full]
}
return ""
}
proc Core::KillBuffer {name} {
variable buffer_list
variable buffer_path
variable buffer_from_path
variable buffer_widget
variable current_buffer
if {![dict exists $buffer_widget $name]} {
Core::Message "No such buffer: $name"
return
}
set cancel [Core::RunHookWithReturn kill-buffer-hook $name]
if {$cancel ne ""} {
Core::Message "Kill cancelled: $cancel"
return
}
set wid [dict get $buffer_widget $name]
set container ".workspace.$wid"
set txt "$container.txt"
if {[$txt edit modified]} {
set answer [tk_messageBox -type yesno -icon warning \
-title "Unsaved changes" \
-message "Buffer \"$name\" has unsaved changes. Kill?"]
if {$answer ne "yes"} return
}
if {[dict exists $buffer_path $name]} {
set path [dict get $buffer_path $name]
catch { dict unset buffer_from_path $path }
}
set buffer_list [lsearch -all -inline -not -exact $buffer_list $name]
catch { dict unset buffer_path $name }
catch { dict unset buffer_widget $name }
if {$current_buffer eq $name} {
if {[llength $buffer_list] > 0} {
Core::SwitchToBuffer [lindex $buffer_list end]
} else {
Core::SwitchToBuffer "scratch"
}
}
after idle [list destroy $container]
Core::Message "Killed buffer: $name"
}
# ---- Status and message helpers ----
proc Core::UpdateStatus {msg} { .status configure -text " $msg " }
proc Core::Message {msg} {
.minibuffer delete 0 end
.minibuffer insert 0 $msg
}
proc Core::RefreshStatus {} {
variable current_buffer
variable active_widget
variable buffer_path
if {$active_widget eq "" || ![winfo exists $active_widget]} { return }
set dirty [expr {[$active_widget edit modified] ? "*" : " "}]
set pos [$active_widget index insert]
set line [lindex [split $pos .] 0]
set col [expr {[lindex [split $pos .] 1] + 1}]
set loc ""
if {[dict exists $buffer_path $current_buffer]} {
set loc " [dict get $buffer_path $current_buffer]"
}
Core::UpdateStatus "${current_buffer}${dirty} Ln $line, Col $col${loc}"
set extra [Core::RunHookWithReturn status-line-hook $current_buffer]
if {$extra ne ""} {
.status configure -text " ${current_buffer}${dirty} Ln $line, Col $col${loc} $extra "
}
}
# ---- Minibuffer prompts ----
proc Core::PromptMinibuffer {mode prompt} {
variable minibuffer_mode
variable minibuffer_prompt
set minibuffer_mode $mode
set minibuffer_prompt $prompt
.minibuffer delete 0 end
.minibuffer insert 0 $prompt
focus .minibuffer
.minibuffer icursor end
}
proc Core::SetMinibufferMode {mode} {
Core::PromptMinibuffer $mode "$mode: "
}
# ---- Minibuffer handling ----
proc Core::ExecuteMinibuffer {} {
variable minibuffer_mode
variable minibuffer_prompt
variable active_widget
variable minibuffer_modes
set input [.minibuffer get]
if {[string match {:*} $input] && $minibuffer_mode eq ""} {
set cmd [string range $input 1 end]
.minibuffer delete 0 end
Core::RunExCommand [string trim $cmd]
focus $active_widget
return
}
set mode $minibuffer_mode
set arg $input
if {$minibuffer_prompt ne "" && [string first $minibuffer_prompt $arg] == 0} {
set arg [string range $arg [string length $minibuffer_prompt] end]
}
set minibuffer_mode ""
set minibuffer_prompt ""
.minibuffer delete 0 end
if {[dict exists $minibuffer_modes $mode]} {
{*}[dict get $minibuffer_modes $mode] $arg
}
focus $active_widget
Core::RunHook minibuffer-execute-hook $mode $arg
}
proc Core::CancelMinibuffer {} {
variable minibuffer_mode
variable minibuffer_prompt
set minibuffer_mode ""
set minibuffer_prompt ""
.minibuffer delete 0 end
if {[winfo exists $Core::active_widget]} {
focus $Core::active_widget
}
}
# ---- Ex-command dispatcher ----
proc Core::RunExCommand {cmd} {
variable buffer_list
variable current_buffer
set words [split $cmd]
set verb [lindex $words 0]
switch -- $verb {
"e" - "edit" {
Core::OpenFile [join [lrange $words 1 end]]
}
"w" - "write" {
Core::SaveCurrentBuffer
}
"q" - "quit" {
Core::Quit
}
"b" - "buffer" {
set arg [join [lrange $words 1 end]]
if {$arg eq ""} {
# list buffers with 1â¬based indices
set idx 0
set lines {}
foreach buf $buffer_list {
incr idx
lappend lines "$idx: $buf"
}
if {[llength $lines] == 0} {
Core::Message "No buffers open"
} else {
Core::Message [join $lines " | "]
}
} elseif {[string is integer -strict $arg]} {
set idx $arg
if {$idx >= 1 && $idx <= [llength $buffer_list]} {
Core::SwitchToBuffer [lindex $buffer_list [expr {$idx - 1}]]
} else {
Core::Message "Buffer index out of range (1â¬[llength $buffer_list])"
}
} else {
# treat as exact name or substring (original behaviour)
if {$arg in $buffer_list} {
Core::SwitchToBuffer $arg
} else {
Core::Message "No such buffer: $arg"
}
}
}
"ls" {
Core::Message "Buffers: [join $buffer_list {, }]"
}
"bd" - "kill" {
set target [lindex $words 1]
if {$target eq ""} { set target $current_buffer }
Core::KillBuffer $target
}
"reload" {
Core::ReloadPlugins [lindex $words 1]
}
"eval" {
set code [join [lrange $words 1 end]]
if {[catch {uplevel #0 $code} result]} {
Core::Message "Error: $result"
} else {
Core::Message "=> $result"
}
}
"help" {
Core::Message ":e :w :q :b[#|name] :ls :bd\[name\] :reload\[plugin\] :eval | C-x C-s/C-f/C-e/b/k, C-x C-r"
}
default {
Core::Message "Unknown command: $verb (try :help)"
}
}
}
# ---- Plugins ----
proc Core::RegisterPlugin {name file} {
variable plugin_state
dict set plugin_state $name file $file
}
proc Core::UnregisterPlugin {name} {
variable plugin_state
if {![dict exists $plugin_state $name]} return
if {[dict exists $plugin_state $name commands]} {
foreach cmd [dict get $plugin_state $name commands] {
catch { rename $cmd {} }
}
}
if {[dict exists $plugin_state $name binds]} {
foreach entry [dict get $plugin_state $name binds] {
lassign $entry widget key script
catch { bind $widget $key {} }
}
}
if {[dict exists $plugin_state $name hooks]} {
foreach entry [dict get $plugin_state $name hooks] {
lassign $entry hookName script
if {[dict exists $Core::hooks $hookName]} {
set idx [lsearch -exact [dict get $Core::hooks $hookName] $script]
if {$idx >= 0} {
dict set Core::hooks $hookName [lreplace [dict get $Core::hooks $hookName] $idx $idx]
}
}
}
}
if {[dict exists $plugin_state $name modes]} {
foreach mode [dict get $plugin_state $name modes] {
Core::UnregisterMinibufferMode $mode
}
}
catch { dict unset plugin_state $name }
}
proc Core::ReloadPlugins {{name ""}} {
variable plugin_state
variable plugindir
if {$name ne ""} {
if {![dict exists $plugin_state $name]} {
Core::Message "Plugin not registered: $name"
return
}
Core::ReloadOnePlugin $name
Core::Message "Reloaded plugin: $name"
return
}
set current_files [glob -nocomplain -directory $plugindir *.tcl]
set current_names {}
foreach f $current_files {
lappend current_names [file rootname [file tail $f]]
}
foreach name [dict keys $plugin_state] {
if {$name ni $current_names} {
Core::UnregisterPlugin $name
}
}
foreach f $current_files {
set name [file rootname [file tail $f]]
if {![dict exists $plugin_state $name]} {
Core::RegisterPlugin $name $f
if {[catch {Core::SourcePlugin $name $f} err]} {
Core::Message "Error loading plugin $name: $err"
}
} else {
dict set plugin_state $name file $f
Core::ReloadOnePlugin $name
}
}
Core::Message "Plugins reloaded."
}
proc Core::ReloadOnePlugin {name} {
variable plugin_state
set file [dict get $plugin_state $name file]
if {[dict exists $plugin_state $name commands]} {
foreach cmd [dict get $plugin_state $name commands] {
catch { rename $cmd {} }
}
}
if {[dict exists $plugin_state $name binds]} {
foreach entry [dict get $plugin_state $name binds] {
lassign $entry widget key script
catch { bind $widget $key {} }
}
}
if {[dict exists $plugin_state $name hooks]} {
foreach entry [dict get $plugin_state $name hooks] {
lassign $entry hookName script
if {[dict exists $Core::hooks $hookName]} {
set idx [lsearch -exact [dict get $Core::hooks $hookName] $script]
if {$idx >= 0} {
dict set Core::hooks $hookName [lreplace [dict get $Core::hooks $hookName] $idx $idx]
}
}
}
}
if {[dict exists $plugin_state $name modes]} {
foreach mode [dict get $plugin_state $name modes] {
Core::UnregisterMinibufferMode $mode
}
}
dict set plugin_state $name commands {}
dict set plugin_state $name binds {}
dict set plugin_state $name hooks {}
dict set plugin_state $name modes {}
if {[catch {Core::SourcePlugin $name $file} err]} {
Core::Message "Error loading plugin $name: $err"
}
}
# ---- Plugin API extensions ----
proc Core::PluginAddCommand {cmd} {
variable plugin_state
variable _reloading_plugin
if {$_reloading_plugin eq ""} return
dict lappend plugin_state $_reloading_plugin commands $cmd
}
proc Core::PluginBindKey {widget key script} {
variable plugin_state
variable _reloading_plugin
if {$_reloading_plugin eq ""} return
bind $widget $key "$script; break"
dict lappend plugin_state $_reloading_plugin binds [list $widget $key $script]
}
proc Core::PluginAddHook {hookName script} {
variable plugin_state
variable _reloading_plugin
if {$_reloading_plugin eq ""} return
dict lappend plugin_state $_reloading_plugin hooks [list $hookName $script]
Core::AddHook $hookName $script
}
proc Core::PluginAddMinibufferMode {mode handler} {
variable plugin_state
variable _reloading_plugin
if {$_reloading_plugin eq ""} return
dict lappend plugin_state $_reloading_plugin modes $mode
Core::RegisterMinibufferMode $mode $handler
}
proc Core::SourcePlugin {name file} {
variable _reloading_plugin
set _reloading_plugin $name
try {
uplevel #0 [list source $file]
} finally {
set _reloading_plugin ""
}
}
# ---- Plugin loading on startup ----
proc Core::LoadPlugins {} {
variable plugindir
foreach file [lsort [glob -nocomplain -directory $plugindir *.tcl]] {
set name [file rootname [file tail $file]]
Core::RegisterPlugin $name $file
if {[catch {Core::SourcePlugin $name $file} err]} {
Core::Message "Error loading plugin $name: $err"
}
}
}
# ---- File operations ----
proc Core::OpenFile {filename} {
variable buffer_path
variable buffer_from_path
variable active_widget
set filename [string trim $filename]
if {$filename eq ""} return
set full [file normalize $filename]
set existing [Core::FindBufferForPath $full]
if {$existing ne ""} {
Core::SwitchToBuffer $existing
Core::Message "Switched to $existing"
return
}
set bname [file tail $full]
if {[dict exists $buffer_path $bname]} {
set n 2
while {[dict exists $buffer_path "$bname<$n>"]} { incr n }
set bname "$bname<$n>"
}
Core::SwitchToBuffer $bname
dict set buffer_path $bname $full
dict set buffer_from_path $full $bname
if {![file exists $full]} {
Core::Message "(New file) $filename"
Core::RunHook find-file-hook $full
return
}
if {[catch {
set fp [open $full r]
$active_widget delete 1.0 end
$active_widget insert end [read $fp]
close $fp
$active_widget edit modified 0
Core::Message "Read $filename"
} err]} {
Core::Message "Open error: $err"
}
Core::RefreshStatus
Core::RunHook find-file-hook $full
}
proc Core::SaveCurrentBuffer {} {
variable current_buffer
variable buffer_path
if {![dict exists $buffer_path $current_buffer]} {
# No file associated prompt for save-as
Core::PromptMinibuffer "save-as" "Save as (filename): "
return
}
set filename [dict get $buffer_path $current_buffer]
Core::SaveBufferToFile $filename
}
proc Core::SaveBufferToFile {filename} {
variable current_buffer
variable active_widget
variable buffer_path
variable buffer_from_path
set norm [file normalize $filename]
set cancel [Core::RunHookWithReturn before-save-hook $norm]
if {$cancel ne ""} {
Core::Message "Save cancelled: $cancel"
return
}
if {[catch {
set fp [open $norm w]
puts -nonewline $fp [$active_widget get 1.0 "end-1c"]
close $fp
$active_widget edit modified 0
# Update buffer-path mappings
if {[dict exists $buffer_path $current_buffer]} {
set old [dict get $buffer_path $current_buffer]
if {$old ne $norm} {
catch { dict unset buffer_from_path $old }
}
}
dict set buffer_path $current_buffer $norm
dict set buffer_from_path $norm $current_buffer
Core::Message "Wrote $norm"
} err]} {
Core::Message "Save error: $err"
}
Core::RefreshStatus
Core::RunHook after-save-hook $norm
}
proc Core::Quit {} {
variable buffer_list
variable buffer_widget
set cancel [Core::RunHookWithReturn before-quit-hook]
if {$cancel ne ""} {
Core::Message "Quit cancelled: $cancel"
return
}
set dirty {}
foreach name $buffer_list {
if {![dict exists $buffer_widget $name]} continue
set wid [dict get $buffer_widget $name]
set txt ".workspace.$wid.txt"
if {[winfo exists $txt] && [$txt edit modified]} {
lappend dirty $name
}
}
if {[llength $dirty] > 0} {
set answer [tk_messageBox -type yesno -icon warning \
-title "Unsaved changes" \
-message "Unsaved changes in: [join $dirty {, }]\n\nQuit?"]
if {$answer ne "yes"} return
}
exit 0
}
# ---- Initialization ----
proc Core::Init {} {
Core::RegisterMinibufferMode "eval" {Core::HandleEvalMode}
Core::RegisterMinibufferMode "find-file" {Core::HandleFindFileMode}
Core::RegisterMinibufferMode "switch-buffer" {Core::HandleSwitchBufferMode}
Core::RegisterMinibufferMode "save-as" {Core::HandleSaveAsMode}
Core::BuildUI
bind CoreText <Control-g> { Core::CancelMinibuffer }
bind CoreText <Control-x><Control-c> { Core::Quit }
bind CoreText <Control-x><Control-r> { Core::ReloadPlugins }
bind CoreText <Control-x><Control-s> { Core::SaveCurrentBuffer }
bind CoreText <Control-x><Control-f> { Core::PromptMinibuffer "find-file" "Path: " }
bind CoreText <Control-x><Control-e> { Core::PromptMinibuffer "eval" "Eval: " }
bind CoreText <Control-x>b { Core::PromptMinibuffer "switch-buffer" "Switch to buffer: "; break }
bind CoreText <Control-x>k { Core::KillBuffer $Core::current_buffer ; break}
bind CoreText <Escape> {
Core::CancelMinibuffer
if {[winfo exists $Core::active_widget]} { focus $Core::active_widget }
}
Core::SwitchToBuffer "scratch"
Core::LoadPlugins
focus $Core::active_widget
}
# ---- Start ----
Core::Init