diff --git a/docs/changes/herdr-theme-system/tasks.md b/docs/changes/herdr-theme-system/tasks.md index 58f5f42..12a5769 100644 --- a/docs/changes/herdr-theme-system/tasks.md +++ b/docs/changes/herdr-theme-system/tasks.md @@ -4,12 +4,12 @@ - [x] 1.2 Add the theme module with named palettes, attribution, and resolution - [x] 1.3 Add the `theme` config keys and extend the config reference - [x] 1.4 Route `Sheet` tones through the resolved palette -- [ ] 1.5 Derive the TUI palette from the resolved palette +- [x] 1.5 Derive the TUI palette from the resolved palette - [x] 1.6 Add best-effort appearance detection with the configured fallback ## 2. Verification - [x] 2.1 Test resolution precedence: `--no-color`, `NO_COLOR`, non-terminal, theme - [x] 2.2 Test named selection, unknown-name warning, and token overrides -- [ ] 2.3 Pin golden TUI snapshots to one named theme +- [x] 2.3 Pin golden TUI snapshots to one named theme - [x] 2.4 Run formatting, `cargo clippy --all-targets --all-features -- -D warnings`, and the tests diff --git a/docs/changes/tui-first-run/design.md b/docs/changes/tui-first-run/design.md new file mode 100644 index 0000000..c4b3fa8 --- /dev/null +++ b/docs/changes/tui-first-run/design.md @@ -0,0 +1,29 @@ +# TUI First Run Design + +## Approach + +One palette struct in `src/tui/styles.rs` derives every TUI color from `theme::current()`. The +five theme tones carry the meaning (accent for focus and keys, good and bad for diffs and states, +alert for the cursor, violet for headings). Surfaces and text tones follow a `light` flag on the +theme, so a light palette flips panel and text without a second setting. This beat adding surface +tokens to every theme because the tones already exist and the flag is one bit per palette. + +## Structure + +- `ThemeTones` gains `light`. The two light palettes set it. +- `styles::Palette::from_theme` maps tones to the TUI roles; accessor functions replace the old + constants, so call sites change one token each. +- `App` detects provider states once at load through the shared detection registry and reads the + bound target once. The status bar renders both beside the deck name. +- `App::is_first_run` is true after a scan that found no deck and no modules. `render` then + draws the first-run panel in place of the three columns and a matching footer hint. +- The help overlay title carries the version and the close keys; group labels and keys use the + theme. + +## Risks + +- Detection at load adds filesystem reads before the first frame. They are bounded evidence + checks and run once. +- A light palette on a terminal that ignores truecolor falls back to the ANSI tones; surfaces + stay readable because text tones are basic colors. +- Golden snapshots pin the default dark theme; a theme change in tests must install one first. diff --git a/docs/changes/tui-first-run/proposal.md b/docs/changes/tui-first-run/proposal.md new file mode 100644 index 0000000..8d25e18 --- /dev/null +++ b/docs/changes/tui-first-run/proposal.md @@ -0,0 +1,30 @@ +--- +adr: "docs/decisions/CLI-0036 TUI Status and First Run.md" +status: proposed +--- + +# TUI First Run + +## Why + +`rune tui` opens on a blank list when no deck is configured, paints one hard-coded dark palette +whatever `theme.name` says, and its status bar names nothing the user chose: no deck, no target, +no provider state. Herdr's TUI starts every session with the same three facts in view and leads a +new user to setup instead of an empty screen. Governing decision: CLI-0036. + +## What Changes + +- The TUI palette derives from the resolved theme. Light themes get light surfaces and dark text. +- The status bar shows the deck name, the bound target, and one glyph per enabled provider. +- A first-run panel replaces the empty list when the scan finds no deck and no modules, and names + the commands that lead out of it. +- The help overlay names its close keys and the rune version, in theme colors. + +## Capabilities + +- tui (new) + +## Impact + +- `src/tui/styles.rs`, `src/tui/app.rs`, `src/cli/theme.rs`, and the TUI tests +- Closes tasks 1.5 and 2.3 of the herdr-theme-system change diff --git a/docs/changes/tui-first-run/specs/tui/spec.md b/docs/changes/tui-first-run/specs/tui/spec.md new file mode 100644 index 0000000..c718482 --- /dev/null +++ b/docs/changes/tui-first-run/specs/tui/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Theme-derived TUI palette + +The TUI SHALL derive every color from the resolved theme, and a light theme SHALL use light +surfaces with dark text. + +#### Scenario: Light theme selected + +- **WHEN** the user config selects a light theme +- **THEN** the TUI panels, status bar, and text use the light palette + +### Requirement: Status bar context + +The TUI status bar SHALL show the deck name, the bound target when one exists, and one glyph per +enabled provider that reflects its deployment state. + +#### Scenario: Provider needs repair + +- **WHEN** a provider's deployment state is `needs repair` +- **THEN** the status bar shows that provider with the repair glyph in the bad tone + +### Requirement: First-run panel + +When the scan finds no deck and no modules, the TUI SHALL replace the list with a panel that names +the root and the commands that configure a deck, and the footer SHALL point at `rune setup`. + +#### Scenario: TUI opens on an unconfigured root + +- **WHEN** `rune tui` starts in a directory with no deck and no modules +- **THEN** the panel names `rune setup`, `rune config set deck`, and `rune tui --source` + +### Requirement: Help overlay close keys + +The help overlay SHALL name its close keys and the rune version in its title. + +#### Scenario: User opens help + +- **WHEN** the user presses `?` +- **THEN** the overlay title names `?` and `Esc` as the close keys diff --git a/docs/changes/tui-first-run/tasks.md b/docs/changes/tui-first-run/tasks.md new file mode 100644 index 0000000..5c527d1 --- /dev/null +++ b/docs/changes/tui-first-run/tasks.md @@ -0,0 +1,14 @@ +## 1. Implementation + +- [x] 1.1 Record the decision in CLI-0036 +- [x] 1.2 Add the `light` flag to the theme tones and derive the TUI palette from the theme +- [x] 1.3 Route every TUI color through the palette accessors +- [x] 1.4 Show deck, target, and provider states in the status bar +- [x] 1.5 Render the first-run panel and footer hint when no deck and no modules exist +- [x] 1.6 Name the close keys and version in the help overlay + +## 2. Verification + +- [x] 2.1 Test the light and dark palette derivation and the tone mapping +- [x] 2.2 Test the first-run panel, the status bar context, and the help title +- [x] 2.3 Run formatting, `cargo clippy --all-targets --all-features -- -D warnings`, and the tests diff --git a/docs/decisions/CLI-0036 TUI Status and First Run.md b/docs/decisions/CLI-0036 TUI Status and First Run.md new file mode 100644 index 0000000..69d1213 --- /dev/null +++ b/docs/decisions/CLI-0036 TUI Status and First Run.md @@ -0,0 +1,71 @@ +--- +title: "TUI Status and First Run" +description: "The TUI paints from the resolved theme, shows deck, target, and provider states, and routes an unconfigured root into setup" +type: adr +category: cli +tags: + - cli + - ux + - tui +status: proposed +created: 2026-09-02 +updated: 2026-09-02 +author: "@N4M3Z" +project: rune-cli +related: + - "CLI-0007 Interactive Mode and TUI" + - "CLI-0028 Setup Plan and Apply" + - "CLI-0031 Terminal Theme System" +responsible: ["@N4M3Z"] +accountable: ["@N4M3Z"] +consulted: ["claude-fable-5"] +informed: [] +upstream: [] +--- + +# TUI Status and First Run + +## Context and Problem Statement + +CLI-0031 promised one palette for `Sheet` output and the TUI, but `src/tui/styles.rs` kept its +hard-coded dark constants and the app painted named terminal colors beside them. A user who +selects a light theme gets themed CLI output and an unchanged dark TUI. The status bar reports +scan counts and nothing the user configured. On a root without a deck the list reads `no rows`. +[Herdr][HERDR] opens every session with the workspace facts in view and leads a new user to +setup. Rune needs the same three things without a second configuration surface. + +## Decision Drivers + +- One palette source, as CLI-0031 requires +- The status bar shows what the user chose: deck, target, providers +- An unconfigured root leads into `rune setup`, never a blank list +- No new configuration keys + +## Considered Options + +1. **Surface tokens per theme** — add background and text tones to every palette. Six palettes + to maintain, and custom overrides grow. +2. **A light flag per theme with derived surfaces** — the five tones keep the meaning, one bit + picks the surface set, and the TUI derives the rest. +3. **Terminal default backgrounds** — paint no backgrounds and inherit the terminal. Selection + and diff highlights lose contrast on unknown backgrounds. + +## Decision Outcome + +Option 2. `ThemeTones` gains `light`; `styles::Palette::from_theme` derives every TUI color from +the tones and that flag, and accessor functions replace the constants. The app detects provider +states once at load through the shared registry and reads the bound target once; the status bar +shows the deck name, the target, and one glyph per enabled provider. After a scan that finds no +deck and no modules, the TUI draws a first-run panel that names the root and the commands that +configure a deck, and the footer points at `rune setup`. The help overlay names its close keys +and the version. + +## Consequences + +- [+] A theme change restyles the TUI without a second setting +- [+] The first frame answers which deck, target, and providers are active +- [+] A new user reaches setup from the TUI instead of an empty list +- [-] Provider detection adds bounded filesystem reads before the first frame +- [-] Light surfaces are fixed values; a custom light palette cannot tune them yet + +[HERDR]: https://github.com/herdrdev/herdr diff --git a/src/cli/theme.rs b/src/cli/theme.rs index 24908bd..46faa0a 100644 --- a/src/cli/theme.rs +++ b/src/cli/theme.rs @@ -26,6 +26,9 @@ pub struct ThemeTones { pub alert: Tone, pub bad: Tone, pub violet: Tone, + /// The palette expects a light terminal background. The TUI derives + /// its surfaces and text tones from this flag. + pub light: bool, } pub const DEFAULT_DARK: &str = "rune-dark"; @@ -55,6 +58,7 @@ const THEMES: &[(&str, ThemeTones)] = &[ rgb: (187, 154, 247), ansi: 35, }, + light: false, }, ), ( @@ -80,6 +84,7 @@ const THEMES: &[(&str, ThemeTones)] = &[ rgb: (110, 66, 180), ansi: 35, }, + light: true, }, ), ( @@ -105,6 +110,7 @@ const THEMES: &[(&str, ThemeTones)] = &[ rgb: (203, 166, 247), ansi: 35, }, + light: false, }, ), ( @@ -130,6 +136,7 @@ const THEMES: &[(&str, ThemeTones)] = &[ rgb: (136, 57, 239), ansi: 35, }, + light: true, }, ), ( @@ -155,6 +162,7 @@ const THEMES: &[(&str, ThemeTones)] = &[ rgb: (187, 154, 247), ansi: 35, }, + light: false, }, ), ( @@ -180,6 +188,7 @@ const THEMES: &[(&str, ThemeTones)] = &[ rgb: (180, 142, 173), ansi: 35, }, + light: false, }, ), ]; diff --git a/src/tui/app.rs b/src/tui/app.rs index 88fe777..89051a9 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -15,6 +15,7 @@ use ratatui::{ }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +use rune::provider::detection::DeploymentState; use rune::{ manifest::FileStatus, review::{self, ExportFormat, ReviewComment}, @@ -633,6 +634,10 @@ pub struct App { toast: Option, preview: Option, help_state: HelpState, + /// Provider deployment states for the status bar, detected once at load. + provider_states: Vec<(String, DeploymentState)>, + /// The bound working repository, if any, for the status bar. + target_label: Option, palette: Palette, mouse_regions: MouseRegions, /// External command queued to run with the real terminal (gitui/jjui, @@ -691,11 +696,36 @@ pub struct App { impl App { pub fn load(root: PathBuf) -> Self { let mut app = Self::from_view(root, Vec::new(), Vec::new(), empty_dashboard_view()); + app.provider_states = detect_provider_states(&app.root); + app.target_label = crate::cli::target::bound_target_silent().and_then(|path| { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + }); app.start_validation(); app.start_scan(); app } + /// Set the provider states shown in the status bar (tests and snapshots). + #[cfg(test)] + pub fn set_provider_states(&mut self, states: Vec<(String, DeploymentState)>) { + self.provider_states = states; + } + + /// Set the bound target label shown in the status bar (tests and snapshots). + #[cfg(test)] + pub fn set_target_label(&mut self, label: Option) { + self.target_label = label; + } + + /// The scan finished and found neither a deck nor modules: the first-run + /// state, which routes into setup instead of an empty list. + fn is_first_run(&self) -> bool { + self.scan_state != ScanState::Loading + && self.view.deck.is_none() + && self.view.modules.is_empty() + } + #[must_use] pub fn from_view( root: PathBuf, @@ -767,6 +797,8 @@ impl App { toast: comment_warning, preview: None, help_state: HelpState::Closed, + provider_states: Vec::new(), + target_label: None, palette: Palette::new(), mouse_regions: MouseRegions::default(), pending_external: None, @@ -1069,6 +1101,14 @@ impl App { ]) .split(frame.area()); self.render_status(frame, layout[0]); + if self.is_first_run() { + self.render_first_run(frame, layout[1]); + self.render_footer(frame, layout[2]); + if self.help_state == HelpState::Open { + render_help(frame, frame.area()); + } + return; + } let mut desired_widths = self.column_widths; if self.view.deck.is_some() && matches!( @@ -1190,22 +1230,105 @@ impl App { let brand = Span::styled( " rune ", Style::default() - .fg(styles::FG_PRIMARY) + .fg(styles::fg_primary()) .add_modifier(Modifier::BOLD), ); - let padding = usize::from(area.width) - .saturating_sub(brand.width().saturating_add(source_text.width())); + let mut left = vec![brand]; + left.extend(self.context_spans()); + let left_width: usize = left.iter().map(Span::width).sum(); + let padding = + usize::from(area.width).saturating_sub(left_width.saturating_add(source_text.width())); + left.push(Span::raw(" ".repeat(padding))); + left.push(Span::styled( + source_text, + Style::default().fg(styles::fg_secondary()), + )); frame.render_widget( - Paragraph::new(Line::from(vec![ - brand, - Span::raw(" ".repeat(padding)), - Span::styled(source_text, Style::default().fg(styles::FG_SECONDARY)), - ])) - .style(styles::status_bar_style()), + Paragraph::new(Line::from(left)).style(styles::status_bar_style()), area, ); } + /// Deck, bound target, and provider states for the status bar. + fn context_spans(&self) -> Vec> { + let dim = Style::default().fg(styles::fg_dim()); + let value = Style::default().fg(styles::fg_secondary()); + let mut spans = Vec::new(); + match &self.view.deck { + Some(deck) => { + spans.push(Span::styled(" deck ", dim)); + spans.push(Span::styled(deck.name.clone(), value)); + } + None if self.scan_state == ScanState::Loading => {} + None => spans.push(Span::styled(" no deck ", dim)), + } + if let Some(target) = &self.target_label { + spans.push(Span::styled(" · target ", dim)); + spans.push(Span::styled(target.clone(), value)); + } + let mut first = true; + for (provider, state) in &self.provider_states { + let Some((glyph, color)) = provider_state_glyph(*state) else { + continue; + }; + spans.push(Span::styled(if first { " · " } else { " " }, dim)); + first = false; + spans.push(Span::styled(provider.clone(), value)); + spans.push(Span::styled( + format!(" {glyph}"), + Style::default().fg(color), + )); + } + if !spans.is_empty() { + spans.push(Span::raw(" ")); + } + spans + } + + /// The first-run panel: no deck and no modules under the root. It names + /// the root and the three commands that lead out of the empty state. + fn render_first_run(&self, frame: &mut Frame<'_>, area: Rect) { + let block = Block::default() + .title(" No deck ") + .borders(Borders::ALL) + .border_style(styles::border_style(true)); + let inner = block.inner(area); + frame.render_widget(block, area); + let key = Style::default() + .fg(styles::accent()) + .add_modifier(Modifier::BOLD); + let dim = Style::default().fg(styles::fg_dim()); + let lines = vec![ + Line::from(""), + Line::from(format!( + " rune found no deck and no modules under {}.", + self.root.display() + )), + Line::from(""), + Line::from(vec![ + Span::styled(" rune setup", key), + Span::styled( + " configure a deck and the providers", + dim, + ), + ]), + Line::from(vec![ + Span::styled(" rune config set deck ", key), + Span::styled(" point rune at an existing deck", dim), + ]), + Line::from(vec![ + Span::styled(" rune tui --source ", key), + Span::styled(" inspect another root", dim), + ]), + Line::from(""), + Line::from(Span::styled(" q quit · ? help", dim)), + ]; + frame.render_widget( + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), + inner, + ); + } + fn footer_text(&self) -> String { if let Some(editor) = &self.file_editor { if let Some(toast) = &self.toast { @@ -1311,7 +1434,11 @@ impl App { } fn render_footer(&self, frame: &mut Frame<'_>, area: Rect) { - let text = self.footer_text(); + let text = if self.is_first_run() { + "no deck · run rune setup · q quit · ? help".to_string() + } else { + self.footer_text() + }; frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled(self.footer_mode(), styles::mode_style()), @@ -1340,7 +1467,7 @@ impl App { Style::default() }; ListItem::new(Line::from(vec![ - Span::styled(prefix, Style::default().fg(Color::DarkGray)), + Span::styled(prefix, Style::default().fg(styles::fg_dim())), Span::styled(section.label(), style), ])) }) @@ -1377,7 +1504,7 @@ impl App { Span::styled(deck_entry.name.clone(), style), Span::styled( format!(" {}", deck_entry.rune_count()), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ), ])) }) @@ -1406,7 +1533,7 @@ impl App { }; ListItem::new(Line::from(vec![ Span::styled(kind, style), - Span::styled(format!(" {count}"), Style::default().fg(Color::DarkGray)), + Span::styled(format!(" {count}"), Style::default().fg(styles::fg_dim())), ])) }) .collect::>(); @@ -1435,7 +1562,7 @@ impl App { let mut lines = vec![Line::from(Span::styled( artifact_table_header(&target_names), Style::default() - .fg(Color::Magenta) + .fg(styles::violet()) .add_modifier(Modifier::BOLD), ))]; lines.extend( @@ -1504,7 +1631,8 @@ impl App { if self.scan_state == ScanState::Loading && self.cached_rows.is_empty() { frame.render_widget( - Paragraph::new("Scanning modules...").style(Style::default().fg(Color::Gray)), + Paragraph::new("Scanning modules...") + .style(Style::default().fg(styles::fg_secondary())), inner, ); return; @@ -1540,7 +1668,7 @@ impl App { return ListItem::new(Line::from(Span::styled( row.label.clone(), Style::default() - .fg(Color::Magenta) + .fg(styles::violet()) .add_modifier(Modifier::BOLD), ))); } @@ -1569,7 +1697,7 @@ impl App { }; let pad = room.saturating_sub(shown_width); spans.push(Span::raw(" ".repeat(pad))); - spans.push(Span::styled(text, base.fg(Color::DarkGray))); + spans.push(Span::styled(text, base.fg(styles::fg_dim()))); } } ListItem::new(Line::from(spans)) @@ -1694,7 +1822,8 @@ impl App { self.render_validation_problem(frame, area, index); } else { frame.render_widget( - Paragraph::new("✓ no validation problems").style(Style::default().fg(Color::Green)), + Paragraph::new("✓ no validation problems") + .style(Style::default().fg(styles::good())), area, ); } @@ -1706,8 +1835,8 @@ impl App { return; }; let (marker, color) = match violation.severity { - ViolationSeverity::Error => ("✗", Color::Red), - ViolationSeverity::Warning => ("⚡", Color::Yellow), + ViolationSeverity::Error => ("✗", styles::bad()), + ViolationSeverity::Warning => ("⚡", styles::alert()), }; let mut lines = vec![ Line::from(vec![ @@ -1794,7 +1923,7 @@ impl App { Line::from(cast.description.clone()), Line::from(Span::styled( "Space toggles · Enter confirms pending edit", - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), )), Line::from(""), ]; @@ -1920,7 +2049,7 @@ impl App { .is_some_and(|cache| cache.key != expected_key) { frame.render_widget( - Paragraph::new("rendering…").style(Style::default().fg(Color::DarkGray)), + Paragraph::new("rendering…").style(Style::default().fg(styles::fg_dim())), chunks[1], ); } else { @@ -1953,7 +2082,7 @@ impl App { if needs_build { if self.preview_cache.is_some() && input_pending() { frame.render_widget( - Paragraph::new("rendering…").style(Style::default().fg(Color::DarkGray)), + Paragraph::new("rendering…").style(Style::default().fg(styles::fg_dim())), area, ); return; @@ -1975,7 +2104,7 @@ impl App { { lines.push(Line::from(Span::styled( "─".repeat(usize::from(cache_width)), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ))); match rich::render_markdown_with_glow(&readme, cache_width) { Some(rendered) => lines.extend(rendered), @@ -2025,7 +2154,7 @@ impl App { if needs_build { if self.preview_cache.is_some() && input_pending() { frame.render_widget( - Paragraph::new("rendering…").style(Style::default().fg(Color::DarkGray)), + Paragraph::new("rendering…").style(Style::default().fg(styles::fg_dim())), chunks[1], ); return; @@ -2457,9 +2586,9 @@ impl App { *marker = Span::styled( if has_comment { "◆ " } else { " " }, if has_comment { - Style::default().fg(Color::Yellow) + Style::default().fg(styles::alert()) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(styles::fg_dim()) }, ); } @@ -2480,7 +2609,7 @@ impl App { .visual_selection .is_some_and(|selection| selection.contains(index)) { - line.style = Style::default().fg(Color::White).bg(Color::Blue); + line.style = styles::visual_selection_style(); } rows.extend(expand_gutter_wrapped(vec![line], CODE_GUTTER, width)); if let Some(prompt) = prompt { @@ -2554,7 +2683,7 @@ impl App { let matrix = builders::build_matrix(&self.view); lines.push(Line::from(Span::styled( "Matrix", - Style::default().fg(Color::Magenta), + Style::default().fg(styles::violet()), ))); lines.push(Line::from(format!("columns: {}", matrix.cols.join(", ")))); for row in matrix.rows { @@ -2569,7 +2698,7 @@ impl App { } else { lines.push(Line::from(Span::styled( "Nested", - Style::default().fg(Color::Magenta), + Style::default().fg(styles::violet()), ))); for group in builders::build_nested(&self.view, "kind") { lines.push(Line::from(format!("{} ({})", group.label, group.count))); @@ -5817,7 +5946,7 @@ impl App { fn provenance_lines(&self, module: &ModuleView, artifact: &ArtifactView) -> Vec> { fn field(key: &str, value: String) -> Line<'static> { Line::from(vec![ - Span::styled(format!("{key:<14}"), Style::default().fg(Color::Magenta)), + Span::styled(format!("{key:<14}"), Style::default().fg(styles::violet())), Span::raw(value), ]) } @@ -5891,7 +6020,7 @@ impl App { if artifact.provenance_raw.trim().is_empty() { lines.push(Line::from(Span::styled( "No provenance sidecar is available for this artifact.", - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ))); } else { lines.extend(rich::highlight_code( @@ -5908,7 +6037,7 @@ impl App { /// to scan without hiding any extension fields. fn provenance_field(key: &str, value: impl Into) -> Line<'static> { Line::from(vec![ - Span::styled(format!("{key:<14}"), Style::default().fg(Color::Magenta)), + Span::styled(format!("{key:<14}"), Style::default().fg(styles::violet())), Span::raw(value.into()), ]) } @@ -6080,14 +6209,14 @@ fn module_header_lines(module: &ModuleView) -> Vec> { let mut spans = vec![ Span::styled( format!("{sha_short} {date} "), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ), Span::raw(commit.message.clone()), ]; if !commit.jj_change.is_empty() { spans.push(Span::styled( format!(" · jj {}", commit.jj_change), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), )); } lines.push(Line::from(spans)); @@ -6095,7 +6224,7 @@ fn module_header_lines(module: &ModuleView) -> Vec> { lines.push(Line::from("")); lines.push(Line::from(Span::styled( "o open gitui · O open jjui", - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ))); } lines @@ -6114,12 +6243,15 @@ fn module_vcs_line(vcs: &VcsState) -> Line<'static> { branch.push_str(" · jj"); } let (state_label, state_style) = match vcs.worktree { - WorktreeState::Clean => ("✓ clean", Style::default().fg(Color::Green)), - WorktreeState::Modified => ("⚠ uncommitted changes", Style::default().fg(Color::Yellow)), - WorktreeState::Untracked => ("● untracked", Style::default().fg(Color::Magenta)), + WorktreeState::Clean => ("✓ clean", Style::default().fg(styles::good())), + WorktreeState::Modified => ( + "⚠ uncommitted changes", + Style::default().fg(styles::alert()), + ), + WorktreeState::Untracked => ("● untracked", Style::default().fg(styles::violet())), }; Line::from(vec![ - Span::styled(branch, Style::default().fg(Color::Cyan)), + Span::styled(branch, Style::default().fg(styles::accent())), Span::raw(" · "), Span::styled(state_label, state_style), ]) @@ -6146,15 +6278,15 @@ fn render_hook_detail(frame: &mut Frame<'_>, area: Rect, hook: &files::HookEntry let (_, command) = files::unwrap_shell(&hook.command); let lines = vec![ Line::from(vec![ - Span::styled("event: ", Style::default().fg(Color::Magenta)), + Span::styled("event: ", Style::default().fg(styles::violet())), Span::raw(hook.event.clone()), ]), Line::from(vec![ - Span::styled("matcher: ", Style::default().fg(Color::Magenta)), + Span::styled("matcher: ", Style::default().fg(styles::violet())), Span::raw(value_or_any(&hook.matcher).to_string()), ]), Line::from(vec![ - Span::styled("source: ", Style::default().fg(Color::Magenta)), + Span::styled("source: ", Style::default().fg(styles::violet())), Span::raw(hook.source.clone()), ]), Line::from(""), @@ -6171,9 +6303,13 @@ fn render_hook_detail(frame: &mut Frame<'_>, area: Rect, hook: &files::HookEntry fn render_help(frame: &mut Frame<'_>, area: Rect) { frame.render_widget(Clear, area); let block = Block::default() - .title(" Help ") + .title(format!( + " Help · rune {} · ? or Esc closes ", + env!("CARGO_PKG_VERSION") + )) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Magenta)); + .border_style(Style::default().fg(styles::violet())) + .style(styles::panel_style()); let inner = block.inner(area); frame.render_widget(block, area); @@ -6189,19 +6325,16 @@ fn render_help(frame: &mut Frame<'_>, area: Rect) { .map(|(_, group)| *group); let mut lines = Vec::new(); for (group, bindings) in groups { - lines.push(Line::from(Span::styled( - group, - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - ))); + lines.push(Line::from(Span::styled(group, styles::heading_style()))); for (key, description) in bindings { lines.push(Line::from(vec![ Span::styled( format!("{key:<12}"), - Style::default().add_modifier(Modifier::BOLD), + Style::default() + .fg(styles::accent()) + .add_modifier(Modifier::BOLD), ), - Span::styled(*description, Style::default().fg(Color::DarkGray)), + Span::styled(*description, Style::default().fg(styles::fg_dim())), ])); } lines.push(Line::from("")); @@ -6224,6 +6357,34 @@ pub fn load_provider_targets(root: &Path) -> Vec<(String, String)> { targets } +/// Provider deployment states for the status bar, in provider order. +/// Detection is bounded filesystem evidence, so it runs once at load. +fn detect_provider_states(root: &Path) -> Vec<(String, DeploymentState)> { + let Some(home) = dirs::home_dir() else { + return Vec::new(); + }; + config::detect_registered_providers(root, &home) + .map(|detections| { + detections + .into_iter() + .map(|detection| (detection.provider, detection.deployment_state)) + .collect() + }) + .unwrap_or_default() +} + +/// Glyph and tone for one provider state; disabled providers stay hidden. +fn provider_state_glyph(state: DeploymentState) -> Option<(&'static str, Color)> { + Some(match state { + DeploymentState::Disabled => return None, + DeploymentState::Current => ("✓", styles::good()), + DeploymentState::Outdated => ("↑", styles::alert()), + DeploymentState::NeedsRepair => ("✗", styles::bad()), + DeploymentState::Modified => ("~", styles::violet()), + DeploymentState::NotInstalled => ("·", styles::fg_dim()), + }) +} + fn empty_dashboard_view() -> DashboardView { DashboardView { deck: None, @@ -6299,7 +6460,7 @@ fn selected_style(focused: bool) -> Style { styles::selected_style().add_modifier(Modifier::BOLD) } else { Style::default() - .fg(Color::White) + .fg(styles::fg_primary()) .add_modifier(Modifier::BOLD) } } @@ -6383,10 +6544,10 @@ fn status_dot(status: &str) -> &'static str { fn status_style(status: &str) -> Style { match status { - "modified" => Style::default().fg(Color::Yellow), - "stale" => Style::default().fg(Color::Red), - "new" => Style::default().fg(Color::Magenta), - _ => Style::default().fg(Color::DarkGray), + "modified" => Style::default().fg(styles::alert()), + "stale" => Style::default().fg(styles::bad()), + "new" => Style::default().fg(styles::violet()), + _ => Style::default().fg(styles::fg_dim()), } } @@ -6444,10 +6605,10 @@ fn render_choice_popup( .title(title.to_string()) .title_bottom(Line::from(Span::styled( footer.to_string(), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ))) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); + .border_style(Style::default().fg(styles::accent())); let inner = block.inner(popup); frame.render_widget(block, popup); let viewport = usize::from(inner.height.max(1)).saturating_sub(usize::from(input.is_some())); @@ -6468,9 +6629,9 @@ fn render_choice_popup( .collect(); if let Some(path) = input { items.push(ListItem::new(Line::from(vec![ - Span::styled("path: ", Style::default().fg(Color::Magenta)), + Span::styled("path: ", Style::default().fg(styles::violet())), Span::raw(path.to_string()), - Span::styled("▌", Style::default().fg(Color::Cyan)), + Span::styled("▌", Style::default().fg(styles::accent())), ]))); } frame.render_widget(List::new(items), inner); @@ -6604,13 +6765,16 @@ fn vcs_line(artifact: &ArtifactView) -> Option> { let _ = write!(branch, " ↓{}", vcs.behind); } let mut spans = vec![ - Span::styled(branch, Style::default().fg(Color::Cyan)), + Span::styled(branch, Style::default().fg(styles::accent())), Span::raw(" · "), ]; let (worktree_label, worktree_style) = match vcs.worktree { - WorktreeState::Clean => ("✓ committed", Style::default().fg(Color::Green)), - WorktreeState::Modified => ("⚠ uncommitted changes", Style::default().fg(Color::Yellow)), - WorktreeState::Untracked => ("● untracked", Style::default().fg(Color::Magenta)), + WorktreeState::Clean => ("✓ committed", Style::default().fg(styles::good())), + WorktreeState::Modified => ( + "⚠ uncommitted changes", + Style::default().fg(styles::alert()), + ), + WorktreeState::Untracked => ("● untracked", Style::default().fg(styles::violet())), }; spans.push(Span::styled(worktree_label, worktree_style)); if let Some(commit) = artifact.git_log.first() { @@ -6618,16 +6782,16 @@ fn vcs_line(artifact: &ArtifactView) -> Option> { let date: String = commit.date.chars().take(10).collect(); spans.push(Span::styled( format!(" · {sha_short} {date}"), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), )); if !commit.jj_change.is_empty() { spans.push(Span::styled( format!(" · jj {}", commit.jj_change), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), )); } } else if vcs.jj_colocated { - spans.push(Span::styled(" · jj", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled(" · jj", Style::default().fg(styles::fg_dim()))); } Some(Line::from(spans)) } @@ -6668,7 +6832,7 @@ fn preview_lines_for_width(artifact: &ArtifactView, width: u16) -> (Vec4} {:>4} ", "", index + 1), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ), Span::styled(format!("+{line}"), styles::diff_add_style()), ]) @@ -7013,7 +7172,7 @@ fn diff_lines( return vec![ header, Line::from(vec![ - Span::styled("✓ ", Style::default().fg(Color::Green)), + Span::styled("✓ ", Style::default().fg(styles::good())), Span::raw("source file matches HEAD — no uncommitted changes"), ]), ]; @@ -7030,7 +7189,7 @@ fn diff_lines( } lines.push(Line::from(Span::styled( separator.clone(), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ))); lines.push(diff_line_colored(raw)); continue; @@ -7233,7 +7392,7 @@ fn jj_log_lines(module: &ModuleView) -> Vec> { for row in String::from_utf8_lossy(&output.stdout).lines() { let (change, rest) = row.split_at(row.len().min(8)); lines.push(Line::from(vec![ - Span::styled(change.to_string(), Style::default().fg(Color::Magenta)), + Span::styled(change.to_string(), Style::default().fg(styles::violet())), Span::raw(rest.to_string()), ])); } @@ -7265,7 +7424,7 @@ fn deployment_lines(groups: &[rune::view::DeployGroup]) -> Vec> { if groups.is_empty() { lines.push(Line::from(Span::styled( "not deployed anywhere — D deploys it to a target", - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ))); } for group in groups { @@ -7274,9 +7433,9 @@ fn deployment_lines(groups: &[rune::view::DeployGroup]) -> Vec> { Span::styled( if all_verified { "✓ " } else { "✗ " }, Style::default().fg(if all_verified { - Color::Green + styles::good() } else { - Color::Red + styles::bad() }), ), Span::styled( @@ -7285,25 +7444,25 @@ fn deployment_lines(groups: &[rune::view::DeployGroup]) -> Vec> { ), Span::styled( format!(" {}/{} verified", group.verified, group.total), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ), ])); for harness in &group.harnesses { let (badge, style) = if harness.verified { - ("✓", Style::default().fg(Color::Green)) + ("✓", Style::default().fg(styles::good())) } else { - ("✗ DRIFT", Style::default().fg(Color::Red)) + ("✗ DRIFT", Style::default().fg(styles::bad())) }; lines.push(Line::from(vec![ Span::raw(" "), Span::styled( format!("{:<12}", harness.harness), - Style::default().fg(Color::Cyan), + Style::default().fg(styles::accent()), ), Span::styled(format!("{badge:<8}"), style), Span::styled( harness.deployed_path.clone(), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ), ])); } @@ -7351,7 +7510,7 @@ fn frontmatter_lines(artifact: &ArtifactView, width: u16) -> Vec> .iter() .map(|(key, value)| { Line::from(vec![ - Span::styled(format!("{key:<18}"), Style::default().fg(Color::Magenta)), + Span::styled(format!("{key:<18}"), Style::default().fg(styles::violet())), Span::raw(value.clone()), ]) }) @@ -7366,7 +7525,7 @@ fn history_lines(artifact: &ArtifactView) -> Vec> { Line::from("no git history for this file"), Line::from(Span::styled( "o opens gitui on the repository", - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), )), ]; } diff --git a/src/tui/cast_editor.rs b/src/tui/cast_editor.rs index 11408d4..037e44d 100644 --- a/src/tui/cast_editor.rs +++ b/src/tui/cast_editor.rs @@ -5,13 +5,15 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, Paragraph}, }; use crate::cli::dotrune::{self, DotRune}; +use super::styles; + #[derive(Debug, Clone)] struct EditorRune { source_label: String, @@ -399,7 +401,7 @@ impl CastEditor { "read-only" } )) - .style(Style::default().fg(Color::Gray)), + .style(Style::default().fg(styles::fg_secondary())), layout[0], ); @@ -461,7 +463,7 @@ impl CastEditor { format!(" {hints} │ {}", self.status) }; frame.render_widget( - Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)), + Paragraph::new(footer).style(Style::default().fg(styles::fg_dim())), layout[2], ); } @@ -476,7 +478,7 @@ impl CastEditor { rows.push(Line::from(Span::styled( format!("▾ {} · {}", item.deck_name, item.group), Style::default() - .fg(Color::Cyan) + .fg(styles::accent()) .add_modifier(Modifier::BOLD), ))); previous_group = Some(group); @@ -486,8 +488,8 @@ impl CastEditor { } let style = if index == self.cursor { Style::default() - .fg(Color::Black) - .bg(Color::Cyan) + .fg(styles::palette().mode_fg) + .bg(styles::accent()) .add_modifier(Modifier::BOLD) } else { Style::default() diff --git a/src/tui/comment_navigator.rs b/src/tui/comment_navigator.rs index 5b8df60..992405e 100644 --- a/src/tui/comment_navigator.rs +++ b/src/tui/comment_navigator.rs @@ -1,7 +1,7 @@ use ratatui::{ Frame, layout::Rect, - style::{Color, Style}, + style::Style, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState}, }; @@ -76,7 +76,7 @@ fn render_comment_row(item: &CommentNavigatorItem, width: usize) -> Line<'static Line::from(vec![ Span::styled(kind, styles::comment_type_style(item.kind)), Span::raw(" "), - Span::styled(location, Style::default().fg(Color::DarkGray)), + Span::styled(location, Style::default().fg(styles::fg_dim())), Span::raw(" "), Span::raw(first_segment), ]) diff --git a/src/tui/comment_panel.rs b/src/tui/comment_panel.rs index 57d0d42..b24bf6e 100644 --- a/src/tui/comment_panel.rs +++ b/src/tui/comment_panel.rs @@ -95,7 +95,7 @@ pub(super) fn format_comment_input_lines( let type_style = styles::comment_type_style(comment_kind); let border_style = styles::comment_border_style(); let cursor_style = Style::default() - .fg(styles::CURSOR_COLOR) + .fg(styles::palette().cursor) .add_modifier(Modifier::UNDERLINED); let action = if is_editing { "Edit" } else { "Add" }; diff --git a/src/tui/file_editor.rs b/src/tui/file_editor.rs index 75efad5..82f8ea2 100644 --- a/src/tui/file_editor.rs +++ b/src/tui/file_editor.rs @@ -8,12 +8,14 @@ use edtui::{ use ratatui::{ Frame, layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, widgets::{Block, Borders}, }; use super::modal_editor::{ModalAction, ModalState}; +use super::styles; + pub(super) enum EditorAction { Continue, Save, @@ -127,16 +129,20 @@ impl FileEditor { pub(super) fn render(&mut self, frame: &mut Frame<'_>, area: Rect) { let dirty = if self.is_dirty() { "*" } else { "" }; let title = format!(" Edit{dirty} · {} ", self.display_path()); - let line = Style::default().fg(Color::Gray); + let line = Style::default().fg(styles::fg_secondary()); let mode = Style::default() - .fg(Color::Black) - .bg(Color::Cyan) + .fg(styles::palette().mode_fg) + .bg(styles::accent()) .add_modifier(Modifier::BOLD); let theme = EditorTheme::default() .base(Style::default()) - .cursor_style(Style::default().fg(Color::Black).bg(Color::White)) - .selection_style(Style::default().fg(Color::Black).bg(Color::Yellow)) - .line_numbers_style(Style::default().fg(Color::DarkGray)) + .cursor_style( + Style::default() + .fg(styles::palette().panel_bg) + .bg(styles::fg_primary()), + ) + .selection_style(styles::highlight_style(false)) + .line_numbers_style(Style::default().fg(styles::fg_dim())) .status_line( EditorStatusLine::default() .style_line(line) diff --git a/src/tui/rich.rs b/src/tui/rich.rs index 4ff54dd..ca9ac28 100644 --- a/src/tui/rich.rs +++ b/src/tui/rich.rs @@ -16,6 +16,8 @@ use syntect::{ parsing::SyntaxSet, }; +use super::styles; + /// Markdown preview: prose renders through glow, fenced code blocks through /// syntect. Glamour leaves fences without a language tag uncolored, so code /// gets the same highlighter as the Code tab instead. @@ -222,8 +224,8 @@ fn glow_style_path() -> Option { pub fn highlight_code(path: &str, source: &str) -> Vec> { if source.is_empty() { return vec![Line::from(vec![ - Span::styled(" ", Style::default().fg(Color::DarkGray)), - Span::styled(" 1 ", Style::default().fg(Color::DarkGray)), + Span::styled(" ", Style::default().fg(styles::fg_dim())), + Span::styled(" 1 ", Style::default().fg(styles::fg_dim())), Span::raw("no raw source"), ])]; } @@ -248,10 +250,10 @@ pub fn highlight_code(path: &str, source: &str) -> Vec> { .enumerate() .map(|(index, line)| { let mut spans = vec![ - Span::styled(" ", Style::default().fg(Color::DarkGray)), + Span::styled(" ", Style::default().fg(styles::fg_dim())), Span::styled( format!("{:>4} ", index + 1), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ), ]; match highlighter.highlight_line(line, syntax_set) { @@ -276,10 +278,10 @@ fn numbered_plain_lines(source: &str) -> Vec> { .enumerate() .map(|(index, line)| { Line::from(vec![ - Span::styled(" ", Style::default().fg(Color::DarkGray)), + Span::styled(" ", Style::default().fg(styles::fg_dim())), Span::styled( format!("{:>4} ", index + 1), - Style::default().fg(Color::DarkGray), + Style::default().fg(styles::fg_dim()), ), Span::raw(line.to_string()), ]) diff --git a/src/tui/styles.rs b/src/tui/styles.rs index 381458d..43ce952 100644 --- a/src/tui/styles.rs +++ b/src/tui/styles.rs @@ -1,88 +1,258 @@ -//! tuicr's TUI style palette, adapted to rune's fixed-color interface. +//! The TUI palette, derived from the resolved theme. +//! +//! Every color the TUI paints comes through here. The five theme tones +//! (accent, good, alert, bad, violet) carry the meaning; the surfaces and +//! text tones follow the theme's light or dark flag, so a light palette gets +//! light panels and dark text without a second setting. use ratatui::style::{Color, Modifier, Style}; use rune::review::CommentKind; -pub(super) const PANEL_BG: Color = Color::Rgb(24, 24, 28); -pub(super) const BG_HIGHLIGHT: Color = Color::Rgb(70, 70, 70); -pub(super) const FG_PRIMARY: Color = Color::White; -pub(super) const FG_SECONDARY: Color = Color::Rgb(210, 210, 210); -pub(super) const FG_DIM: Color = Color::Rgb(160, 160, 160); -pub(super) const DIFF_ADD: Color = Color::Rgb(80, 220, 120); -pub(super) const DIFF_ADD_BG: Color = Color::Rgb(0, 60, 20); -pub(super) const DIFF_DEL: Color = Color::Rgb(240, 90, 90); -pub(super) const DIFF_DEL_BG: Color = Color::Rgb(70, 0, 0); -pub(super) const DIFF_CONTEXT: Color = Color::Rgb(200, 200, 200); -pub(super) const BORDER_FOCUSED: Color = Color::Rgb(90, 200, 255); -pub(super) const BORDER_UNFOCUSED: Color = Color::Rgb(110, 110, 110); -pub(super) const STATUS_BAR_BG: Color = Color::Rgb(30, 30, 30); -pub(super) const CURSOR_COLOR: Color = Color::Rgb(255, 210, 90); -pub(super) const MODE_FG: Color = Color::Black; -pub(super) const MODE_BG: Color = Color::Rgb(90, 200, 255); +use crate::cli::theme::{self, ThemeTones, Tone}; + +/// One resolved TUI palette. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct Palette { + pub(super) panel_bg: Color, + pub(super) bg_highlight: Color, + pub(super) fg_primary: Color, + pub(super) fg_secondary: Color, + pub(super) fg_dim: Color, + pub(super) diff_add: Color, + pub(super) diff_add_bg: Color, + pub(super) diff_del: Color, + pub(super) diff_del_bg: Color, + pub(super) diff_context: Color, + pub(super) hunk_header_bg: Color, + pub(super) border_focused: Color, + pub(super) border_unfocused: Color, + pub(super) status_bar_bg: Color, + pub(super) cursor: Color, + pub(super) mode_fg: Color, + pub(super) mode_bg: Color, + pub(super) accent: Color, + pub(super) good: Color, + pub(super) alert: Color, + pub(super) bad: Color, + pub(super) violet: Color, +} + +fn rgb(tone: Tone) -> Color { + let (red, green, blue) = tone.rgb; + Color::Rgb(red, green, blue) +} + +impl Palette { + /// Derive the TUI palette from one theme. + #[must_use] + pub(super) fn from_theme(tones: &ThemeTones) -> Self { + let accent = rgb(tones.accent); + let good = rgb(tones.good); + let alert = rgb(tones.alert); + let bad = rgb(tones.bad); + let violet = rgb(tones.violet); + if tones.light { + Self { + panel_bg: Color::Rgb(250, 250, 252), + bg_highlight: Color::Rgb(214, 218, 226), + fg_primary: Color::Black, + fg_secondary: Color::Rgb(60, 60, 70), + fg_dim: Color::Rgb(110, 110, 120), + diff_add: good, + diff_add_bg: Color::Rgb(222, 244, 226), + diff_del: bad, + diff_del_bg: Color::Rgb(250, 222, 222), + diff_context: Color::Rgb(70, 70, 70), + hunk_header_bg: Color::Rgb(236, 236, 240), + border_focused: accent, + border_unfocused: Color::Rgb(170, 170, 180), + status_bar_bg: Color::Rgb(235, 236, 240), + cursor: alert, + mode_fg: Color::White, + mode_bg: accent, + accent, + good, + alert, + bad, + violet, + } + } else { + Self { + panel_bg: Color::Rgb(24, 24, 28), + bg_highlight: Color::Rgb(70, 70, 70), + fg_primary: Color::White, + fg_secondary: Color::Rgb(210, 210, 210), + fg_dim: Color::Rgb(160, 160, 160), + diff_add: good, + diff_add_bg: Color::Rgb(0, 60, 20), + diff_del: bad, + diff_del_bg: Color::Rgb(70, 0, 0), + diff_context: Color::Rgb(200, 200, 200), + hunk_header_bg: Color::Rgb(42, 42, 46), + border_focused: accent, + border_unfocused: Color::Rgb(110, 110, 110), + status_bar_bg: Color::Rgb(30, 30, 30), + cursor: alert, + mode_fg: Color::Black, + mode_bg: accent, + accent, + good, + alert, + bad, + violet, + } + } + } +} + +/// The active palette. The theme installs once at dispatch, so this is a +/// cheap derivation on every call. +pub(super) fn palette() -> Palette { + Palette::from_theme(&theme::current()) +} + +pub(super) fn fg_primary() -> Color { + palette().fg_primary +} + +pub(super) fn fg_secondary() -> Color { + palette().fg_secondary +} + +pub(super) fn fg_dim() -> Color { + palette().fg_dim +} + +pub(super) fn accent() -> Color { + palette().accent +} + +pub(super) fn good() -> Color { + palette().good +} + +pub(super) fn alert() -> Color { + palette().alert +} + +pub(super) fn bad() -> Color { + palette().bad +} + +pub(super) fn violet() -> Color { + palette().violet +} + +/// Bold heading in the theme's violet, for group labels and keys. +pub(super) fn heading_style() -> Style { + Style::default().fg(violet()).add_modifier(Modifier::BOLD) +} + +/// Inverse highlight for search matches and the picker cursor. +pub(super) fn highlight_style(current: bool) -> Style { + let palette = palette(); + Style::default() + .fg(palette.mode_fg) + .bg(if current { + palette.violet + } else { + palette.alert + }) + .add_modifier(Modifier::BOLD) +} + +/// Visual (line-range) selection in the Code tab. +pub(super) fn visual_selection_style() -> Style { + let palette = palette(); + Style::default().fg(palette.mode_fg).bg(palette.accent) +} pub(super) fn selected_style() -> Style { - Style::default().bg(BG_HIGHLIGHT).fg(FG_PRIMARY) + let palette = palette(); + Style::default() + .bg(palette.bg_highlight) + .fg(palette.fg_primary) } pub(super) fn current_line_indicator_style() -> Style { Style::default() - .fg(CURSOR_COLOR) + .fg(palette().cursor) .add_modifier(Modifier::BOLD) } pub(super) fn dim_style() -> Style { - Style::default().fg(FG_DIM) + Style::default().fg(fg_dim()) } pub(super) fn diff_add_style() -> Style { - Style::default().fg(DIFF_ADD).bg(DIFF_ADD_BG) + let palette = palette(); + Style::default() + .fg(palette.diff_add) + .bg(palette.diff_add_bg) } pub(super) fn diff_del_style() -> Style { - Style::default().fg(DIFF_DEL).bg(DIFF_DEL_BG) + let palette = palette(); + Style::default() + .fg(palette.diff_del) + .bg(palette.diff_del_bg) } pub(super) fn diff_context_style() -> Style { - Style::default().fg(DIFF_CONTEXT) + Style::default().fg(palette().diff_context) } pub(super) fn diff_hunk_header_style() -> Style { - Style::default().fg(FG_DIM).bg(Color::Rgb(42, 42, 46)) + let palette = palette(); + Style::default() + .fg(palette.fg_dim) + .bg(palette.hunk_header_bg) } pub(super) fn file_header_style() -> Style { - Style::default().fg(FG_PRIMARY).add_modifier(Modifier::BOLD) + Style::default() + .fg(fg_primary()) + .add_modifier(Modifier::BOLD) } pub(super) fn border_style(focused: bool) -> Style { + let palette = palette(); Style::default().fg(if focused { - BORDER_FOCUSED + palette.border_focused } else { - BORDER_UNFOCUSED + palette.border_unfocused }) } pub(super) fn panel_style() -> Style { - Style::default().bg(PANEL_BG).fg(FG_PRIMARY) + let palette = palette(); + Style::default().bg(palette.panel_bg).fg(palette.fg_primary) } pub(super) fn status_bar_style() -> Style { - Style::default().bg(STATUS_BAR_BG).fg(FG_PRIMARY) + let palette = palette(); + Style::default() + .bg(palette.status_bar_bg) + .fg(palette.fg_primary) } pub(super) fn mode_style() -> Style { + let palette = palette(); Style::default() - .fg(MODE_FG) - .bg(MODE_BG) + .fg(palette.mode_fg) + .bg(palette.mode_bg) .add_modifier(Modifier::BOLD) } +/// Comment kinds map onto the theme tones: notes are informational (accent), +/// suggestions are stylistic (violet), issues block (bad), praise is good. pub(super) fn comment_type_style(kind: CommentKind) -> Style { + let palette = palette(); let color = match kind { - CommentKind::Note => Color::Rgb(90, 170, 255), - CommentKind::Suggestion => Color::Rgb(90, 220, 240), - CommentKind::Issue => Color::Rgb(240, 90, 90), - CommentKind::Praise => Color::Rgb(80, 220, 120), + CommentKind::Note => palette.accent, + CommentKind::Suggestion => palette.violet, + CommentKind::Issue => palette.bad, + CommentKind::Praise => palette.good, }; Style::default().fg(color).add_modifier(Modifier::BOLD) } diff --git a/src/tui/styles/tests.rs b/src/tui/styles/tests.rs index 951b82f..eae65e6 100644 --- a/src/tui/styles/tests.rs +++ b/src/tui/styles/tests.rs @@ -1,29 +1,65 @@ use super::*; +use crate::cli::theme::{DEFAULT_DARK, DEFAULT_LIGHT, named}; + +fn tone(color: Color) -> (u8, u8, u8) { + match color { + Color::Rgb(red, green, blue) => (red, green, blue), + other => panic!("expected an RGB color, got {other:?}"), + } +} + +#[test] +fn dark_palette_keeps_the_dark_surfaces() { + let palette = Palette::from_theme(&named(DEFAULT_DARK).unwrap()); + + assert_eq!(palette.status_bar_bg, Color::Rgb(30, 30, 30)); + assert_eq!(palette.bg_highlight, Color::Rgb(70, 70, 70)); + assert_eq!(palette.fg_primary, Color::White); + assert_eq!(palette.mode_fg, Color::Black); +} #[test] -fn status_bar_uses_tuicr_default_dark_palette() { - let style = status_bar_style(); +fn light_palette_flips_surfaces_and_text() { + let palette = Palette::from_theme(&named(DEFAULT_LIGHT).unwrap()); - assert_eq!(style.bg, Some(Color::Rgb(30, 30, 30))); - assert_eq!(style.fg, Some(Color::White)); + assert_eq!(palette.fg_primary, Color::Black); + assert_eq!(palette.mode_fg, Color::White); + let (red, green, blue) = tone(palette.panel_bg); + assert!(red > 200 && green > 200 && blue > 200, "light panel"); + let (red, green, blue) = tone(palette.status_bar_bg); + assert!(red > 200 && green > 200 && blue > 200, "light status bar"); } #[test] -fn comment_badges_use_tuicr_kind_colors() { +fn semantic_colors_follow_the_theme_tones() { + let tones = named("nord").unwrap(); + let palette = Palette::from_theme(&tones); + + assert_eq!(tone(palette.border_focused), tones.accent.rgb); + assert_eq!(tone(palette.mode_bg), tones.accent.rgb); + assert_eq!(tone(palette.cursor), tones.alert.rgb); + assert_eq!(tone(palette.diff_add), tones.good.rgb); + assert_eq!(tone(palette.diff_del), tones.bad.rgb); +} + +#[test] +fn comment_badges_use_theme_tones() { + let tones = crate::cli::theme::current(); + assert_eq!( - comment_type_style(CommentKind::Issue).fg, - Some(Color::Rgb(240, 90, 90)) + tone(comment_type_style(CommentKind::Issue).fg.unwrap()), + tones.bad.rgb ); assert_eq!( - comment_type_style(CommentKind::Note).fg, - Some(Color::Rgb(90, 170, 255)) + tone(comment_type_style(CommentKind::Note).fg.unwrap()), + tones.accent.rgb ); assert_eq!( - comment_type_style(CommentKind::Suggestion).fg, - Some(Color::Rgb(90, 220, 240)) + tone(comment_type_style(CommentKind::Suggestion).fg.unwrap()), + tones.violet.rgb ); assert_eq!( - comment_type_style(CommentKind::Praise).fg, - Some(Color::Rgb(80, 220, 120)) + tone(comment_type_style(CommentKind::Praise).fg.unwrap()), + tones.good.rgb ); } diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 9aa8518..0a5d00b 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{Terminal, backend::TestBackend, style::Color}; +use rune::provider::detection::DeploymentState; use rune::{ manifest::FileStatus, services::files::{ @@ -1674,14 +1675,12 @@ fn code_search_incrementally_highlights_and_navigates_matches() { let backend = TestBackend::new(120, 32); let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|frame| app.render(frame)).unwrap(); - assert!( - terminal - .backend() - .buffer() - .content() - .iter() - .any(|cell| { matches!(cell.bg, Color::Yellow | Color::Magenta) }) - ); + assert!(terminal.backend().buffer().content().iter().any(|cell| { + let tones = crate::cli::theme::current(); + let alert = Color::Rgb(tones.alert.rgb.0, tones.alert.rgb.1, tones.alert.rgb.2); + let violet = Color::Rgb(tones.violet.rgb.0, tones.violet.rgb.1, tones.violet.rgb.2); + cell.bg == alert || cell.bg == violet + })); let snapshot = terminal .backend() .buffer() @@ -1859,3 +1858,59 @@ fn hooks_section_lists_fixture_hook_and_detail() { assert!(detail.contains("~/.claude/settings.json")); assert!(detail.contains("echo fixture-hook")); } + +#[test] +fn first_run_panel_routes_into_setup() { + let view = DashboardView { + modules: Vec::new(), + summary: StatusSummary::default(), + provenance: Vec::new(), + adrs: Vec::new(), + deck: None, + }; + let mut app = App::from_view( + PathBuf::from("/tmp/empty-root"), + Vec::new(), + Vec::new(), + view, + ); + let output = rendered(&mut app); + + assert!(output.contains("No deck"), "{output}"); + assert!(output.contains("rune setup"), "{output}"); + assert!(output.contains("/tmp/empty-root"), "{output}"); + assert!(output.contains("run rune setup"), "footer hint: {output}"); + assert!(!output.contains("no rows"), "{output}"); +} + +#[test] +fn status_bar_shows_target_and_provider_states() { + let mut app = App::from_view(PathBuf::from("."), Vec::new(), Vec::new(), fixture_view()); + app.set_target_label(Some("demo".to_string())); + app.set_provider_states(vec![ + ("claude".to_string(), DeploymentState::Current), + ("codex".to_string(), DeploymentState::NotInstalled), + ("gemini".to_string(), DeploymentState::Disabled), + ]); + let output = rendered(&mut app); + let status = output.lines().next().unwrap_or_default(); + + assert!(status.contains("no deck"), "{status}"); + assert!(status.contains("target demo"), "{status}"); + assert!(status.contains("claude ✓"), "{status}"); + assert!(status.contains("codex ·"), "{status}"); + assert!( + !status.contains("gemini"), + "disabled providers stay hidden: {status}" + ); +} + +#[test] +fn help_overlay_names_the_close_keys() { + let mut app = App::from_view(PathBuf::from("."), Vec::new(), Vec::new(), fixture_view()); + event::handle_key(&mut app, key(KeyCode::Char('?'))); + let output = rendered(&mut app); + + assert!(output.contains("? or Esc closes"), "{output}"); + assert!(output.contains("Global"), "{output}"); +}