tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

#[derive(Clone)]
pub struct DetailState {
    pub issue: Issue,
    /// The issue's column usage against its WIP limit, from the shared snapshot
    /// (WP05 T025): `3/2 max_items 2, block` or similar, or `None` for a column with
    /// no limit. Filled in by the caller, so the detail pane cannot count a column
    /// differently from the board's header.
    pub wip: Option<String>,
    /// The issue's resolved category label and color hint, from the service
    /// (WP05 T023, FR-004).
    ///
    /// `None` for a column the board does not configure, which the chip renders as
    /// `(unmapped)` — a statement about the file rather than a silent default. Filled
    /// in by the caller from the service, so the detail view cannot spell a category
    /// differently from the board card (T024).
    pub category: Option<(String, Option<crate::workflow::ColorHint>)>,
    /// The issue's relationships as the **service** resolves them, not
    /// `issue.relationships`.
    ///
    /// An edge is stored once in its canonical source's file, so the issue's own
    /// field holds only the edges it happens to own — for `2 related 1` it is
    /// empty, so rendering it showed a related issue with no relationships. The
    /// retired symmetric model mirrored the link on both sides by construction,
    /// so reading the raw field here was a regression, not a simplification.
    /// Populated by the caller from [`crate::storage::Store::relationships_for`].
    pub relationships: Vec<crate::relationships::Relationship>,
    /// The issue's dependency chain, exactly as the service ordered it, and
    /// whether a traversal bound truncated it.
    ///
    /// Taken from the service rather than derived here: re-deriving the walk in
    /// the renderer is how a detail pane ends up disagreeing with `tix chain`
    /// about the same snapshot (`contracts/surface-parity.md` §3).
    pub chain: crate::relationships::DependencyChain,
    /// The service's blocked reason, or `None` when the issue is not blocked.
    pub blocked: Option<String>,
    /// The issue's hierarchy facts, from the shared
    /// [`crate::commands::parent::HierarchyFacts`].
    ///
    /// Taken whole rather than as pre-rendered strings, so the detail pane and
    /// `tix parent show` are guaranteed to describe the same snapshot and the
    /// pane can lay them out its own way — which the parity contract allows:
    /// same facts, same order, presentation may differ.
    pub hierarchy: crate::commands::parent::HierarchyFacts,
    /// The scope's health, exactly as the shared document describes it (WP06 T031):
    /// the state, its layer, the policy version, the History entries, and — when the
    /// scope has contributors — the derived rollup with them.
    ///
    /// Taken whole from [`crate::health::PortableHealth`] rather than as a rendered
    /// string, so the detail view and the CLI describe the same snapshot and the pane
    /// can lay it out its own way. `None` when the issue is not in the document.
    pub health: Option<crate::health::PortableScopeHealth>,
    /// The health diagnostics about this scope (WP06 T033): a legacy `unknown`, a
    /// missing reference, a value this build cannot read. Rendered with their remedy,
    /// so a reader is told what to do rather than only that something is wrong.
    pub health_diagnostics: Vec<crate::health::HealthDiagnostic>,
    pub scroll_offset: u16,
    /// The pane's last-rendered viewport height, in rows (tix #1442).
    ///
    /// Recorded during the draw because the *key* handler is what needs it: "scroll
    /// one page" has to mean the page the reader is looking at, and only the renderer
    /// knows how tall that is. A `Cell` for the same reason `App::frame_hit_regions`
    /// is a `RefCell` — the renderer holds this state immutably.
    pub viewport: std::cell::Cell<u16>,
    /// The largest `scroll_offset` that still shows content (tix #1442).
    ///
    /// Scroll steps used to be unclamped, so paging past the end of a long issue left
    /// a blank pane with no way back except scrolling up the same distance. A page
    /// step makes that much easier to hit, so the bound travels with the step.
    pub max_scroll: std::cell::Cell<u16>,
    /// Which relationship `E` (re-kind) and `d` (remove) act on, as an index into
    /// `relationships` (#1359).
    ///
    /// An index and not an id, because the pane does not display ids — it shows
    /// `r.display`, which carries the kind and the target but not the stable
    /// `rel_…` token. A user picks an edge by looking at it.
    ///
    /// Always within `0..relationships.len()`; [`DetailState::clamp_rel_cursor`] is
    /// the only thing that writes it, so a removal that shrinks the list cannot leave
    /// the cursor pointing past the end.
    pub rel_cursor: usize,
    /// The issue's classified `related_issues` entries (tix #1513).
    ///
    /// Classified by [`crate::related::classify_all`] at open/refresh time, not at
    /// render time: the classification is stable for a given string and doing it once
    /// keeps the renderer a pure function of the pane's state. The list is empty for
    /// an issue with no `related_issues:` field, and the block is then not drawn.
    pub related: Vec<crate::related::RelatedRef>,
    /// Which related entry `o` follows, as an index into `related` (tix #1513).
    ///
    /// Moved only by the related picker (`r`), never by `j`/`k` — those keep moving
    /// the **relationship** cursor, which is a different list with a different
    /// meaning. Always within `0..related.len()`; [`DetailState::clamp_related_cursor`]
    /// is the only thing that writes it, so indexing cannot panic.
    pub related_cursor: usize,
    /// Whether the pane shows an issue it must not offer to mutate (tix #1513).
    ///
    /// `true` only for a `related_issues` *path* reference that parsed as an issue
    /// outside the store — the file is readable but its writes have no store to route
    /// through, so the pane refuses `e`/`c`/`a` and the relationship operations rather
    /// than failing with a lookup error.
    pub read_only: bool,
    /// The issue's referenced epics, exactly as the service resolved them (WP04 T019,
    /// FR-003, C-005).
    ///
    /// The same canonical rows `tix show` prints
    /// ([`crate::commands::view::EpicBlockEntry`]), so the pane and the CLI cannot
    /// word the same epic differently: the pane adds styling, never facts. Built by
    /// the caller from the one service read
    /// ([`crate::commands::view::epic_entries`]) because the renderer holds no store.
    pub epics: Vec<crate::commands::view::EpicBlockEntry>,
    /// The capacity scopes this issue contributes to, as the shared calculation
    /// describes them (WP06 T031).
    ///
    /// One entry per cycle the issue's own `cycles` field names, plus its assignee
    /// when it states one — the issue's **own** explicit scope references, folded by
    /// the library's [`crate::capacity::calculate::CapacityScope`] adapter rather than
    /// re-resolved here. Empty for an issue in no scope, which draws no block.
    pub capacity: Vec<IssueCapacityView>,
}

