http://qs321.pair.com?node_id=410596
Category: GUI Programming
Author/Contact Info
Description: This is a Tk-based hierarchical Todo list keeper. At work I would always keep a simple text file with a list of things to do. As I continued to neglect them, and as the tasks got more complicated in structure, I felt a need to have a GUI for this stuff; however, none of the Linux-based GUIs I knew of had the functionality of organizing tasks in a tree. Anyway, instead of finding a good one, I thought I'd learn Tk by making this.

You can add and delete items by right-clicking on things and choosing from a menu. Use the arrows to change order of things. If an item text begins with "(!)", it will be highlighted. If it begins with "(wish)", it will be grayed out. Unfortunately, there is not much freedom in moving items around, except for changing order.

By default, it stores the created list in ~/.todo.txt. Take a look at the file - it's very simple and human-readable, so you can still use it with any regular text editor.

Be gentle on my coding practices - this was written a while ago; and some things I am simply stubborn about :).

#!/usr/bin/perl -w

use warnings;

use Tk;
use Tk::Font;
use Tk::ItemStyle;
use Tk::Adjuster;
use Tk::Button;
use Tk::HList;

##
#
#       Main application
#
##

package App;

sub new($)
{
        my($class) = @_;
        my $self = {};
        bless $self, $class;

        # read bundled data
        my(undef, %data) = split(/========\s+(.*?)\s+========\n/,
                join('', <::DATA>));

        # misc vars
        $self->{ActiveItem} = undef;
        $self->{File} = undef;

        # main window
        $self->{TopWindow} = new MainWindow();

        # icons
        $self->{Icons}{Plus} = $self->{TopWindow}->Pixmap(-data => $da
+ta{'icon plus'});
        $self->{Icons}{Minus} = $self->{TopWindow}->Pixmap(-data => $d
+ata{'icon minus'});
        $self->{Icons}{Up} = $self->{TopWindow}->Pixmap(-data => $data
+{'icon up'});
        $self->{Icons}{Down} = $self->{TopWindow}->Pixmap(-data => $da
+ta{'icon down'});
        $self->{Icons}{ToDo} = $self->{TopWindow}->Pixmap(-data => $da
+ta{'icon todo'});

        $self->{TopWindow}->iconmask(undef);
        #$self->{TopWindow}->iconbitmap(undef);
        $self->{TopWindow}->iconimage($self->{Icons}{ToDo});

        # fonts
        $self->{Fonts}{Regular} = $self->{TopWindow}->Font(-family => 
+'Verdana', -size => 10);
        $self->{Fonts}{RegularBold} = $self->{TopWindow}->Font(-family
+ => 'Verdana', -size => 10, -weight => 'bold');
        $self->{Fonts}{TextBox} = $self->{TopWindow}->Font(-family => 
+'Verdana', -size => 12);
        $self->{Fonts}{Button} = $self->{TopWindow}->Font(-family => '
+Verdana', -size => 10);

        # display item styles - for the main tree
        $self->{ItemStyles}{Regular} = $self->{TopWindow}->ItemStyle('
+imagetext',
                -font => $self->{Fonts}{Regular});
        $self->{ItemStyles}{Bold} = $self->{TopWindow}->ItemStyle('ima
+getext',
                -font => $self->{Fonts}{RegularBold});
        $self->{ItemStyles}{RegularGray} = $self->{TopWindow}->ItemSty
+le('imagetext',
                -font => $self->{Fonts}{Regular},
                -foreground => 'gray');
        $self->{ItemStyles}{BoldGray} = $self->{TopWindow}->ItemStyle(
+'imagetext',
                -font => $self->{Fonts}{RegularBold},
                -foreground => 'gray');
        $self->{ItemStyles}{RegularUrgent} = $self->{TopWindow}->ItemS
+tyle('imagetext',
                -font => $self->{Fonts}{Regular},
                -foreground => 'red', -selectforeground => 'red', -act
+iveforeground => 'red');
        $self->{ItemStyles}{BoldUrgent} = $self->{TopWindow}->ItemStyl
+e('imagetext',
                -font => $self->{Fonts}{RegularBold},
                -foreground => 'red', -selectforeground => 'red', -act
+iveforeground => 'red');

        # the main tree widget
        $self->{ToDoTree} = $self->{TopWindow}->Scrolled('HList',
                -width => 70, -indent => 35,
                -scrollbars => 'osoe',
                -drawbranch => 0, -separator => '.',
                -selectmode => 'none',
                -command => sub { $self->toggleItem($_[0]) },
                -browsecmd => sub { $self->setActiveItem($_[0]) });
        $self->{ToDoTree}->add('root', -itemtype => 'imagetext',
                -text => "To Do", -image => $self->{Icons}{ToDo}); # r
+oot item

        # popup invocation
        $self->{ToDoTree}->bind('<Button-3>' => sub {
                my $tree = $self->{ToDoTree};

                # we select the item under cursor
                my $item = $tree->pointery() - $tree->rooty();
                $item = $tree->nearest($item);
                $tree->selectionClear();
                if(defined $item) { $tree->selectionSet($item); }

                # show the context menu
                $self->{ToDoTreeMenu}->Popup(-popover => 'cursor', -po
+panchor => 'nw');
                });

        # tree popup menu
        $self->{ToDoTreeMenu} = $self->{TopWindow}->Menu(-tearoff => 0
+,
                -font => $self->{Fonts}{Button});
        $self->{ToDoTreeMenu}->command(-label => 'Add Sub-item',
                -command => sub { my $sel = ($self->{ToDoTree}->info('
+selection'))[0];
                        $self->setActiveItem($self->addItemUnder($sel)
+); });
        $self->{ToDoTreeMenu}->command(-label => 'Remove',
                -command => sub { my $sel = ($self->{ToDoTree}->info('
+selection'))[0];
                        $self->removeItem($sel); });

        # item editor
        $self->{ItemEditor} = $self->{TopWindow}->Text(-width => 40,
                -font => $self->{Fonts}{TextBox}, -wrap => 'word');
        $self->{ItemEditor}->bind('<KeyPress>' =>
                sub { $self->setItemContents($self->{ActiveItem},
                        $self->{ItemEditor}->get('0.0', 'end')) });

        # toolbar
        my $toolbar = $self->{TopWindow}->Frame();
        $toolbar->Button(-text => 'Load...',
                -font => $self->{Fonts}{Button},
                -command => sub {
                        my $file = $self->{TopWindow}->getOpenFile(-ti
+tle => 'Open To Do File');
                        if($file ne '') { $self->loadFile($file) }
                })->pack(-side => 'left');
        $toolbar->Button(-text => 'Save',
                -font => $self->{Fonts}{Button},
                -command => sub { $self->saveFile($self->{File}) })
                ->pack(-side => 'left');
        $toolbar->Button(-text => 'Save As...',
                -font => $self->{Fonts}{Button},
                -command => sub {
                        my $file = $self->{TopWindow}->getSaveFile(-in
+itialfile => $self->{File},
                                -title => 'Save To Do File');
                        if($file ne '') { $self->saveFile($file); }
                })->pack(-side => 'left');

        # item toolbar
        my $itembar = $self->{TopWindow}->Frame();
        $itembar->Button(-text => 'Shift Up',
                -image => $self->{Icons}{Up},
                -font => $self->{Fonts}{Button},
                -command => sub {
                        my $sel = ($self->{ToDoTree}->info('selection'
+))[0];
                        $self->shiftItem($sel, -1);
                })->pack(-side => 'left');
        $itembar->Button(-text => 'Shift Down',
                -image => $self->{Icons}{Down},
                -font => $self->{Fonts}{Button},
                -command => sub {
                        my $sel = ($self->{ToDoTree}->info('selection'
+))[0];
                        $self->shiftItem($sel, 1);
                })->pack(-side => 'left');

        # positioning
        $toolbar->pack(-side => 'top', -fill => 'x');
        $itembar->pack(-side => 'top', -fill => 'x');
        $self->{ItemEditor}->pack(-side => 'right', -fill => 'y');
        $self->{TopWindow}->Adjuster(-widget => $self->{ItemEditor}, -
+side => 'right')
                ->pack(-side => 'right', -fill => 'y');
        $self->{ToDoTree}->pack(-expand => 1, -fill => 'both');

        # kickoff
        $self->loadFile($ENV{'HOME'} . '/.todo.txt');

        return $self;
}

# sets the current file path
sub setFile($$)
{
        my($self, $file) = @_;
        $self->{File} = $file;
        $self->{TopWindow}->configure(-title => "Todo - $file");
}

# hides/unhides children of specified item
sub toggleItem($$)
{
        my($self, $path) = @_;
        return unless(defined $path && $path ne 'root');
        my $direction = 0;
        my $tree = $self->{ToDoTree};
        foreach($tree->info('children', $path))
        {
                if($tree->info('hidden', $_))
                        { $tree->show('entry', $_); $direction = 1 }
                else { $tree->hide('entry', $_); $direction = -1 }
        }

        $self->updateItemStyle($path);
}

# shifts item
sub shiftItem($$$)
{
        my($self, $path, $offset) = @_;
        return unless(defined $path && $path ne 'root');
        my $tree = $self->{ToDoTree};

        # get the neighbor to hop over
        my $parent = $tree->info('parent', $path);
        my @siblings = $tree->info('children', $parent);
        my $myIndex = undef;
        foreach(0 .. $#siblings)
        { if($siblings[$_] eq $path) { $myIndex = $_; last; } }
        return unless(defined $myIndex);
        my $newIndex = $myIndex + ($offset > 0 ? 1 : -1);
        return if($newIndex < 0 or $newIndex > $#siblings);
        my $sibling = $siblings[$newIndex];

        # move the item itself
        my $side = $offset > 0 ? -after : -before;
        my $newPath = $self->addItemUnder($parent, $side => $sibling);

        # do the copying
        my @queue = ($path, $newPath);
        while(scalar @queue)
        {
                my $from = shift @queue;
                my $to = shift @queue;
                $self->setItemContents($to, $self->getItemContents($fr
+om));

                # create children and schedule them for copying
                foreach($tree->info('children', $from))
                { push @queue, $_, $self->addItemUnder($to); }
        }

        # delete old item
        $self->removeItem($path);

        $self->setActiveItem($newPath);
}

# sets the contents of the item and updates its appearance
sub setItemContents($$$)
{
        my($self, $path, $data) = @_;
        return unless(defined $path && $path ne 'root');
        $data =~ s/\s+$//s; # remove trailing whitespace
        $data =~ s/^\n+//s; # remove leading newlines

        my $firstLine;
        if($data =~ /^(.*?)\n/) { $firstLine = "$1  (...)"; }
        elsif($data eq '') { $firstLine = '(blank)'; }
        else { $firstLine = $data; }

        $self->{ToDoTree}->entryconfigure($path, -text => $firstLine);
        $self->{ToDoTree}->entryconfigure($path, -data => $data);
        $self->updateItemStyle($path);
}

# resets the display style of the item
sub updateItemStyle($$$)
{
        my($self, $path) = @_;
        return unless(defined $path && $path ne 'root');

        my $data = $self->getItemContents($path);
        my($firstChild) = $self->{ToDoTree}->info('children', $path);

        my $icon = undef;
        my $style = 'Regular';
        if(defined $firstChild)
        {
                $style = 'Bold';
                $icon = $self->{Icons}{Minus};

                # see if it's expanded
                if($self->{ToDoTree}->info('hidden', $firstChild))
                        { $icon = $self->{Icons}{Plus}; }
        }

        if($data =~ /^\(?\!/o) { $style .= 'Urgent'; }
        elsif($data =~ /^\(wish/i) { $style .= 'Gray'; }

        $self->{ToDoTree}->itemConfigure($path, 0, -image => $icon,
                -style => $self->{ItemStyles}{$style});
}

# gets the contents of the item
sub getItemContents($$)
{
        my($self, $path) = @_;
        return undef unless(defined $path && $path ne 'root');
        my $data = $self->{ToDoTree}->info('data', $path);
        return defined $data ? $data : '';
}

# makes the specified item active and editable
# TODO: catch empty items
sub setActiveItem($$)
{
        my($self, $path) = @_;
        return unless(defined $path);
        $self->{ItemEditor}->delete('0.0', 'end');
        $self->{ItemEditor}->insert('0.0', $self->{ToDoTree}->info('da
+ta', $path));
        $self->{ActiveItem} = $path;
        $self->{ToDoTree}->selectionClear();
        $self->{ToDoTree}->selectionSet($path);
        $self->{ToDoTree}->anchorSet($path);
        $self->{ToDoTree}->see($path);
}

# adds an item under specified one
sub addItemUnder($$@)
{
        my($self, $path, @options) = @_;
        return unless(defined $path);

        # add the child
        my $newpath = $self->{ToDoTree}->addchild($path, -itemtype => 
+'imagetext', -text => "New Item",
                -style => $self->{ItemStyles}{Regular}, @options);
        $self->updateItemStyle($newpath);

        # expand parent and update style
        # root entry is always expanded
        if($path ne 'root')
        {
                foreach($self->{ToDoTree}->info('children', $path)) { 
+$self->{ToDoTree}->show('entry', $_); }
                $self->updateItemStyle($path);
        }

        return $newpath;
}

# removes specified item
sub removeItem($$)
{
        my($self, $path) = @_;
        return unless(defined $path && $path ne 'root');
        my $parent = $self->{ToDoTree}->info('parent', $path);
        $self->{ToDoTree}->delete('entry', $path);

        # update parent's display
        $self->updateItemStyle($parent);

        # change focus
        $self->{ToDoTree}->selectionClear();
        $self->{ToDoTree}->selectionSet($parent);
        $self->setActiveItem($parent);
}

# populates the todo tree widget from file
sub loadFile($$)
{
        my($self, $fileName) = @_;
        my $tree = $self->{ToDoTree};

        open(FILE, $fileName) or return;
        my @lines = <FILE>;
        close(FILE);
        push @lines, '-';

        $tree->delete('offsprings', 'root');
        my($curText, $curHead, $line);
        my @parents = (['', 'root']); # list of 'head', 'entrypath' en
+tries
                # heads are guaranteed to decrease in length sequentia
+lly
        foreach $line (@lines)
        {
                chomp $line;

                if($line =~ /^\s*-/)
                {
                        # take care of the entry so far
                        if(defined $curText)
                        {
                                # go through parents and weed out ever
+ything
                                # but the elders (that removes sibling
+s from list)
                                while(length($curHead) <= length($pare
+nts[0]->[0]))
                                        { shift(@parents); }

                                # show the minus icon for the latest p
+arent
                                if(scalar @parents > 1) { $self->updat
+eItemStyle($parents[0]->[1]); }

                                # add ourselves under latest parent
                                $path = $self->addItemUnder($parents[0
+]->[1]);
                                $self->setItemContents($path, $curText
+);

                                # add ourselves as the latest parent
                                unshift @parents, [ $curHead, $path ];
                        }

                        # start new entry
                        $line =~ s/^(\s*-)\s*//;
                        $curHead = $1;
                        $curText = $line;
                }
                elsif(defined $curText)
                {
                        $line =~ s/^\s*//;
                        if($line ne '') { $curText .= "\n" . $line; }
                }
        }
        $self->setActiveItem('root');
        $self->setFile($fileName);
}

##      writes text file based on todo widget contents
sub saveFile($$)
{
        my($self, $fileName) = @_;
        my $tree = $self->{ToDoTree};

        # recursively generate lines
        my(@lines, @paths);
        @paths = ('root', -1);
        while(scalar @paths)
        {
                my $path = shift @paths;
                my $offset = shift @paths;
                if($offset > -1)
                {
                        my $head = "\t" x $offset;
                        my @contentLines = split(/\n/, $self->getItemC
+ontents($path));
                        push @lines, "$head- " . shift @contentLines;
                        push @lines, map { $head . $_ } @contentLines;
                }

                unshift @paths, map { $_, $offset + 1 } $tree->info('c
+hildren', $path);
        }

        open(FILE, ">$fileName");
        print FILE join("\n", @lines), "\n";
        close(FILE);

        $self->setFile($fileName);
}

##
#       Main
##

package main;

my $app = new App();

MainLoop();

# data items are separated by strings of '======== (name) ========'

__DATA__
======== icon down ========
/* XPM */
static char *down[] = {
/* columns rows colors chars-per-pixel */
"15 15 7 1",
"  c None",
"X c black",
"# c #C04040",
"@ c #D08040",
"O c #E0A040",
"* c #F0C040",
". c #FFFF40",
/* pixels */
"       XXXXXX  ",
"      X....X   ",
"     X....X    ",
"     X*.*.X    ",
"    X*.*.X     ",
"    X****X     ",
" XXXXO*O*XXXXX ",
" XOOOOOOOOOOX  ",
"  X@O@O@O@OX   ",
"   X@@@@@@@X   ",
"    X#@#@#X    ",
"     XX###X    ",
"       X##X    ",
"        XXX    ",
"          X    "
};

======== icon up ========
/* XPM */
static char *up[] = {
/* columns rows colors chars-per-pixel */
"15 15 7 1",
"  c None",
"X c black",
"# c #C04040",
"@ c #D08040",
"O c #E0A040",
"* c #F0C040",
". c #FFFF40",
/* pixels */
"          X    ",
"        XXX    ",
"       X..X    ",
"     XX...X    ",
"    X.*.*.X    ",
"   X*******X   ",
"  X*O*O*O*OX   ",
" XOOOOOOOOOOX  ",
" XXXXO@O@XXXXX ",
"    X@@@@X     ",
"    X@@@@X     ",
"     X#@#@X    ",
"     X####X    ",
"      X####X   ",
"       XXXXXX  "
};

======== icon plus ========
/* XPM */
static char *item_plus[] = {
/* columns rows colors chars-per-pixel */
"11 11 5 1",
"  c gray100", ". c #848484", "X c black", "o c gray100", "O c None",
/* pixels */
"OOOOOOOOOOO",
"O.........O",
"O.       .O",
"O.   X   .O",
"O.   X   .O",
"O. XXXXX .O",
"O.   X   .O",
"O.   X   .O",
"O.       .O",
"O.........O",
"OOOOOOOOOOO" };

======== icon minus ========
/* XPM */
static char *item_minus[] = {
/* columns rows colors chars-per-pixel */
"11 11 5 1",
"  c gray100", ". c #848484", "X c black", "o c gray100", "O c None",
/* pixels */
"OOOOOOOOOOO",
"O.........O",
"O.       .O",
"O.       .O",
"O.       .O",
"O. XXXXX .O",
"O.       .O",
"O.       .O",
"O.       .O",
"O.........O",
"OOOOOOOOOOO" };

======== icon todo ========
/* XPM */
static char *todo[] = {
/* columns rows colors chars-per-pixel */
"16 16 76 1",
"  c black", ". c #252626", "X c #262727", "o c #272828", "O c #353636
+", "+ c gray24", "@ c #6C0000", "# c #444545",
"$ c gray27", "% c #484949", "& c #4A4B4B", "* c #4B4C4C", "= c #4C4C4
+C", "- c #535353", "; c #545555", ": c #5A5A5A",
"> c #616262", ", c #686969", "< c #6C6C6C", "1 c #706C66", "2 c #716D
+67", "3 c gray44", "4 c #777777", "5 c #7B7770",
"6 c gray50", "7 c #1E6087", "8 c #1B6693", "9 c #1B6A97", "0 c #1B6C9
+9", "q c #1B6F9B", "w c #1C729E", "e c #1B789C",
"r c #1C75A0", "t c #1C77A2", "y c #1C7AA5", "u c #3C6A83", "i c #D200
+00", "p c #FA0000", "a c #F64F4F", "s c #AD987A",
"d c #958F87", "f c #9B958C", "g c #9C968D", "h c #A09A91", "j c #A29C
+93", "k c #AFA088", "l c #A6A094", "z c #B2AA99",
"x c #ACA8A0", "c c #B2AEA6", "v c #B7B3AB", "b c #CEB691", "n c #DEC4
+9C", "m c #D3C1A4", "M c #D5C7AF", "N c #E0C69E",
"B c #E3CCA8", "V c #E3D0B1", "C c #E5D2B3", "Z c #E8D8BE", "A c #C9DD
+E2", "S c #CCDFE4", "D c #CCE0E3", "F c #D4E3E6",
"G c #D7E7E8", "H c #EADFC9", "J c #FFC0C0", "K c #EDE5D3", "L c #EEEB
+DD", "P c #EFECDE", "I c #E0EBEB", "U c #E5EDED",
"Y c #F2F2E9", "T c #F4F8F4", "R c #F7FFFF", "E c None",
/* pixels */
"EEEEEEEEEEEEEEEE", "E6666666666666EE", "4RRRRRRRRRRRRR4E", "3Tu789qqw
+wwyeT3E",
"3TUGDAAAAASFIJ< ", ",YxcxvxcxcxcJp; ", ">PjPjLjPjPjJp@% ", ":KjjjJafh
+jJp@l$ ",
"-HjH@ppidJp@2z= ", "&Zjjg@ppap@25M&E", "$CjChm@pp@1kdV$E", "+Bjjjhd@@
+15dhB+E",
"ONNNNNnbssbnNNOE", "EooooooXXXXoooEE", "EEEEEEEEEEEEEEEE", "EEEEEEEEE
+EEEEEEE" };