- ## --- App::Cairn::Service::TaskArrange ------------------------------------------------
- #| Pre-order tree arrangement of a flat task list. Children sit
- #| directly under their parent when both are in the view, with depth
- #| incrementing per level. Tasks whose parent isn't in the set render
- #| at depth 0 as roots of their own.
- #|
- #| Returns an Array of `%( task => $task, depth => $Int )` hashes in
- #| display order. Position-based dedup (not id-based) means forecast's
- #| projected tasks — which share their parent's id — all emit as
- #| distinct rows instead of being collapsed into one.
- #|
- #| Projected tasks are treated as leaves: they never serve as parents
- #| in the lookup, so a later row won't be pulled under a projection
- #| of its ancestor.
- sub arrange-task-tree(@tasks --> Array) is export {
- # Only materialised tasks serve as parent-lookup targets —
- # projected tasks share their parent's id and would otherwise
- # overwrite the real row in %by-id, letting a projection act as
- # the "ancestor" of a later child row.
- my %by-id;
- for @tasks.kv -> $i, $t {
- next unless $t.id.defined;
- next if $t.is-projected;
- %by-id{$t.id} //= $i;
- }
- my @emitted-at = False xx @tasks.elems;
- my @out;
- # Inner routine (sub, not pointy-block) so `return` works — this
- # used to live as a pointy block inside Screen::Main and tripped
- # the outside-enclosing-Routine error when we tried to early-exit.
- sub emit-subtree(Int $i, Int $depth) {
- return if @emitted-at[$i];
- @emitted-at[$i] = True;
- my $t = @tasks[$i];
- @out.push: %( task => $t, depth => $depth );
- return unless $t.id.defined;
- return if $t.is-projected;
- # Find this task's children by positional scan. Walking @tasks
- # (vs a prebuilt children map) keeps sibling order aligned
- # with the caller's supplied ordering — important for
- # due-date / created-at sorts where the gateway already made
- # a choice we want to respect.
- for @tasks.kv -> $j, $c {
- next if @emitted-at[$j];
- next unless $c.parent-task-id.defined
- && $c.parent-task-id == $t.id;
- emit-subtree($j, $depth + 1);
- }
- }
- for @tasks.kv -> $i, $t {
- next if @emitted-at[$i];
- # Walk up to the highest ancestor still in the set —
- # that's where this task's subtree starts rendering. If no
- # ancestor is in view (typical for roots, or for descendants
- # whose parent was filtered out), $cur-idx stays at $i and
- # the task emits at depth 0.
- my $cur-idx = $i;
- loop {
- my $cur = @tasks[$cur-idx];
- last unless $cur.parent-task-id.defined
- && (%by-id{$cur.parent-task-id}:exists);
- my $parent-idx = %by-id{$cur.parent-task-id};
- last if $parent-idx == $cur-idx;
- $cur-idx = $parent-idx;
- }
- emit-subtree($cur-idx, 0);
- }
- @out.Array;
- }
- #| Split the arranged rows into parallel Arrays of Task models and
- #| depths — convenient for the store slice (app.tasks +
- #| app.task-row-depths) that keeps cursor indices aligned with the
- #| displayed order.
- sub arrange-split(@tasks --> List) is export {
- my @rows = arrange-task-tree(@tasks);
- my @arranged-tasks = @rows.map(*.<task>).Array;
- my @depths = @rows.map(*.<depth>).Array;
- ( @arranged-tasks, @depths );
- }
- ## --- App::Cairn::View::TaskRow -------------------------------------------------------
- #| Labels + style for every row in the main task list. Depths are
- #| supplied by the store (C<view-state-for> arranges tasks + depths in
- #| lock-step so cursor indices stay aligned with the displayed order).
- #| Index out of range falls back to 0 so a mis-sized caller doesn't
- #| throw — the worst case is a missed indent.
- #|
- #| NOTE: this used to memoise per row via a module-scope hash so the
- #| once-per-second overdue-tick subscription wouldn't reallocate every
- #| label. The cache was reverted because it shared module-level state
- #| that crossed render-frame boundaries — combined with Selkie's
- #| Pair-stored item model, this triggered a MoarVM spesh
- #| mis-specialisation in the render loop ("Cannot unbox 3274 bit wide
- #| bigint into native integer") that the Selkie::Widget.set-viewport
- #| free-sub workaround couldn't catch. The per-frame allocation cost
- #| is small in practice (overdue-tick fires once a second, lists are
- #| short); revisit only if profiling shows it as a real hot spot AND
- #| the spesh issue has been root-caused upstream.
- sub task-row-labels(@tasks, @depths, :$theme! --> List) is export {
- @tasks.kv.map(-> $i, $t {
- my $depth = @depths[$i] // 0;
- my $glyph = $t.is-completed ?? '☒' !! '☐';
- my $flag = $t.flagged ?? ' ⚑' !! '';
- my $due = $t.due-date.defined
- ?? ' · due ' ~ $t.due-date.substr(0, 10) !! '';
- my $tag = $t.is-projected ?? ' ↻' !! '';
- my $is-overdue = $t.is-active && overdue-date($t.due-date);
- my $overdue-prefix = $is-overdue ?? '‼ ' !! '';
- # Waiting indicator — makes waiting rows instantly distinguishable
- # when they show up in Forecast alongside active items.
- my $waiting-prefix = $t.is-waiting ?? '⏸ ' !! '';
- my $indent = ' ' x $depth;
- my $label = "$indent$overdue-prefix$waiting-prefix$glyph$flag {$t.title}$due$tag";
- # Scaffold rows (parent added purely for hierarchy context on
- # a filter-matching subtask) render dimmed so the user can
- # tell them apart from rows that actually matched the filter.
- my $style = $t.is-scaffold
- ?? Selkie::Style.new(fg => $theme.fg-dim, italic => True)
- !! task-style($t, :$theme, :$is-overdue);
- $label => $style;
- }).List;
- }
- ## --- App::Cairn::Widget::TaskList -------------------------------------------------------------
- method render() {
- return without self.plane;
- my UInt $max = self!max-offset;
- $!scroll-offset = $max if $!scroll-offset > $max;
- ncplane_erase(self.plane);
- my UInt $vh = self.rows;
- my UInt $vw = self.cols;
- my Bool $need-scrollbar = $!show-scrollbar && @!labels.elems > $vh;
- my UInt $content-w = $need-scrollbar ?? $vw - 1 !! $vw;
- my $base = self.theme.base;
- my $default-text = self.theme.text;
- my $highlight = self.theme.text-highlight;
- my $default-fg = $default-text.fg // 0xE6EDF3;
- my $default-bg = $base.bg // 0x1A1A2E;
- my UInt $visible = $vh min @!labels.elems;
- for ^$visible -> $row {
- my UInt $idx = $!scroll-offset + $row;
- last if $idx >= @!labels.elems;
- my $is-selected = $idx == $!cursor;
- my $row-style = @!styles[$idx] // $default-text;
- # Always write a fresh fg / bg / style-mask for every row.
- # notcurses's set-* calls are sticky on the plane, so a
- # defined value from the selected row (white fg, cleared
- # styles) would persist onto the next row if the row-style's
- # own fg was undefined — that bug made a second row look
- # focused when the top one was the actual cursor target.
- if $is-selected {
- # Selected row: theme-highlight fg (bright, usually white
- # or near-white) + highlight bg, styles cleared. The
- # bright fg beats red/yellow/dim for spotting the cursor
- # at a glance; clearing bold also keeps the checkbox
- # glyph from widening enough to clip the label.
- ncplane_set_fg_rgb(self.plane,
- $highlight.fg // $default-fg);
- ncplane_set_bg_rgb(self.plane,
- $highlight.bg // $default-bg);
- ncplane_set_styles(self.plane, 0);
- } else {
- ncplane_set_fg_rgb(self.plane, $row-style.fg // $default-fg);
- ncplane_set_bg_rgb(self.plane, $default-bg);
- ncplane_set_styles(self.plane, $row-style.styles);
- }
- my $text = @!labels[$idx] // '';
- $text = $text.substr(0, $content-w) if $text.chars > $content-w;
- $text = $text ~ (' ' x ($content-w - $text.chars))
- if $text.chars < $content-w;
- ncplane_putstr_yx(self.plane, $row, 0, $text);
- }
- self!render-scrollbar if $need-scrollbar;
- self.clear-dirty;
- }
- ## --- App::Cairn::StoreHandlers ---------------------------------------------------------
- #| Compute the complete view-state for a given perspective + drill
- #| pair in one place. `list-kind` is either 'task' (normal
- #| perspectives with just tasks) or 'hybrid' (projects / tags, with
- #| a filter strip of container rows on top of the task list). The
- #| filter strip always leads with an ALL row (id => Int type object)
- #| so the user can navigate back to "no filter" without an explicit
- #| exit keybind — just cursor up to the ALL row and the tasks refresh.
- method view-state-for(Str:D $perspective, Hash $drill,
- Bool :$include-archived = False,
- Str :$time-view-mode = 'task',
- :%time-drill = %(),
- --> Hash) {
- my $kind = ($drill // {})<kind> // '';
- my $id = ($drill // {})<id>;
- if $perspective eq 'time' {
- # Time perspective: aggregated totals across a rolling
- # window, grouped by task / project per the caller-supplied
- # C<$time-view-mode> (default: by task). Drill state on
- # C<%time-drill> overrides the top-level grouping with a
- # scoped aggregation — see Service::TimeReport::build's
- # C<%scope> param for the supported shapes.
- #
- # `tasks` is empty so the existing task-list subscription
- # doesn't fight us; the Time view branches on
- # C<list-kind eq 'time'> to render time-rows instead.
- my ($gb, %scope) = self!time-report-scope-for(
- $time-view-mode, %time-drill,
- );
- return {
- list-kind => 'time',
- container-kind => '',
- container-rows => [],
- tasks => [],
- task-row-depths => [],
- time-rows => App::Cairn::Service::TimeReport::build(
- $!db, group-by => $gb, scope => %scope,
- ),
- };
- }
- if $perspective eq 'projects' {
- my @rows = self!project-rows(:$include-archived);
- my @scaffolded = self!augment-with-scaffolds(
- self!tasks-for-project-drill($id),
- );
- my ($arranged, $depths) = arrange-split(@scaffolded);
- return {
- list-kind => 'hybrid',
- container-kind => 'project',
- container-rows => (self!all-row(perspective => 'project'), |@rows),
- tasks => $arranged,
- task-row-depths => $depths,
- time-rows => [],
- };
- }
- if $perspective eq 'tags' {
- my @rows = self!tag-rows(:$include-archived);
- my @scaffolded = self!augment-with-scaffolds(
- self!tasks-for-tag-drill($id),
- );
- my ($arranged, $depths) = arrange-split(@scaffolded);
- return {
- list-kind => 'hybrid',
- container-kind => 'tag',
- container-rows => (self!all-row(perspective => 'tag'), |@rows),
- tasks => $arranged,
- task-row-depths => $depths,
- time-rows => [],
- };
- }
- # Normal task perspectives — arrange so app.tasks reflects the
- # display order. Cursor-index selection (task/selected) indexes
- # into app.tasks, so the arrangement MUST happen here rather than
- # at render time, otherwise the Down-arrow in Forecast (which
- # interleaves projected rows that share the parent's id) would
- # pick a different task than the one the cursor visually lands on.
- #
- # The scaffold pass adds ancestor rows for filter-matched
- # subtasks (flagged subtask with unflagged parent, today-due
- # subtask with no-due-date parent, etc.) so the hierarchy stays
- # legible instead of dumping subtasks flat.
- my @scaffolded = self!augment-with-scaffolds(
- self.load-tasks-for($perspective),
- );
- my ($arranged, $depths) = arrange-split(@scaffolded);
- return {
- list-kind => 'task',
- container-kind => '',
- tasks => $arranged,
- task-row-depths => $depths,
- container-rows => [],
- time-rows => [],
- };
- }