├── .gitignore ├── README.md ├── doc ├── sample-config.json └── screenshot.png └── task-dashboard /.gitignore: -------------------------------------------------------------------------------- 1 | # 2 | # general 3 | # 4 | *.bak 5 | *~ 6 | 7 | # 8 | # vim 9 | # 10 | .*.sw? 11 | 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | task-dashboard 3 | ============== 4 | 5 | A tmux based dashboard for taskwarrior. 6 | 7 | ![Screenshot](doc/screenshot.png) 8 | 9 | 10 | Setup 11 | ----- 12 | 13 | 1. Clone repository 14 | ``` 15 | git clone https://github.com/8ware/task-dashboard.git 16 | ``` 17 | 2. Copy sample configuration file to its expected location 18 | ``` 19 | cp doc/sample-config.json ~/.task/dashboard.json 20 | ``` 21 | *If the task shell is not available the last command must be adjusted!* 22 | 3. Start dashboard (preferably maximized) 23 | ``` 24 | urxvt -e $PWD/task-dashboard & 25 | ``` 26 | 27 | 28 | Layout Configuration 29 | -------------------- 30 | 31 | The configuration of the layout is closely tied to tmux' behavior of splitting 32 | windows. Thus the target pane and execution order does matter. The layout is 33 | constructed splitting a *pivot* pane (i.e. the pane which would be focussed if 34 | splitting manually). 35 | 36 | The layout configuration consists of a key which is the *layout specification* 37 | and an array of *commands* as its value. Instead of a command another nested 38 | layout can be specified which allows to create more complex setups. The keys 39 | have the following structure: 40 | ``` 41 | {h|v}:(~|pN|lN)[:...] 42 | ``` 43 | Such a layout specification consists of a split direction, i.e. `h` or `v` as 44 | used with the tmux' `split-window` command (`h` for a vertical and `v` for a 45 | horizontal split), *one* pivot pane indicator (`~`) and one or more split 46 | directives, e.g. percentage (`p33` for a pane ratio of 2:1) or lines (`l2` for 47 | creating a pane with just two lines height). For example, the layout 48 | `v:l2:~:p33` will result in tree panes which are split horizontally where the 49 | top pane is two lines high, the bottom pane is approx. one third of the window 50 | height (i.e. a third of the window's height minus the two previously split 51 | lines) and the middle pane fills the rest of the space (since it is the pivot 52 | pane). 53 | 54 | When constructing an own layout the split order has to be taken into account. 55 | Internally the list of split directives is processed in the following order: 56 | 1. all directives before the pivot pane indicator split above 57 | 2. all directives after the pivot pane indicator split below *in reverse order* 58 | Consider the following directives: `v:~:p50:p33`. This will split the pane into 59 | three equally sized panes. First, the pane is split with a 2:1 ratio, then the 60 | pane is split again with a 1:1 ratio resulting in 1:1:1. 61 | 62 | See the [example configuration](doc/sample-config.json) file for a more complex example. 63 | 64 | **WARNING:** If you don't pay attention to always include the `rc.hooks:off` 65 | option in task commands (use `rc.verbose:hook` to avoid unnecessary output) you 66 | easily create a *fork bomb* (because using task commands invokes the hook which 67 | updates the view again). 68 | 69 | ### Markers 70 | 71 | Markers are used to annotate commands to indicate special handling for them. 72 | Currently two markers are supported: the focus marker (`*`) and the no-update 73 | marker (`!`). The former can be used to focus a certain pane after layouting 74 | has been finished; the latter will prevent execution of that command when 75 | updating the views (e.g. when running task shell in one pane). 76 | 77 | *Note, that the order of the marker matters (for now):* 78 | * `*!` ... valid 79 | * `!*` ... invalid 80 | 81 | --- 82 | Any improvements, especially regarding more intuitive ways of describing the 83 | layout, are appreciated. 84 | 85 | -------------------------------------------------------------------------------- /doc/sample-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "session" : "task-dashboard", 3 | "window" : "view", 4 | 5 | "auto-attach" : true, 6 | 7 | "layout" : { 8 | "v:~:p33:l2" : [ 9 | { "h:~:p33" : [ 10 | "task", 11 | { "v:~:p50:p33" : [ 12 | "task rc.context:none summary", 13 | "task rc.context:default overdue", 14 | "task rc.context:none +TODAY" 15 | ] } 16 | ] }, 17 | "task burndown.daily", 18 | "*!task shell" 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/8ware/task-dashboard/12203c556603a7a9853094471fd8998c13d50ac4/doc/screenshot.png -------------------------------------------------------------------------------- /task-dashboard: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use feature 'say'; 7 | 8 | =head1 NAME 9 | 10 | task-dashboard - A tmux-based dashboard for taskworrior 11 | 12 | =head1 SYNOPSIS 13 | 14 | # just 15 | task-dashboard 16 | 17 | =head1 DESCRIPTION 18 | 19 | Using tmux panes and task's hook capability a dashboard is created within a 20 | tmux session. If the hook gets triggered the views will be updated. 21 | 22 | =cut 23 | 24 | 25 | use Cwd qw(abs_path); 26 | use File::Basename; 27 | use Getopt::Long; 28 | use JSON::PP qw(decode_json); 29 | 30 | 31 | my %DEFAULT = ( 32 | CONFIG_NAME => 'dashboard.json', 33 | 34 | TMUX_SESSION_NAME => 'task-dashboard', 35 | TMUX_WINDOW_NAME => 'view', 36 | TMUX_WIDTH => 273, 37 | TMUX_HEIGHT => 74, 38 | ); 39 | 40 | # Taken from the 'Write Commands' list 41 | # (https://taskwarrior.org/download/task-2.5.0.ref.pdf) 42 | my @WRITE_COMMANDS = qw( 43 | add annotate denotate append config delete done duplicate edit import log 44 | modify prepent start stop synchronize undo 45 | 46 | context 47 | ); # Also accept 'context' since views probably depend on current context 48 | 49 | # Minimal interactive bash as a hack to keep panes open 50 | my $MINIMAL_BASH = 'bash --rcfile <(echo "unset MAIL PS1")'; 51 | 52 | 53 | my $config = get_config(); 54 | my $session = $config->{session} || $DEFAULT{TMUX_SESSION_NAME}; 55 | my $window = $config->{window} || $DEFAULT{TMUX_WINDOW_NAME}; 56 | 57 | # Called as task hook 58 | if (@ARGV == 6) { 59 | my $task_context = get_task_context(); 60 | 61 | # Only update view if executed task command is write command 62 | exit 0 unless grep { $_ eq $task_context->{command} } @WRITE_COMMANDS; 63 | 64 | # Exit if the dashboard is not running, yet 65 | dashboard_is_running($session) or exit 0; 66 | 67 | my @pane_commands = parse($config->{layout}, 0, []); 68 | for my $idx (0 .. $#pane_commands) { 69 | my $command = $pane_commands[$idx]; 70 | $command =~ s/^\*//; 71 | # Do not execute again if `!`-marker is present 72 | next if $command =~ /^!/; 73 | $command = sanitize($command); 74 | tmux('send-keys', '-t', "$session:$window.$idx", "clear; $command", 'Enter'); 75 | } 76 | 77 | # Called from terminal 78 | } else { 79 | # Exit if the dashboard is already running 80 | if (dashboard_is_running($session)) { 81 | say "Task dashboard already running."; 82 | tmux('attach', '-t', $session) if $config->{'auto-attach'}; 83 | exit 0; 84 | } 85 | 86 | my @pane_commands = parse($config->{layout}, 0, \my @split_options);; 87 | 88 | my $width = $config->{width} || $DEFAULT{TMUX_WIDTH}; 89 | my $height = $config->{height} || $DEFAULT{TMUX_HEIGHT}; 90 | 91 | # Check hook link before creating tmux session 92 | ensure_hook_link(); 93 | 94 | # Setup layout 95 | tmux('new', '-s', $session, '-n', $window, '-d', '-x', $width, '-y', $height, $MINIMAL_BASH); 96 | tmux('set', '-t', "$session:$window", 'status', "off"); 97 | tmux('split-window', @{$_}, $MINIMAL_BASH) for @split_options; 98 | 99 | # Executing commands initially 100 | for my $idx (0 .. $#pane_commands) { 101 | my $command = $pane_commands[$idx]; 102 | # Select pane if `*`-marker is present 103 | tmux('select-pane', '-t', "$session:$window.$idx") if $command =~ s/^\*//; 104 | $command =~ s/^!//; 105 | $command = sanitize($command); 106 | tmux('send-keys', '-t', "$session:$window.$idx", "clear; $command", 'Enter'); 107 | } 108 | 109 | tmux('attach', '-t', $session) if $config->{'auto-attach'}; 110 | } 111 | 112 | 113 | sub get_config { 114 | my $config_file = get_config_file(); 115 | 116 | die "Cannot find dashboard configuration!\n" unless -f $config_file; 117 | 118 | open my $fh, '<', $config_file or die $!; 119 | local $/; 120 | my $config = decode_json(<$fh>); 121 | close $fh or warn $!; 122 | 123 | return $config; 124 | } 125 | 126 | sub get_config_file { 127 | GetOptions('c|config=s' => \my $config); 128 | 129 | unless (defined $config) { 130 | $config = task_config('rc.data.location').'/'.$DEFAULT{CONFIG_NAME}; 131 | } 132 | 133 | return $config; 134 | } 135 | 136 | sub get_task_context { 137 | my $context = {}; 138 | my $idx = 0; 139 | for (qw(api args command rc data version)) { 140 | $context->{$_} = $ARGV[$idx++] =~ s/^$_://r or return; 141 | } 142 | return $context; 143 | } 144 | 145 | sub parse { 146 | my $layer = shift; 147 | my $pivot_id = shift; 148 | my $s_opts = shift; 149 | 150 | my ($layout, $contents) = each %{$layer}; 151 | my ($split, @parts) = split /:/, $layout; 152 | my ($pivot) = grep { $parts[$_] eq '~' } 0 .. $#parts; 153 | 154 | die if @parts != @{$contents}; 155 | 156 | # ID offset is consequently number of created panes 157 | my $id_offset = @{$s_opts}; 158 | 159 | # TODO Move $session and $window variable into this subroutine instead of 160 | # using their global scope 161 | for my $idx (0 .. $pivot-1) { 162 | push @{$s_opts}, [ '-t', "$session:$window.$pivot_id", "-$split", "-$parts[$idx]", '-b' ]; 163 | } 164 | for my $idx (reverse $pivot+1 .. $#parts) { 165 | push @{$s_opts}, [ '-t', "$session:$window.$pivot_id", "-$split", "-$parts[$idx]" ]; 166 | } 167 | 168 | my @indeces = ( $pivot, 0 .. $pivot-1, reverse $pivot+1 .. $#parts ); 169 | my @commands = @{$contents}[@indeces]; 170 | 171 | # Expand commands recursively 172 | for my $idx (0 .. $#commands) { 173 | my $command = $commands[$idx]; 174 | if (ref $command eq 'HASH') { 175 | my $p_id = $idx + $id_offset; # Index + the count of split-window w/o this round 176 | # First element is command of pivot pane 177 | ($commands[$idx], my @sub_cmds) = parse($command, $p_id, $s_opts); 178 | push @commands, @sub_cmds; 179 | } 180 | } 181 | 182 | return @commands; 183 | } 184 | 185 | sub dashboard_is_running { 186 | my $session = shift; 187 | return not system "tmux has-session -t '$session' > /dev/null 2>&1"; 188 | } 189 | 190 | sub ensure_hook_link { 191 | my $target = abs_path($0); 192 | my $symlink = task_config('rc.data.location') . '/hooks/on-exit_dashboard'; 193 | if (-e $symlink) { 194 | die "Hook symlink points to another location!\n" if abs_path($symlink) ne $target; 195 | } else { 196 | symlink $target, $symlink; 197 | } 198 | } 199 | 200 | sub task_config { 201 | chomp (my $output = `task rc.verbose:nothing rc.hooks:off _get @_`); 202 | $output =~ s/^~/$ENV{HOME}/; # Tilde expansion 203 | return $output; 204 | } 205 | 206 | sub tmux { 207 | system 'tmux', @_; 208 | } 209 | 210 | sub sanitize { 211 | my $command = shift; 212 | 213 | $command =~ s/^\s*\Ktask\b/task rc.verbose:label rc.hooks:off/; 214 | 215 | return $command; 216 | } 217 | 218 | --------------------------------------------------------------------------------