1. ## --- App::Cairn::Service::TaskArrange ------------------------------------------------
  2. #| Pre-order tree arrangement of a flat task list. Children sit
  3. #| directly under their parent when both are in the view, with depth
  4. #| incrementing per level. Tasks whose parent isn't in the set render
  5. #| at depth 0 as roots of their own.
  6. #|
  7. #| Returns an Array of `%( task => $task, depth => $Int )` hashes in
  8. #| display order. Position-based dedup (not id-based) means forecast's
  9. #| projected tasks — which share their parent's id — all emit as
  10. #| distinct rows instead of being collapsed into one.
  11. #|
  12. #| Projected tasks are treated as leaves: they never serve as parents
  13. #| in the lookup, so a later row won't be pulled under a projection
  14. #| of its ancestor.
  15. sub arrange-task-tree(@tasks --> Array) is export {
  16. # Only materialised tasks serve as parent-lookup targets —
  17. # projected tasks share their parent's id and would otherwise
  18. # overwrite the real row in %by-id, letting a projection act as
  19. # the "ancestor" of a later child row.
  20. my %by-id;
  21. for @tasks.kv -> $i, $t {
  22. next unless $t.id.defined;
  23. next if $t.is-projected;
  24. %by-id{$t.id} //= $i;
  25. }
  26. my @emitted-at = False xx @tasks.elems;
  27. my @out;
  28. # Inner routine (sub, not pointy-block) so `return` works — this
  29. # used to live as a pointy block inside Screen::Main and tripped
  30. # the outside-enclosing-Routine error when we tried to early-exit.
  31. sub emit-subtree(Int $i, Int $depth) {
  32. return if @emitted-at[$i];
  33. @emitted-at[$i] = True;
  34. my $t = @tasks[$i];
  35. @out.push: %( task => $t, depth => $depth );
  36. return unless $t.id.defined;
  37. return if $t.is-projected;
  38. # Find this task's children by positional scan. Walking @tasks
  39. # (vs a prebuilt children map) keeps sibling order aligned
  40. # with the caller's supplied ordering — important for
  41. # due-date / created-at sorts where the gateway already made
  42. # a choice we want to respect.
  43. for @tasks.kv -> $j, $c {
  44. next if @emitted-at[$j];
  45. next unless $c.parent-task-id.defined
  46. && $c.parent-task-id == $t.id;
  47. emit-subtree($j, $depth + 1);
  48. }
  49. }
  50. for @tasks.kv -> $i, $t {
  51. next if @emitted-at[$i];
  52. # Walk up to the highest ancestor still in the set —
  53. # that's where this task's subtree starts rendering. If no
  54. # ancestor is in view (typical for roots, or for descendants
  55. # whose parent was filtered out), $cur-idx stays at $i and
  56. # the task emits at depth 0.
  57. my $cur-idx = $i;
  58. loop {
  59. my $cur = @tasks[$cur-idx];
  60. last unless $cur.parent-task-id.defined
  61. && (%by-id{$cur.parent-task-id}:exists);
  62. my $parent-idx = %by-id{$cur.parent-task-id};
  63. last if $parent-idx == $cur-idx;
  64. $cur-idx = $parent-idx;
  65. }
  66. emit-subtree($cur-idx, 0);
  67. }
  68. @out.Array;
  69. }
  70. #| Split the arranged rows into parallel Arrays of Task models and
  71. #| depths — convenient for the store slice (app.tasks +
  72. #| app.task-row-depths) that keeps cursor indices aligned with the
  73. #| displayed order.
  74. sub arrange-split(@tasks --> List) is export {
  75. my @rows = arrange-task-tree(@tasks);
  76. my @arranged-tasks = @rows.map(*.<task>).Array;
  77. my @depths = @rows.map(*.<depth>).Array;
  78. ( @arranged-tasks, @depths );
  79. }
  80. ## --- App::Cairn::View::TaskRow -------------------------------------------------------
  81. #| Labels + style for every row in the main task list. Depths are
  82. #| supplied by the store (C<view-state-for> arranges tasks + depths in
  83. #| lock-step so cursor indices stay aligned with the displayed order).
  84. #| Index out of range falls back to 0 so a mis-sized caller doesn't
  85. #| throw — the worst case is a missed indent.
  86. #|
  87. #| NOTE: this used to memoise per row via a module-scope hash so the
  88. #| once-per-second overdue-tick subscription wouldn't reallocate every
  89. #| label. The cache was reverted because it shared module-level state
  90. #| that crossed render-frame boundaries — combined with Selkie's
  91. #| Pair-stored item model, this triggered a MoarVM spesh
  92. #| mis-specialisation in the render loop ("Cannot unbox 3274 bit wide
  93. #| bigint into native integer") that the Selkie::Widget.set-viewport
  94. #| free-sub workaround couldn't catch. The per-frame allocation cost
  95. #| is small in practice (overdue-tick fires once a second, lists are
  96. #| short); revisit only if profiling shows it as a real hot spot AND
  97. #| the spesh issue has been root-caused upstream.
  98. sub task-row-labels(@tasks, @depths, :$theme! --> List) is export {
  99. @tasks.kv.map(-> $i, $t {
  100. my $depth = @depths[$i] // 0;
  101. my $glyph = $t.is-completed ?? '☒' !! '☐';
  102. my $flag = $t.flagged ?? ' ⚑' !! '';
  103. my $due = $t.due-date.defined
  104. ?? ' · due ' ~ $t.due-date.substr(0, 10) !! '';
  105. my $tag = $t.is-projected ?? ' ↻' !! '';
  106. my $is-overdue = $t.is-active && overdue-date($t.due-date);
  107. my $overdue-prefix = $is-overdue ?? '‼ ' !! '';
  108. # Waiting indicator — makes waiting rows instantly distinguishable
  109. # when they show up in Forecast alongside active items.
  110. my $waiting-prefix = $t.is-waiting ?? '⏸ ' !! '';
  111. my $indent = ' ' x $depth;
  112. my $label = "$indent$overdue-prefix$waiting-prefix$glyph$flag {$t.title}$due$tag";
  113. # Scaffold rows (parent added purely for hierarchy context on
  114. # a filter-matching subtask) render dimmed so the user can
  115. # tell them apart from rows that actually matched the filter.
  116. my $style = $t.is-scaffold
  117. ?? Selkie::Style.new(fg => $theme.fg-dim, italic => True)
  118. !! task-style($t, :$theme, :$is-overdue);
  119. $label => $style;
  120. }).List;
  121. }
  122. ## --- App::Cairn::Widget::TaskList -------------------------------------------------------------
  123. method render() {
  124. return without self.plane;
  125. my UInt $max = self!max-offset;
  126. $!scroll-offset = $max if $!scroll-offset > $max;
  127. ncplane_erase(self.plane);
  128. my UInt $vh = self.rows;
  129. my UInt $vw = self.cols;
  130. my Bool $need-scrollbar = $!show-scrollbar && @!labels.elems > $vh;
  131. my UInt $content-w = $need-scrollbar ?? $vw - 1 !! $vw;
  132. my $base = self.theme.base;
  133. my $default-text = self.theme.text;
  134. my $highlight = self.theme.text-highlight;
  135. my $default-fg = $default-text.fg // 0xE6EDF3;
  136. my $default-bg = $base.bg // 0x1A1A2E;
  137. my UInt $visible = $vh min @!labels.elems;
  138. for ^$visible -> $row {
  139. my UInt $idx = $!scroll-offset + $row;
  140. last if $idx >= @!labels.elems;
  141. my $is-selected = $idx == $!cursor;
  142. my $row-style = @!styles[$idx] // $default-text;
  143. # Always write a fresh fg / bg / style-mask for every row.
  144. # notcurses's set-* calls are sticky on the plane, so a
  145. # defined value from the selected row (white fg, cleared
  146. # styles) would persist onto the next row if the row-style's
  147. # own fg was undefined — that bug made a second row look
  148. # focused when the top one was the actual cursor target.
  149. if $is-selected {
  150. # Selected row: theme-highlight fg (bright, usually white
  151. # or near-white) + highlight bg, styles cleared. The
  152. # bright fg beats red/yellow/dim for spotting the cursor
  153. # at a glance; clearing bold also keeps the checkbox
  154. # glyph from widening enough to clip the label.
  155. ncplane_set_fg_rgb(self.plane,
  156. $highlight.fg // $default-fg);
  157. ncplane_set_bg_rgb(self.plane,
  158. $highlight.bg // $default-bg);
  159. ncplane_set_styles(self.plane, 0);
  160. } else {
  161. ncplane_set_fg_rgb(self.plane, $row-style.fg // $default-fg);
  162. ncplane_set_bg_rgb(self.plane, $default-bg);
  163. ncplane_set_styles(self.plane, $row-style.styles);
  164. }
  165. my $text = @!labels[$idx] // '';
  166. $text = $text.substr(0, $content-w) if $text.chars > $content-w;
  167. $text = $text ~ (' ' x ($content-w - $text.chars))
  168. if $text.chars < $content-w;
  169. ncplane_putstr_yx(self.plane, $row, 0, $text);
  170. }
  171. self!render-scrollbar if $need-scrollbar;
  172. self.clear-dirty;
  173. }
  174. ## --- App::Cairn::StoreHandlers ---------------------------------------------------------
  175. #| Compute the complete view-state for a given perspective + drill
  176. #| pair in one place. `list-kind` is either 'task' (normal
  177. #| perspectives with just tasks) or 'hybrid' (projects / tags, with
  178. #| a filter strip of container rows on top of the task list). The
  179. #| filter strip always leads with an ALL row (id => Int type object)
  180. #| so the user can navigate back to "no filter" without an explicit
  181. #| exit keybind — just cursor up to the ALL row and the tasks refresh.
  182. method view-state-for(Str:D $perspective, Hash $drill,
  183. Bool :$include-archived = False,
  184. Str :$time-view-mode = 'task',
  185. :%time-drill = %(),
  186. --> Hash) {
  187. my $kind = ($drill // {})<kind> // '';
  188. my $id = ($drill // {})<id>;
  189. if $perspective eq 'time' {
  190. # Time perspective: aggregated totals across a rolling
  191. # window, grouped by task / project per the caller-supplied
  192. # C<$time-view-mode> (default: by task). Drill state on
  193. # C<%time-drill> overrides the top-level grouping with a
  194. # scoped aggregation — see Service::TimeReport::build's
  195. # C<%scope> param for the supported shapes.
  196. #
  197. # `tasks` is empty so the existing task-list subscription
  198. # doesn't fight us; the Time view branches on
  199. # C<list-kind eq 'time'> to render time-rows instead.
  200. my ($gb, %scope) = self!time-report-scope-for(
  201. $time-view-mode, %time-drill,
  202. );
  203. return {
  204. list-kind => 'time',
  205. container-kind => '',
  206. container-rows => [],
  207. tasks => [],
  208. task-row-depths => [],
  209. time-rows => App::Cairn::Service::TimeReport::build(
  210. $!db, group-by => $gb, scope => %scope,
  211. ),
  212. };
  213. }
  214. if $perspective eq 'projects' {
  215. my @rows = self!project-rows(:$include-archived);
  216. my @scaffolded = self!augment-with-scaffolds(
  217. self!tasks-for-project-drill($id),
  218. );
  219. my ($arranged, $depths) = arrange-split(@scaffolded);
  220. return {
  221. list-kind => 'hybrid',
  222. container-kind => 'project',
  223. container-rows => (self!all-row(perspective => 'project'), |@rows),
  224. tasks => $arranged,
  225. task-row-depths => $depths,
  226. time-rows => [],
  227. };
  228. }
  229. if $perspective eq 'tags' {
  230. my @rows = self!tag-rows(:$include-archived);
  231. my @scaffolded = self!augment-with-scaffolds(
  232. self!tasks-for-tag-drill($id),
  233. );
  234. my ($arranged, $depths) = arrange-split(@scaffolded);
  235. return {
  236. list-kind => 'hybrid',
  237. container-kind => 'tag',
  238. container-rows => (self!all-row(perspective => 'tag'), |@rows),
  239. tasks => $arranged,
  240. task-row-depths => $depths,
  241. time-rows => [],
  242. };
  243. }
  244. # Normal task perspectives — arrange so app.tasks reflects the
  245. # display order. Cursor-index selection (task/selected) indexes
  246. # into app.tasks, so the arrangement MUST happen here rather than
  247. # at render time, otherwise the Down-arrow in Forecast (which
  248. # interleaves projected rows that share the parent's id) would
  249. # pick a different task than the one the cursor visually lands on.
  250. #
  251. # The scaffold pass adds ancestor rows for filter-matched
  252. # subtasks (flagged subtask with unflagged parent, today-due
  253. # subtask with no-due-date parent, etc.) so the hierarchy stays
  254. # legible instead of dumping subtasks flat.
  255. my @scaffolded = self!augment-with-scaffolds(
  256. self.load-tasks-for($perspective),
  257. );
  258. my ($arranged, $depths) = arrange-split(@scaffolded);
  259. return {
  260. list-kind => 'task',
  261. container-kind => '',
  262. tasks => $arranged,
  263. task-row-depths => $depths,
  264. container-rows => [],
  265. time-rows => [],
  266. };
  267. }