/// One capacity scope an issue contributes to, as the detail pane draws it (WP06
/// T031).
///
/// Every figure is a field of the shared DTOs
/// ([`crate::capacity::calculate::CapacityCalculation`] and
/// [`crate::capacity::snapshot::CapacitySnapshot`]); the pane adds styling, never a
/// fact, and an absent value is `-` rather than `0`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueCapacityView {
    /// The scope the figures belong to, `cycle `Cycle 1`` — the library's own
    /// resolution, so the name and its stable id are one scope.
    pub scope: String,
    /// The issue's own contribution: its raw estimate, the value it contributes in
    /// the scope's unit, or an explicit statement that it states no estimate.
    pub issue_line: String,
    /// The shared calculation's fields, in `tix capacity calculate`'s spelling.
    pub fields: Vec<(String, String)>,
    /// The findings that name this issue, or the scope's own, each with its remedy.
    pub diagnostics: Vec<String>,
}

impl DetailState {
    /// Keep the relationship cursor inside the list.
    ///
    /// Called after every mutation that can change the list's length — a removal, and
    /// a refresh after a create. A cursor that survived a removal as `3` into a
    /// two-element list would make `E` and `d` act on nothing while still reporting
    /// a selection.
    pub fn clamp_rel_cursor(&mut self) {
        if self.relationships.is_empty() {
            self.rel_cursor = 0;
        } else if self.rel_cursor >= self.relationships.len() {
            self.rel_cursor = self.relationships.len() - 1;
        }
    }

    /// Keep the related-entry cursor inside the list (tix #1513).
    ///
    /// The same rule [`DetailState::clamp_rel_cursor`] follows: `o` indexes
    /// `related` and the picker writes the index, so the list shrinking (a refresh
    /// after the `related_issues` field was edited) must not leave it past the end.
    pub fn clamp_related_cursor(&mut self) {
        if self.related.is_empty() {
            self.related_cursor = 0;
        } else if self.related_cursor >= self.related.len() {
            self.related_cursor = self.related.len() - 1;
        }
    }

    /// Rows a **page** step moves: the last-rendered viewport, less one row of
    /// overlap (tix #1442).
    ///
    /// The overlap is the point. A step that moves exactly the height of the pane
    /// leaves no line on screen that was there before, so a reader paging through a
    /// long body has nothing to anchor the new page to. One row of context costs
    /// nothing and removes that.
    pub fn page(&self) -> u16 {
        self.viewport.get().saturating_sub(1).max(1)
    }

    /// Scroll by `rows` (negative scrolls up), clamped to the content.
    ///
    /// The clamp is what keeps a page step usable: without it, one `PgDn` past the
    /// end of an issue left a blank pane, and the only way back was to scroll up the
    /// same distance — which, on a page step, is the whole screen.
    /// Scroll so `body_line` is the first visible row of the body.
    ///
    /// Used by the mention jump (OpenSpec change `issue-mentions`). The offset is a
    /// **body line**, and the pane wraps long lines, so the target can land a row or
    /// two off on a narrow terminal — close enough to see the mention, which is what
    /// the jump is for; exact row arithmetic would need the wrapped layout, which
    /// only the renderer knows.
    pub fn focus_body_line(&mut self, body_line: usize) {
        self.scroll_offset = u16::try_from(body_line)
            .unwrap_or(u16::MAX)
            .min(self.max_scroll.get());
    }

    pub fn scroll_by(&mut self, rows: i32) {
        let next = i32::from(self.scroll_offset) + rows;
        self.scroll_offset = next.clamp(0, i32::from(self.max_scroll.get())) as u16;
    }
}