#!/usr/bin/env tclsh # # ToyEd: a toy text editor for educational purposes in Tcl/Tk. # Copyright 2022, 2023, 2024 Felix Pleșoianu # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. package require Tcl 8.6 package require Tk 8.6 package require ctext package require getstring namespace import getstring::* set window_title "ToyEd Text Editor" set about_text "A toy text editor\nVersion 1.3 (23 Jan 2024)\nMIT License" set credits_text "Made by No Time To Play\nbased on knowledge\nfrom TkDocs.com" set site_link "https://ctrl-c.club/~nttp/toys/toyed/" set file_types { {"All files" ".*"} {"Text files" ".txt .md"} {"Script files" ".py .tcl .sh"} } set file_name "" set search_term "" set _word_wrap 1 namespace eval font_size { variable minimum 6 variable default 11 variable maximum 16 namespace export increase decrease reset namespace ensemble create variable current $default proc increase {widget {family "Courier"}} { variable current variable maximum if {$current < $maximum} { incr current } $widget configure -font "$family $current" } proc decrease {widget {family "Courier"}} { variable current variable minimum if {$current > $minimum} { incr current -1 } $widget configure -font "$family $current" } proc reset {widget {family "Courier"}} { variable current variable default set current $default $widget configure -font "$family $current" } } namespace eval tk_util { proc pack_scrolled widget { set parent [winfo parent $widget] if {$parent == "."} { set scroll .scroll } else { set scroll $parent.scroll } ttk::scrollbar $scroll -orient "vertical" \ -command "$widget yview" pack $widget -side "left" -fill "both" -expand 1 $widget configure -yscrollcommand "$scroll set" pack $scroll -side "right" -fill y } proc load_text {widget content} { $widget delete 1.0 end $widget insert end $content $widget edit reset $widget edit modified 0 } proc text_selection widget { if {[llength [$widget tag ranges sel]] > 0} { return [$widget get sel.first sel.last] } else { return "" } } proc paste_content widget { if {[llength [$widget tag ranges sel]] > 0} { $widget delete sel.first sel.last } tk_textPaste $widget } proc select_all widget { $widget tag remove sel 1.0 end $widget tag add sel 1.0 end } proc highlight_text {widget content {start "1.0"}} { set idx [$widget search -nocase $content $start] if {$idx ne ""} { set len [string length $content] set pos "$idx +$len chars" $widget tag remove "sel" "1.0" "end" $widget tag add "sel" $idx $pos $widget mark set "insert" $pos $widget see "insert" focus $widget } return $idx } proc open_line widget { $widget insert "insert +0 chars" "\n" $widget mark set "insert" "insert -1 chars" } proc word_wrap widget { if {[$widget cget -wrap] eq "word"} { $widget configure -wrap "none" } else { $widget configure -wrap "word" } } proc full_screen window { if {[wm attributes $window -fullscreen]} { wm attributes $window -fullscreen 0 } else { wm attributes $window -fullscreen 1 } } } wm title . $window_title option add *tearOff 0 . configure -padx 4 if {[tk windowingsystem] == "x11"} { ttk::style theme use "clam" } pack [ttk::frame .toolbar] -side top -pady 4 ttk::frame .status ttk::label .status.line -relief sunken -textvar status ttk::sizegrip .status.grip pack .status -side bottom -fill x -pady 4 pack .status.line -side left -fill x -expand 1 pack .status.grip -side right -anchor s ctext .editor -width 80 -height 24 -wrap "word" -undo 1 -linemap 0 font_size::reset .editor tk_util::pack_scrolled .editor ttk::button .toolbar.new -text "New" -width 8 -command do_new ttk::button .toolbar.bOpen -text "Open" -width 8 -command do_open ttk::button .toolbar.save -text "Save" -width 8 -command do_save ttk::button .toolbar.reload -text "Reload" -width 8 -command do_reload ttk::separator .toolbar.sep1 -orient vertical ttk::button .toolbar.undo -text "Undo" -width 8 -command {.editor edit undo} ttk::button .toolbar.redo -text "Redo" -width 8 -command {.editor edit redo} ttk::button .toolbar.find -text "Find" -width 8 -command do_find ttk::button .toolbar.next -text "Next" -width 8 -command find_again pack .toolbar.new -side left pack .toolbar.bOpen -side left pack .toolbar.save -side left pack .toolbar.reload -side left pack .toolbar.sep1 -side left -padx 4 -pady 4 -fill y pack .toolbar.undo -side left pack .toolbar.redo -side left pack .toolbar.find -side left pack .toolbar.next -side left . configure -menu [menu .menubar] set m [menu .menubar.mFile] $m add command -label "New" -command do_new -under 0 -accel "Ctrl-N" $m add command -label "Open..." -command do_open -under 0 -accel "Ctrl-O" $m add command -label "Save" -command do_save -under 0 -accel "Ctrl-S" $m add separator $m add command -label "Save as..." -command do_save_as -under 5 $m add command -label "Reload" -command do_reload -under 0 -accel "Ctrl-R" $m add command -label "Statistics" -command show_stats -under 1 -accel "Ctrl-T" $m add separator $m add command -label "Quit" -command do_quit -under 0 -accel "Ctrl-Q" .menubar add cascade -menu .menubar.mFile -label "File" -underline 0 set m [menu .menubar.edit] $m add command -label "Undo" -command {.editor edit undo} \ -underline 0 -accelerator "Ctrl-Z" $m add command -label "Redo" -command {.editor edit redo} \ -underline 0 -accelerator "Ctrl-Y" $m add separator $m add command -label "Cut" -command {tk_textCut .editor} \ -underline 0 -accelerator "Ctrl-X" $m add command -label "Copy" -command {tk_textCopy .editor} \ -underline 1 -accelerator "Ctrl-C" $m add command -label "Paste" -command {tk_util::paste_content .editor} \ -underline 0 -accelerator "Ctrl-V" $m add separator $m add command -label "Select all" -under 7 -accel "Ctrl-A" \ -command {tk_util::select_all .editor; break} $m add command -label "Find..." -command do_find -under 0 -accel "Ctrl-F" $m add command -label "Next" -command find_again -under 0 -accel "Ctrl-G" .menubar add cascade -menu .menubar.edit -label "Edit" -underline 0 set m [menu .menubar.mFormat] $m add command -label "Join lines" -command join_lines -under 0 -accel "Alt-J" $m add command -label "Open line" -under 0 -accel "Alt-O" \ -command {tk_util::open_line .editor} $m add separator $m add command -label "Lower case" -command lower_case -under 0 -accel "Alt-L" $m add command -label "Title case" -command title_case -under 1 -accel "Alt-T" $m add command -label "Upper case" -command upper_case -under 0 -accel "Alt-U" $m add separator $m add command -label "Prefix lines..." -command prefix_lines \ -under 0 -accel "Alt-P" $m add command -label "Number lines" -command number_lines \ -under 0 -accel "Alt-N" .menubar add cascade -menu .menubar.mFormat -label "Format" -underline 3 set m [menu .menubar.view] $m add checkbutton -label "Word wrap" -under 0 -var _word_wrap \ -command {tk_util::word_wrap .editor} $m add checkbutton -label "Line numbers" -under 0 -var _line_nums \ -command {toggle_lines .editor} $m add separator $m add command -label "Bigger font" -under 0 -accel "Ctrl-+" \ -command {font_size incr .editor} $m add command -label "Smaller font" -under 0 -accel "Ctrl -" \ -command {font_size decr .editor} $m add command -label "Reset font" -under 0 -accel "Ctrl-0" \ -command {font_size reset .editor} $m add separator if {[llength [info commands "console"]] > 0} { $m add command -label "Console" -command {console show} \ -underline 5 -accelerator "Ctrl-L" } $m add checkbutton -label "Full screen" -command {tk_util::full_screen .} \ -underline 10 -accelerator "F11" -var _full_screen .menubar add cascade -menu .menubar.view -label "View" -underline 0 set m [menu .menubar.help] $m add command -label "About" -command {alert $about_text} -under 0 $m add command -label "Credits" -command {alert $credits_text} -under 0 $m add command -label "Website" -command {open_in_app $site_link} -under 0 .menubar add cascade -menu .menubar.help -label "Help" -underline 0 wm protocol . WM_DELETE_WINDOW do_quit bind .editor <> show_modified bind . do_new bind . do_open bind . do_save bind . do_reload bind . show_stats bind . do_quit bind . do_new bind . do_open bind . do_save bind . do_reload bind . show_stats bind . do_quit # Undo, cut and copy are already bound to their usual keys by default. # Many bindings here have to override broken defaults. bind .editor {.editor edit redo} bind .editor {tk_util::paste_content .editor; break} bind .editor {tk_util::select_all .editor; break} bind . do_find bind . find_again bind .editor {.editor edit redo} bind .editor {tk_util::paste_content .editor; break} bind .editor {tk_util::select_all .editor; break} bind . do_find bind . find_again bind Text {} bind .editor join_lines bind .editor {tk_util::open_line .editor; break} bind .editor {lower_case; break} bind .editor {title_case; break} bind .editor upper_case bind .editor prefix_lines bind .editor {number_lines; break} bind . {font_size::increase .editor} bind . {font_size::decrease .editor} bind . {font_size::reset .editor} bind . {font_size::increase .editor} bind . {font_size::decrease .editor} bind . {font_size::reset .editor} bind . {tk_util::full_screen .} if {[llength [info commands "console"]] > 0} { bind . {console show} bind . {console show} } proc show_modified {} { global status if {[.editor edit modified]} { set status "(modified)" } } proc do_new {} { global status file_name window_title if {[.editor edit modified]} { set answer [tk_messageBox -parent . \ -type "yesno" -icon "question" \ -title $window_title \ -message "New file?" \ -detail "File isn't saved.\nStart another?"] if {!$answer} { set status "New file canceled." return } } wm title . $window_title set file_name "" .editor delete "1.0" "end" .editor edit reset .editor edit modified 0 set status [clock format [clock seconds]] } proc do_open {} { global status file_types file_name window_title if {[.editor edit modified]} { set answer [tk_messageBox -parent . \ -type "yesno" -icon "question" \ -title $window_title \ -message "Open another file?" \ -detail "File isn't saved.\nOpen another?"] if {!$answer} { set status "Opening canceled." return } } if {[tk windowingsystem] == "x11" && [auto_execok "zenity"] ne ""} { set choice [open_with_zenity $file_name] } else { set choice [tk_getOpenFile -parent . \ -title "Open existing file" \ -initialdir [file_dir $file_name] \ -filetypes $file_types] } if {[string length $choice] == 0} { set status "Opening canceled." } elseif {![file isfile $choice]} { tk_messageBox -parent . \ -type "ok" -icon "error" \ -title $window_title \ -message "Error opening file" \ -detail "File not found: $choice" } elseif {[load_file $choice]} { set file_name $choice } } proc open_with_zenity file_name { try { if {$file_name ne ""} { return [exec zenity --file-selection \ --title "Open existing file" \ --filename $file_name \ --file-filter "All files | *" \ --file-filter "Text files | *.txt *.md" \ --file-filter "Script files | *.py *.tcl *.sh"] } else { return [exec zenity --file-selection \ --title "Open existing file" \ --file-filter "All files | *" \ --file-filter "Text files | *.txt *.md" \ --file-filter "Script files | *.py *.tcl *.sh"] } } trap CHILDSTATUS {results options} { return "" } } proc load_file full_path { global status window_title set fn [file tail $full_path] try { set f [open $full_path] tk_util::load_text .editor [read $f] set status "Opened $fn" wm title . "$fn | $window_title" return 1 } on error e { tk_messageBox -parent . \ -type "ok" -icon "error" \ -title $window_title \ -message "Error opening file" \ -detail $e return 0 } finally { close $f } } proc file_dir name { if {$name ne ""} { return [file dirname $name] } else { return [pwd] } } proc do_save {} { global file_name if {$file_name eq ""} { do_save_as } else { save_file $file_name } } proc do_save_as {} { global file_name file_types status if {[tk windowingsystem] == "x11" && [auto_execok "zenity"] ne ""} { set choice [save_with_zenity $file_name] } else { set choice [tk_getSaveFile -parent . \ -title "Save file as..." \ -initialdir [file_dir $file_name] \ -filetypes $file_types] } if {[string length $choice] == 0} { set status "Save canceled." } elseif {[save_file $choice]} { set file_name $choice } } proc save_with_zenity file_name { try { if {$file_name ne ""} { return [exec zenity --file-selection \ --title "Save file as..." \ --save --confirm-overwrite \ --filename $file_name \ --file-filter "All files | *" \ --file-filter "Text files | *.txt *.md" \ --file-filter "Script files | *.py *.tcl *.sh"] } else { return [exec zenity --file-selection \ --title "Save file as..." \ --save --confirm-overwrite \ --file-filter "All files | *" \ --file-filter "Text files | *.txt *.md" \ --file-filter "Script files | *.py *.tcl *.sh"] } } trap CHILDSTATUS {results options} { return "" } } proc save_file full_path { global status window_title set fn [file tail $full_path] set data [.editor get "1.0" "end"] set data [string trimright $data "\n"] try { set f [open $full_path "w"] puts -nonewline $f "$data\n" flush $f .editor edit modified 0 set status "Saved $fn" wm title . "$fn | $window_title" return 1 } on error e { tk_messageBox -parent . \ -type "ok" -icon "error" \ -title $window_title \ -message "Error saving file" \ -detail $e return 0 } finally { close $f } } proc do_reload {} { global file_name status window_title if {$file_name eq ""} { tk_messageBox -parent . \ -type "ok" -icon "warning" \ -title $window_title \ -message "Can't reload." \ -detail "The file was never saved." } else { set answer [tk_messageBox -parent . \ -type "yesno" -icon "question" \ -title $window_title \ -message "Reload file?" \ -detail "Reload last save?"] if {$answer eq "yes"} { load_file $file_name } else { set status "Reloading canceled." } } } proc show_stats {} { global window_title set data [tk_util::text_selection .editor] if {$data eq ""} { set data [.editor get "1.0" "end"] set msg "File statistics" } else { set msg "Selection stats" } set clean [string trimright $data "\n"] set lines [llength [split $clean "\n"]] set words [regexp -all {\S+} $data] set chars [string length $data] set stats "Lines: $lines\nWords: $words\nCharacters: $chars" tk_messageBox -parent . \ -type "ok" -icon "info" -title $window_title \ -message $msg -detail $stats } proc do_quit {} { global window_title if {[.editor edit modified]} { set answer [tk_messageBox -parent . \ -type "yesno" -icon "question" \ -title $window_title \ -message "Quit $window_title?" \ -detail "File is unsaved.\nQuit anyway?"] } else { set answer "yes" } if {$answer eq "yes"} { destroy . } } proc do_find {} { global search_term status set term [tk_util::text_selection .editor] set ret [tk_getString .gs answer "Search term:" \ -title "Find" -entryoptions "-textvar search_term"] if {!$ret} { set status "Search canceled." return } set search_term $answer; # Possibly redundant step_search } proc find_again {} { global search_term status if {$search_term eq ""} { do_find } else { step_search } } proc step_search {} { global search_term status set res [tk_util::highlight_text .editor $search_term "insert"] if {$res eq ""} { set search_term "" set status "Nothing found." } } proc join_lines {} { global status set t [tk_util::text_selection .editor] if {[string length $t] > 0} { set t [string map {"\n" " "} $t] .editor replace sel.first sel.last $t } else { set status "Nothing selected." } } proc lower_case {} { global status set t [tk_util::text_selection .editor] if {[string length $t] > 0} { .editor replace sel.first sel.last [string tolower $t] } else { set status "Nothing selected." } } proc title_case {} { global status set sel [tk_util::text_selection .editor] if {[string length $sel] > 0} { .editor replace sel.first sel.last [string totitle $sel] } else { set status "Nothing selected." } } proc upper_case {} { global status set sel [tk_util::text_selection .editor] if {[string length $sel] > 0} { .editor replace sel.first sel.last [string toupper $sel] } else { set status "Nothing selected." } } proc prefix_lines {} { global status set sel [tk_util::text_selection .editor] if {$sel eq ""} { set status "Nothing selected." return } set ret [tk_getString .gs answer "Prefix selected lines with:"] if {!$ret} { set status "Prefix canceled." return } set lines [split $sel "\n"] set result [list] foreach l $lines { lappend result $answer$l } .editor replace sel.first sel.last [join $result "\n"] } proc number_lines {} { global status set sel [tk_util::text_selection .editor] if {$sel eq ""} { set status "Nothing selected." return } set lines [split $sel "\n"] set result [list] set n 0 foreach l $lines { incr n lappend result "$n. $l" } .editor replace sel.first sel.last [join $result "\n"] } proc toggle_lines widget { if {[$widget cget -linemap]} { $widget configure -linemap 0 } else { $widget configure -linemap 1 } } proc alert message { global window_title tk_messageBox -parent . \ -type "ok" -icon "info" \ -title $window_title \ -message $message } proc open_in_app link { global status if {[auto_execok "xdg-open"] ne ""} { catch {exec "xdg-open" $link &} } elseif {[auto_execok "open"] ne ""} { catch {exec "open" $link &} } elseif {[auto_execok "start"] ne ""} { catch {exec "start" $link &} } else { set status "Can't open website." } } set status [clock format [clock seconds]] if {[llength $argv] > 0} { set fn [lindex $argv 0] if {![file exists $fn]} { set file_name [file normalize $fn] set fn [file tail $file_name] wm title . "$fn | $window_title" } elseif {[load_file $fn]} { set file_name [file normalize $fn] } }