Fabricio Rocha - 14 Jul 2011 - XML may be the current universal format for data files, but it's often way too much for simpler needs. As strings can be easily read and written to files, the "everything is a string" Tcl concept makes it possible to output to a simple text file very complex data structures made of lists and dicts, which can later be read to scalar or array variables and promptly used by the built-in language commands. The files built from Tcl variables contents can be open and edited in any Unicode-capable text editor, but they may become difficult to understand and edit if the data is large and nested.
This problem pushed me to create eDictor, the simple data-files editor presented here. It could be fancier, indeed, but I made it as a development tool for helping me in my TKtEasy project, and I thought it could be useful for other people. It allows you to open, create and edit files created from the output of Tcl variables, and it is also useful for planning data structures which will be used in an application.
Data is shown in a small panel which contain a "mode selector" in which you can choose how you want to view and edit the data. In the "string" mode, data is presented in a text widget; in "list" mode, each list item is a line of a ttk::treeview widget. Using the buttons below the treeview, you can move the list items up or down, remove or add elements, and edit an item in another similar panel by double-clicking it. In "dict" mode, the treeview shows keys, and by double-clicking them you can view and edit their respective values in the new panel that appears. The "nodes" which represent each nesting level of the data are placed in a ttk::panedwindow widget, so you can change their width for showing all the content of a line.
eDictor is built around the valuepanel megawidget and has the following features in its initial version:
The initial version of eDictor is really really simple, it barely works as the development tool I wanted and is also a simple demo of the valuepanel megawidget. I have some ideas of "nice to haves" which may be implemented someday. If you have a suggestion or want to "vote" for an idea, please tell; and if you can implement a suggestion, take a look at the "Code" section.
AMW suggest using a ttk::treeview to present the dict data and created the dictree widget to achieve this.
Translation files and better support - You'll notice that the interface strings are using msgcat, and message catalog files are expected to be in a langs subdirectory below the script's directory. But this is a very basic implementation and I did not write any translation files yet.
Save window size and position
Keep track on the last used directory for open/save as
Open files passed from the command line
Toolbar
Configurable keyboard shortcuts
Support other common data formats - No easy task here, but as INI files can be converted to/from dicts in some conditions, its support is possible. I think a fork of eDictor for XML edition with a similar interface might be interesting.
Here is the code for eDictor. Please remember to save the valuepanel code in a file at the same directory. If you want to correct a bug or change something, be welcome. Please, create a new "version/code" section like the original one, and place the new code there, along with an explanation of what you have changed. Use the common sense for version numbers: major, minor, revision.
Idiot bug: if you saved untitled data, the notebook tab did not have its title changed to the new file name. Corrected. By the way, I discovered that under Tk 8.5.9 (in Debian GNU-Linux) I could not select the contents of the text widget in a "string" node: the selection "flickered" and apparently made the mode selector go nuts. This did not happen after an upgrade to Tk 8.5.10.
#!/bin/sh # -*- tcl -*- # The next line is executed by /bin/sh, but not tcl \ exec tclsh "$0" ${1+"$@"} # eDictor - A Tcl viewer/editor for Tcl lists and dicts # Fabricio Rocha, June 2011 package require Tcl 8.5 package require Tk 8.5 package require msgcat source valuepanel.tcl # ############################################################################# # Initialization and business-logic # ############################################################################# # Init proc Init {} { variable pagecount 0 variable Pages set scriptdir [file dirname [file normalize [info script]]] # Configure msgcat catch {::msgcat::mcload [file join $scriptdir "langs"]} # Create the main window MainWindow_Create # Create the data for the first page (still empty) set ptitle [::msgcat::mc "Untitled%s" $pagecount] Page_New $ptitle {} } # Page_New # Create and configure the data structure which represents a page # ARGUMENTS: "title" is the title of the new page. "filepath" is optional. # RESULTS: A new page is created in the notebook, with an empty valuepanel. # Returns the Tk path to the new page frame. proc Page_New { title {filepath {}} } { variable pagecount variable Pages set page [ttk::frame .nbk.frm$pagecount] .nbk add $page -text $title -sticky news -padding 2 ::ValuePanel::valuepanel $page.vp bind $page.vp <<Modified>> [list Page_Modified $page true] ttk::button $page.btnClose -text "X" -width 0 \ -command [list MainWindow_OnPageClose $page] grid $page.btnClose -column 0 -row 0 -sticky e grid $page.vp -column 0 -row 1 -sticky news grid columnconfigure $page 0 -weight 1 grid rowconfigure $page 0 -weight 0 grid rowconfigure $page 1 -weight 1 dict set Pages($page) filepath $filepath dict set Pages($page) unsaved false dict set Pages($page) title $title .nbk select $page incr pagecount return $page } # Page_Modified # Called as a binding for the <<Modified>> event of a page's valuepanel. # ARGUMENTS: "pagenum" is the index of the page; "modified" is boolean proc Page_Modified {page modified} { variable Pages dict set Pages($page) unsaved $modified set status [expr bool($modified)] if { $status == 1 } { set newtitle "[dict get $Pages($page) title]*" } else { set newtitle [dict get $Pages($page) title] } .nbk tab $page -text $newtitle return } # ############################################################################# # MAIN WINDOW AND DIALOGS # ############################################################################# # MainWindow_Create # Creates the main window's widgets. # ARGUMENTS: # RESULTS: proc MainWindow_Create {} { variable pagecount wm withdraw . wm protocol . WM_DELETE_WINDOW MainWindow_OnQuit # Setup the window size, title, etc wm title . "eDictor" # Create the menus option add *tearOff 0 menu .menubar . configure -menu .menubar menu .menubar.file .menubar add cascade -menu .menubar.file -label [::msgcat::mc "File"] .menubar.file add command -label [::msgcat::mc "New"] \ -command {Page_New [::msgcat::mc "Untitled%s" $pagecount] {}}\ -accelerator "Ctrl+N" .menubar.file add command -label [::msgcat::mc "Open..."] \ -command MainWindow_OnOpen -accelerator "Ctrl+O" .menubar.file add command -label [::msgcat::mc "Save"] \ -command MainWindow_OnSave -accelerator "Ctrl+S" .menubar.file add command -label [::msgcat::mc "Save As..."] \ -command MainWindow_OnSaveAs .menubar.file add separator .menubar.file add command -label [::msgcat::mc "Quit"] \ -command MainWindow_OnQuit -accelerator "Ctrl+Q" menu .menubar.help .menubar add cascade -menu .menubar.help -label [::msgcat::mc "Help"] .menubar.help add command -label [::msgcat::mc "About..."]\ -command MainWindow_OnAbout # The main notebook ttk::notebook .nbk -padding {2 2} # Layout all the stuff grid .nbk -column 0 -row 0 -sticky news grid columnconfigure . 0 -weight 1 grid rowconfigure . 0 -weight 1 # Configure global bindings. Does someone know a way to avoid this silly # duplication due to upper/lower case? bind . <Control-N> {Page_New [::msgcat::mc "Untitled%%s" $pagecount] {}} bind . <Control-n> {Page_New [::msgcat::mc "Untitled%%s" $pagecount] {}} bind . <Control-O> MainWindow_OnOpen bind . <Control-o> MainWindow_OnOpen bind . <Control-S> MainWindow_OnSave bind . <Control-s> MainWindow_OnSave bind . <Control-Q> MainWindow_OnQuit bind . <Control-q> MainWindow_OnQuit # Restore the main window's visibility wm deiconify . } # MainWindow_OnPageClose # Called for closing a datatree currently shown by a page. Checks if the # data structure is saved before proceeding. Destroys the page and removes the # data structure from memory. # ARGUMENTS: "page" is the Tk path to the page to be closed. proc MainWindow_OnPageClose {page} { variable Pages # If the data of this page is not saved, select it and ask the user to save if { [dict get $Pages($page) unsaved] } { .nbk select $page set det [::msgcat::mc "Unsaved data in %s" \ [dict get $Pages($page) title]] set dlg [tk_messageBox -icon question -type yesnocancel \ -message [::msgcat::mc "Save changes before closing?"] \ -title "eDictor" \ -detail $det -default yes] switch -- $dlg { cancel { return -code return } yes { MainWindow_OnSave } }; # end of switch } .nbk forget $page destroy $page array unset Pages $page return } # MainWindow_OnOpen # Callback for an "open file" action from the interface (menu, button, etc). # Shows a file dialog and, if the reading of the file succeeds, a new page is # created, the file contents are written to the page's value panel proc MainWindow_OnOpen {} { variable Pages variable pagecount variable env # Show the Open dialog set paths [tk_getOpenFile \ -initialdir $env(HOME) \ -multiple true \ -title [::msgcat::mc "Open data file..."] \ -parent . \ ] foreach path $paths { if { [catch {File_LoadRaw $path} rv ro] } { set errormsg $rv tk_messageBox -icon error -title "eDictor" -type ok \ -message $errormsg } else { # No error: "rv" contains the successful result of File_LoadRaw set title [file tail $path] set newpage [Page_New $title $path] $newpage.vp setdata $rv } } return } # MainWindow_OnSaveAs # Called in the event of a "Save As..." action by the user. # ARGUMENTS: # RESULTS: Shows a Save As... dialog, asking for a filename. This filename is # then stored in the datatree's properties, and a Save action is called. proc MainWindow_OnSaveAs {} { variable Pages # Get the currently active tab set page [.nbk select] # Check if there is already a filepath for this page set curpath [dict get $Pages($page) filepath] if { $curpath ne {} } { set initdir [file dirname $curpath] set initfile [file tail $curpath] } else { variable env set initdir $env(HOME) set initfile [dict get $Pages($page) title] } # Open the "Save As" file dialog set newpath [tk_getSaveFile\ -initialdir $initdir \ -initialfile $initfile \ -parent . \ -title [::msgcat::mc "Save as..."] \ ] if { $newpath ne {} } { # Get the page's valuepanel data set data [$page.vp getdata] if { [catch {File_SaveRaw $newpath $data} rv ro] } { set errormsg [::msgcat::mc "Can't write to file %s" $newpath] tk_messageBox -icon error -title "eDictor" -type ok \ -message $errormsg -detail $rv # Call MainWindow_OnSaveAs for trying another path MainWindow_OnSaveAs } else { dict set Pages($page) filepath $newpath # v.0.1.1, 10 Jul 2011 - Update the page's title to the new filename dict set Pages($page) title [file tail $newpath] Page_Modified $page false } } return } # MainWindow_OnSave # Called in the event of a "Save" action by the user. Discover which page is # currently shown in the notebook, then uses the filepath stored in the # respective datatree for the proper Save procedure. proc MainWindow_OnSave {} { variable Pages # Get the currently active tab set page [.nbk select] set path [dict get $Pages($page) filepath] if { $path eq "" } { MainWindow_OnSaveAs return } # Get the page valuepanel's data set data [$page.vp getdata] if { [catch {File_SaveRaw $path $data} rv ro] } { set errormsg [::msgcat::mc "Can't write to file" $path] tk_messageBox -icon error -title "eDictor" -type ok -detail $path\ -message $errormsg # Call MainWindow_OnSaveAs for trying another path MainWindow_OnSaveAs } else { Page_Modified $page false } } # MainWindow_OnQuit # Checks if there are shown and unsaved data structures before proceeding. # Destroys the main window and exits the program. proc MainWindow_OnQuit {} { variable Pages # Browse thru the pages data: if there's any unsaved, ask user foreach page [array names Pages] { MainWindow_OnPageClose $page } destroy . exit } # MainWindow_OnAbout # Displays the "About" dialog. proc MainWindow_OnAbout {} { set msg "eDictor 0.1" set d 14 set m Jul set y 2011 set det [::msgcat::mc "Date: %s/%s/%s\n" $d $m $y] set det "$det[::msgcat::mc "GPL license\nFabricio Rocha"]" tk_messageBox -icon info -type ok -default ok -message $msg -detail $det return } # ############################################################################# # FILE I/O # ############################################################################# # File_Load_Raw # Loads a file as a raw string. # ARGUMENTS: "path" is the file to be read # RESULTS: Returns the data read from the file. proc File_LoadRaw {path} { set data {} if { [catch {open $path r} rv ro] } { set errormsg [::msgcat::mc "Can't read from file %s" $path] return -code error $errormsg } else { set filehandler $rv set data [read -nonewline $filehandler] close $filehandler } return $data } # File_Save_Raw # Outputs to a file the passed data without any formatting (i.e., simply as it # is stored in a valuepanel) proc File_SaveRaw {path data} { if { [catch {open $path w} rv ro] } { set errormsg [::msgcat::mc "Can't write to %s" $path] return -code error $errormsg } else { set filehandler $rv puts -nonewline $filehandler $data close $filehandler } return } # ############################################################################# # Application startup at "script root" level # ############################################################################# Init
The initial, quick-and-dirty release. It needs the valuepanel code saved to a file called "valuepanel.tcl" in the same directory.
#!/bin/sh # -*- tcl -*- # The next line is executed by /bin/sh, but not tcl \ exec tclsh "$0" ${1+"$@"} # eDictor - A Tcl viewer/editor for Tcl lists and dicts # Fabricio Rocha, June 2011 package require Tcl 8.5 package require Tk 8.5 package require msgcat source valuepanel.tcl # ############################################################################# # # ############################################################################# # Init proc Init {} { variable pagecount 0 variable Pages set scriptdir [file dirname [file normalize [info script]]] # Configure msgcat catch {::msgcat::mcload [file join $scriptdir "langs"]} # Create the main window MainWindow_Create # Create the data for the first page (still empty) set ptitle [::msgcat::mc "Untitled%s" $pagecount] Page_New $ptitle {} } # Page_New # Create and configure the data structure which represents a page # ARGUMENTS: "title" is the title of the new page. "filepath" is optional. # RESULTS: A new page is created in the notebook, with an empty valuepanel. # Returns the Tk path to the new page frame. proc Page_New { title {filepath {}} } { variable pagecount variable Pages set page [ttk::frame .nbk.frm$pagecount] .nbk add $page -text $title -sticky news -padding 2 ::ValuePanel::valuepanel $page.vp bind $page.vp <<Modified>> [list Page_Modified $page true] ttk::button $page.btnClose -text "X" -width 0 \ -command [list MainWindow_OnPageClose $page] grid $page.btnClose -column 0 -row 0 -sticky e grid $page.vp -column 0 -row 1 -sticky news grid columnconfigure $page 0 -weight 1 grid rowconfigure $page 0 -weight 0 grid rowconfigure $page 1 -weight 1 dict set Pages($page) filepath $filepath dict set Pages($page) unsaved false dict set Pages($page) title $title .nbk select $page incr pagecount return $page } # Page_Modified # Called as a binding for the <<Modified>> event of a page's valuepanel. # ARGUMENTS: "pagenum" is the index of the page; "modified" is boolean proc Page_Modified {page modified} { variable Pages dict set Pages($page) unsaved $modified set status [expr bool($modified)] if { $status == 1 } { set newtitle "[dict get $Pages($page) title]*" } else { set newtitle [dict get $Pages($page) title] } .nbk tab $page -text $newtitle return } # ############################################################################# # MAIN WINDOW AND DIALOGS # ############################################################################# # MainWindow_Create # Creates the main window's widgets. # ARGUMENTS: # RESULTS: proc MainWindow_Create {} { variable pagecount wm withdraw . wm protocol . WM_DELETE_WINDOW MainWindow_OnQuit # Setup the window size, title, etc wm title . "eDictor" # Create the menus option add *tearOff 0 menu .menubar . configure -menu .menubar menu .menubar.file .menubar add cascade -menu .menubar.file -label [::msgcat::mc "File"] .menubar.file add command -label [::msgcat::mc "New"] \ -command {Page_New [::msgcat::mc "Untitled%s" $pagecount] {}}\ -accelerator "Ctrl+N" .menubar.file add command -label [::msgcat::mc "Open..."] \ -command MainWindow_OnOpen -accelerator "Ctrl+O" .menubar.file add command -label [::msgcat::mc "Save"] \ -command MainWindow_OnSave -accelerator "Ctrl+S" .menubar.file add command -label [::msgcat::mc "Save As..."] \ -command MainWindow_OnSaveAs .menubar.file add separator .menubar.file add command -label [::msgcat::mc "Quit"] \ -command MainWindow_OnQuit -accelerator "Ctrl+Q" menu .menubar.help .menubar add cascade -menu .menubar.help -label [::msgcat::mc "Help"] .menubar.help add command -label [::msgcat::mc "About..."]\ -command MainWindow_OnAbout # The main notebook ttk::notebook .nbk -padding {2 2} # Layout all the stuff grid .nbk -column 0 -row 0 -sticky news grid columnconfigure . 0 -weight 1 grid rowconfigure . 0 -weight 1 # Configure global bindings. Does someone know a way to avoid this silly # duplication due to upper/lower case? bind . <Control-N> {Page_New [::msgcat::mc "Untitled%%s" $pagecount] {}} bind . <Control-n> {Page_New [::msgcat::mc "Untitled%%s" $pagecount] {}} bind . <Control-O> MainWindow_OnOpen bind . <Control-o> MainWindow_OnOpen bind . <Control-S> MainWindow_OnSave bind . <Control-s> MainWindow_OnSave bind . <Control-Q> MainWindow_OnQuit bind . <Control-q> MainWindow_OnQuit # Restore the main window's visibility wm deiconify . } # MainWindow_OnPageClose # Called for closing a datatree currently shown by a page. Checks if the # data structure is saved before proceeding. Destroys the page and removes the # data structure from memory. # ARGUMENTS: "page" is the Tk path to the page to be closed. proc MainWindow_OnPageClose {page} { variable Pages # If the data of this page is not saved, select it and ask the user to save if { [dict get $Pages($page) unsaved] } { .nbk select $page set det [::msgcat::mc "Unsaved data in %s" \ [dict get $Pages($page) title]] set dlg [tk_messageBox -icon question -type yesnocancel \ -message [::msgcat::mc "Save changes before closing?"] \ -title "eDictor" \ -detail $det -default yes] switch -- $dlg { cancel { return -code return } yes { MainWindow_OnSave } }; # end of switch } .nbk forget $page destroy $page array unset Pages $page return } # MainWindow_OnOpen # Callback for an "open file" action from the interface (menu, button, etc). # Shows a file dialog and, if the reading of the file succeeds, a new page is # created, the file contents are written to the page's value panel proc MainWindow_OnOpen {} { variable Pages variable pagecount variable env # Show the Open dialog set paths [tk_getOpenFile \ -initialdir $env(HOME) \ -multiple true \ -title [::msgcat::mc "Open data file..."] \ -parent . \ ] foreach path $paths { if { [catch {File_LoadRaw $path} rv ro] } { set errormsg $rv tk_messageBox -icon error -title "eDictor" -type ok \ -message $errormsg } else { # No error: "rv" contains the successful result of File_LoadRaw set title [file tail $path] set newpage [Page_New $title $path] $newpage.vp setdata $rv } } return } # MainWindow_OnSaveAs # Called in the event of a "Save As..." action by the user. # ARGUMENTS: # RESULTS: Shows a Save As... dialog, asking for a filename. This filename is # then stored in the datatree's properties, and a Save action is called. proc MainWindow_OnSaveAs {} { variable Pages # Get the currently active tab set page [.nbk select] # Check if there is already a filepath for this page set curpath [dict get $Pages($page) filepath] if { $curpath ne {} } { set initdir [file dirname $curpath] set initfile [file tail $curpath] } else { variable env set initdir $env(HOME) set initfile [dict get $Pages($page) title] } # Open the "Save As" file dialog set newpath [tk_getSaveFile\ -initialdir $initdir \ -initialfile $initfile \ -parent . \ -title [::msgcat::mc "Save as..."] \ ] if { $newpath ne {} } { # Get the page's valuepanel data set data [$page.vp getdata] if { [catch {File_SaveRaw $newpath $data} rv ro] } { set errormsg [::msgcat::mc "Can't write to file %s" $newpath] tk_messageBox -icon error -title "eDictor" -type ok \ -message $errormsg -detail $rv # Call MainWindow_OnSaveAs for trying another path MainWindow_OnSaveAs } else { dict set Pages($page) filepath $newpath Page_Modified $page false } } return } # MainWindow_OnSave # Called in the event of a "Save" action by the user. Discover which page is # currently shown in the notebook, then uses the filepath stored in the # respective datatree for the proper Save procedure. proc MainWindow_OnSave {} { variable Pages # Get the currently active tab set page [.nbk select] set path [dict get $Pages($page) filepath] if { $path eq "" } { MainWindow_OnSaveAs return } # Get the page valuepanel's data set data [$page.vp getdata] if { [catch {File_SaveRaw $path $data} rv ro] } { set errormsg [::msgcat::mc "Can't write to file" $path] tk_messageBox -icon error -title "eDictor" -type ok -detail $path\ -message $errormsg # Call MainWindow_OnSaveAs for trying another path MainWindow_OnSaveAs } else { Page_Modified $page false } } # MainWindow_OnQuit # Checks if there are shown and unsaved data structures before proceeding. # Destroys the main window and exits the program. proc MainWindow_OnQuit {} { variable Pages # Browse thru the pages data: if there's any unsaved, ask user foreach page [array names Pages] { MainWindow_OnPageClose $page } destroy . exit } # MainWindow_OnAbout # Displays the "About" dialog. proc MainWindow_OnAbout {} { set msg "eDictor 0.1" set d 14 set m Jul set y 2011 set det [::msgcat::mc "Date: %s/%s/%s\n" $d $m $y] set det "$det[::msgcat::mc "GPL license\nFabricio Rocha"]" tk_messageBox -icon info -type ok -default ok -message $msg -detail $det return } # ############################################################################# # FILE I/O # ############################################################################# # File_Load_Raw # Loads a file as a raw string. # ARGUMENTS: "path" is the file to be read # RESULTS: Returns the data read from the file. proc File_LoadRaw {path} { set data {} if { [catch {open $path r} rv ro] } { set errormsg [::msgcat::mc "Can't read from file %s" $path] return -code error $errormsg } else { set filehandler $rv set data [read -nonewline $filehandler] close $filehandler } return $data } # File_Save_Raw # Outputs to a file the passed data without any formatting (i.e., simply as it # is stored in a valuepanel) proc File_SaveRaw {path data} { if { [catch {open $path w} rv ro] } { set errormsg [::msgcat::mc "Can't write to %s" $path] return -code error $errormsg } else { set filehandler $rv puts -nonewline $filehandler $data close $filehandler } return } # ############################################################################# # Application startup at "script root" level # ############################################################################# Init