1633 lines
54 KiB
Rust
1633 lines
54 KiB
Rust
use crate::config::{Keybinds, NewTerminalCwdConfig, SoundConfig, ToastConfig, ToastDelivery};
|
||
use crossterm::event::{KeyCode, KeyModifiers};
|
||
use ratatui::layout::{Direction, Rect};
|
||
use ratatui::style::Color;
|
||
|
||
use crate::detect::AgentState;
|
||
use crate::layout::{PaneId, PaneInfo, SplitBorder};
|
||
use crate::selection::Selection;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Selection autoscroll types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Direction of automatic scrolling during text selection drag.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub(crate) enum SelectionAutoscrollDirection {
|
||
Up,
|
||
Down,
|
||
}
|
||
|
||
/// State for automatic scrolling during text selection drag.
|
||
///
|
||
/// When the cursor hovers in the 1-row hot zone at the top or bottom edge
|
||
/// of a pane (or outside the pane), this struct captures the direction and
|
||
/// last known mouse position so a recurring 30ms tick can continue scrolling
|
||
/// and extending the selection even when the mouse is not moving.
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct SelectionAutoscroll {
|
||
pub direction: SelectionAutoscrollDirection,
|
||
pub last_mouse_screen_col: u16,
|
||
pub last_mouse_screen_row: u16,
|
||
pub inner_rect: Rect,
|
||
}
|
||
use crate::terminal_theme::TerminalTheme;
|
||
use crate::workspace::Workspace;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Theme palette — all UI colors in one place, ready for theming
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// All colors used by the UI. Derived from a base accent color for now,
|
||
/// but structured so a full theme system can replace it later.
|
||
#[derive(Clone)]
|
||
#[allow(dead_code)] // all fields defined for theming — some used later
|
||
pub struct Palette {
|
||
/// Primary accent (highlight, active borders).
|
||
pub accent: Color,
|
||
/// Background for floating panels, overlays, and modals.
|
||
pub panel_bg: Color,
|
||
/// Subtle surface background for selected/focused items.
|
||
pub surface0: Color,
|
||
/// Slightly lighter surface for hover/active states.
|
||
pub surface1: Color,
|
||
/// Very dim surface for separators.
|
||
pub surface_dim: Color,
|
||
/// Muted text (secondary info, numbers).
|
||
pub overlay0: Color,
|
||
/// Slightly brighter overlay text.
|
||
pub overlay1: Color,
|
||
/// Main text color — soft white.
|
||
pub text: Color,
|
||
/// Subdued text (workspace numbers, dim labels).
|
||
pub subtext0: Color,
|
||
/// Branch name / special label color.
|
||
pub mauve: Color,
|
||
/// Done / idle states.
|
||
pub green: Color,
|
||
/// Working / running states.
|
||
pub yellow: Color,
|
||
/// Needs attention / blocked states.
|
||
pub red: Color,
|
||
/// Unseen / done notification accent.
|
||
pub blue: Color,
|
||
/// Notification accent / unseen markers.
|
||
pub teal: Color,
|
||
/// Interrupted / warning states.
|
||
pub peach: Color,
|
||
}
|
||
|
||
impl Palette {
|
||
/// Catppuccin Mocha — the default.
|
||
pub fn catppuccin() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(137, 180, 250), // blue
|
||
panel_bg: Color::Rgb(24, 24, 37),
|
||
surface0: Color::Rgb(49, 50, 68),
|
||
surface1: Color::Rgb(69, 71, 90),
|
||
surface_dim: Color::Rgb(30, 30, 46),
|
||
overlay0: Color::Rgb(108, 112, 134),
|
||
overlay1: Color::Rgb(127, 132, 156),
|
||
text: Color::Rgb(205, 214, 244),
|
||
subtext0: Color::Rgb(166, 173, 200),
|
||
mauve: Color::Rgb(203, 166, 247),
|
||
green: Color::Rgb(166, 227, 161),
|
||
yellow: Color::Rgb(249, 226, 175),
|
||
red: Color::Rgb(243, 139, 168),
|
||
blue: Color::Rgb(137, 180, 250),
|
||
teal: Color::Rgb(148, 226, 213),
|
||
peach: Color::Rgb(250, 179, 135),
|
||
}
|
||
}
|
||
|
||
/// Catppuccin Latte — the light Catppuccin flavor.
|
||
pub fn catppuccin_latte() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(30, 102, 245),
|
||
panel_bg: Color::Rgb(239, 241, 245),
|
||
surface0: Color::Rgb(204, 208, 218),
|
||
surface1: Color::Rgb(188, 192, 204),
|
||
surface_dim: Color::Rgb(230, 233, 239),
|
||
overlay0: Color::Rgb(156, 160, 176),
|
||
overlay1: Color::Rgb(140, 143, 161),
|
||
text: Color::Rgb(76, 79, 105),
|
||
subtext0: Color::Rgb(108, 111, 133),
|
||
mauve: Color::Rgb(136, 57, 239),
|
||
green: Color::Rgb(64, 160, 43),
|
||
yellow: Color::Rgb(223, 142, 29),
|
||
red: Color::Rgb(210, 15, 57),
|
||
blue: Color::Rgb(30, 102, 245),
|
||
teal: Color::Rgb(23, 146, 153),
|
||
peach: Color::Rgb(254, 100, 11),
|
||
}
|
||
}
|
||
|
||
/// Terminal 16-color theme.
|
||
pub fn terminal() -> Self {
|
||
Self {
|
||
accent: Color::Blue,
|
||
panel_bg: Color::Reset,
|
||
surface0: Color::Reset,
|
||
surface1: Color::DarkGray,
|
||
surface_dim: Color::DarkGray,
|
||
overlay0: Color::Gray,
|
||
overlay1: Color::White,
|
||
text: Color::Reset,
|
||
subtext0: Color::Gray,
|
||
mauve: Color::Gray,
|
||
green: Color::Green,
|
||
yellow: Color::Yellow,
|
||
red: Color::LightRed,
|
||
blue: Color::Blue,
|
||
teal: Color::Cyan,
|
||
peach: Color::Yellow,
|
||
}
|
||
}
|
||
|
||
/// Tokyo Night — blue-purple aesthetic.
|
||
pub fn tokyo_night() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(122, 162, 247), // blue
|
||
panel_bg: Color::Rgb(26, 27, 38),
|
||
surface0: Color::Rgb(36, 40, 59),
|
||
surface1: Color::Rgb(65, 72, 104),
|
||
surface_dim: Color::Rgb(26, 27, 38),
|
||
overlay0: Color::Rgb(86, 95, 137),
|
||
overlay1: Color::Rgb(105, 113, 150),
|
||
text: Color::Rgb(192, 202, 245),
|
||
subtext0: Color::Rgb(169, 177, 214),
|
||
mauve: Color::Rgb(187, 154, 247),
|
||
green: Color::Rgb(158, 206, 106),
|
||
yellow: Color::Rgb(224, 175, 104),
|
||
red: Color::Rgb(247, 118, 142),
|
||
blue: Color::Rgb(122, 162, 247),
|
||
teal: Color::Rgb(125, 207, 255),
|
||
peach: Color::Rgb(255, 158, 100),
|
||
}
|
||
}
|
||
|
||
/// Tokyo Night Day — the light Tokyo Night style.
|
||
pub fn tokyo_night_day() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(46, 125, 233),
|
||
panel_bg: Color::Rgb(225, 226, 231),
|
||
surface0: Color::Rgb(196, 200, 218),
|
||
surface1: Color::Rgb(168, 174, 203),
|
||
surface_dim: Color::Rgb(210, 211, 218),
|
||
overlay0: Color::Rgb(137, 144, 179),
|
||
overlay1: Color::Rgb(104, 112, 154),
|
||
text: Color::Rgb(55, 96, 191),
|
||
subtext0: Color::Rgb(97, 114, 176),
|
||
mauve: Color::Rgb(120, 71, 189),
|
||
green: Color::Rgb(88, 117, 57),
|
||
yellow: Color::Rgb(140, 108, 62),
|
||
red: Color::Rgb(245, 42, 101),
|
||
blue: Color::Rgb(46, 125, 233),
|
||
teal: Color::Rgb(17, 140, 116),
|
||
peach: Color::Rgb(177, 92, 0),
|
||
}
|
||
}
|
||
|
||
/// Dracula — purple/pink/green.
|
||
pub fn dracula() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(189, 147, 249), // purple
|
||
panel_bg: Color::Rgb(40, 42, 54),
|
||
surface0: Color::Rgb(68, 71, 90),
|
||
surface1: Color::Rgb(98, 114, 164),
|
||
surface_dim: Color::Rgb(40, 42, 54),
|
||
overlay0: Color::Rgb(98, 114, 164),
|
||
overlay1: Color::Rgb(130, 140, 180),
|
||
text: Color::Rgb(248, 248, 242),
|
||
subtext0: Color::Rgb(210, 210, 220),
|
||
mauve: Color::Rgb(255, 121, 198), // pink
|
||
green: Color::Rgb(80, 250, 123),
|
||
yellow: Color::Rgb(241, 250, 140),
|
||
red: Color::Rgb(255, 85, 85),
|
||
blue: Color::Rgb(139, 233, 253), // cyan-ish
|
||
teal: Color::Rgb(139, 233, 253),
|
||
peach: Color::Rgb(255, 184, 108),
|
||
}
|
||
}
|
||
|
||
/// Nord — frosty blue palette.
|
||
pub fn nord() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(136, 192, 208), // frost
|
||
panel_bg: Color::Rgb(46, 52, 64),
|
||
surface0: Color::Rgb(59, 66, 82),
|
||
surface1: Color::Rgb(67, 76, 94),
|
||
surface_dim: Color::Rgb(46, 52, 64),
|
||
overlay0: Color::Rgb(76, 86, 106),
|
||
overlay1: Color::Rgb(100, 110, 130),
|
||
text: Color::Rgb(236, 239, 244),
|
||
subtext0: Color::Rgb(216, 222, 233),
|
||
mauve: Color::Rgb(180, 142, 173),
|
||
green: Color::Rgb(163, 190, 140),
|
||
yellow: Color::Rgb(235, 203, 139),
|
||
red: Color::Rgb(191, 97, 106),
|
||
blue: Color::Rgb(129, 161, 193),
|
||
teal: Color::Rgb(143, 188, 187),
|
||
peach: Color::Rgb(208, 135, 112),
|
||
}
|
||
}
|
||
|
||
/// Gruvbox Dark — warm retro palette.
|
||
pub fn gruvbox() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(215, 153, 33), // yellow
|
||
panel_bg: Color::Rgb(40, 40, 40),
|
||
surface0: Color::Rgb(60, 56, 54),
|
||
surface1: Color::Rgb(80, 73, 69),
|
||
surface_dim: Color::Rgb(40, 40, 40),
|
||
overlay0: Color::Rgb(146, 131, 116),
|
||
overlay1: Color::Rgb(168, 153, 132),
|
||
text: Color::Rgb(235, 219, 178),
|
||
subtext0: Color::Rgb(213, 196, 161),
|
||
mauve: Color::Rgb(211, 134, 155),
|
||
green: Color::Rgb(184, 187, 38),
|
||
yellow: Color::Rgb(250, 189, 47),
|
||
red: Color::Rgb(251, 73, 52),
|
||
blue: Color::Rgb(131, 165, 152),
|
||
teal: Color::Rgb(142, 192, 124),
|
||
peach: Color::Rgb(254, 128, 25),
|
||
}
|
||
}
|
||
|
||
/// Gruvbox Light — the light retro palette.
|
||
pub fn gruvbox_light() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(7, 102, 120),
|
||
panel_bg: Color::Rgb(251, 241, 199),
|
||
surface0: Color::Rgb(235, 219, 178),
|
||
surface1: Color::Rgb(213, 196, 161),
|
||
surface_dim: Color::Rgb(242, 229, 188),
|
||
overlay0: Color::Rgb(146, 131, 116),
|
||
overlay1: Color::Rgb(124, 111, 100),
|
||
text: Color::Rgb(60, 56, 54),
|
||
subtext0: Color::Rgb(80, 73, 69),
|
||
mauve: Color::Rgb(143, 63, 113),
|
||
green: Color::Rgb(121, 116, 14),
|
||
yellow: Color::Rgb(181, 118, 20),
|
||
red: Color::Rgb(157, 0, 6),
|
||
blue: Color::Rgb(7, 102, 120),
|
||
teal: Color::Rgb(66, 123, 88),
|
||
peach: Color::Rgb(175, 58, 3),
|
||
}
|
||
}
|
||
|
||
/// One Dark — Atom's classic dark theme.
|
||
pub fn one_dark() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(97, 175, 239), // blue
|
||
panel_bg: Color::Rgb(40, 44, 52),
|
||
surface0: Color::Rgb(44, 49, 58),
|
||
surface1: Color::Rgb(62, 68, 81),
|
||
surface_dim: Color::Rgb(40, 44, 52),
|
||
overlay0: Color::Rgb(92, 99, 112),
|
||
overlay1: Color::Rgb(115, 122, 135),
|
||
text: Color::Rgb(171, 178, 191),
|
||
subtext0: Color::Rgb(150, 156, 168),
|
||
mauve: Color::Rgb(198, 120, 221),
|
||
green: Color::Rgb(152, 195, 121),
|
||
yellow: Color::Rgb(229, 192, 123),
|
||
red: Color::Rgb(224, 108, 117),
|
||
blue: Color::Rgb(97, 175, 239),
|
||
teal: Color::Rgb(86, 182, 194),
|
||
peach: Color::Rgb(209, 154, 102),
|
||
}
|
||
}
|
||
|
||
/// One Light — Atom's classic light theme.
|
||
pub fn one_light() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(64, 120, 242),
|
||
panel_bg: Color::Rgb(250, 250, 250),
|
||
surface0: Color::Rgb(240, 240, 241),
|
||
surface1: Color::Rgb(229, 229, 230),
|
||
surface_dim: Color::Rgb(245, 245, 246),
|
||
overlay0: Color::Rgb(160, 161, 167),
|
||
overlay1: Color::Rgb(104, 107, 119),
|
||
text: Color::Rgb(56, 58, 66),
|
||
subtext0: Color::Rgb(104, 107, 119),
|
||
mauve: Color::Rgb(166, 38, 164),
|
||
green: Color::Rgb(80, 161, 79),
|
||
yellow: Color::Rgb(193, 132, 1),
|
||
red: Color::Rgb(228, 86, 73),
|
||
blue: Color::Rgb(64, 120, 242),
|
||
teal: Color::Rgb(1, 132, 188),
|
||
peach: Color::Rgb(152, 104, 1),
|
||
}
|
||
}
|
||
|
||
/// Solarized Dark — Ethan Schoonover's classic.
|
||
pub fn solarized() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(38, 139, 210), // blue
|
||
panel_bg: Color::Rgb(0, 43, 54),
|
||
surface0: Color::Rgb(7, 54, 66),
|
||
surface1: Color::Rgb(88, 110, 117),
|
||
surface_dim: Color::Rgb(0, 43, 54),
|
||
overlay0: Color::Rgb(88, 110, 117),
|
||
overlay1: Color::Rgb(101, 123, 131),
|
||
text: Color::Rgb(147, 161, 161),
|
||
subtext0: Color::Rgb(131, 148, 150),
|
||
mauve: Color::Rgb(211, 54, 130),
|
||
green: Color::Rgb(133, 153, 0),
|
||
yellow: Color::Rgb(181, 137, 0),
|
||
red: Color::Rgb(220, 50, 47),
|
||
blue: Color::Rgb(38, 139, 210),
|
||
teal: Color::Rgb(42, 161, 152),
|
||
peach: Color::Rgb(203, 75, 22),
|
||
}
|
||
}
|
||
|
||
/// Solarized Light — Ethan Schoonover's light variant.
|
||
pub fn solarized_light() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(38, 139, 210),
|
||
panel_bg: Color::Rgb(253, 246, 227),
|
||
surface0: Color::Rgb(238, 232, 213),
|
||
surface1: Color::Rgb(147, 161, 161),
|
||
surface_dim: Color::Rgb(238, 232, 213),
|
||
overlay0: Color::Rgb(147, 161, 161),
|
||
overlay1: Color::Rgb(88, 110, 117),
|
||
text: Color::Rgb(101, 123, 131),
|
||
subtext0: Color::Rgb(131, 148, 150),
|
||
mauve: Color::Rgb(211, 54, 130),
|
||
green: Color::Rgb(133, 153, 0),
|
||
yellow: Color::Rgb(181, 137, 0),
|
||
red: Color::Rgb(220, 50, 47),
|
||
blue: Color::Rgb(38, 139, 210),
|
||
teal: Color::Rgb(42, 161, 152),
|
||
peach: Color::Rgb(203, 75, 22),
|
||
}
|
||
}
|
||
|
||
/// Kanagawa — inspired by Katsushika Hokusai.
|
||
pub fn kanagawa() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(126, 156, 216), // blue
|
||
panel_bg: Color::Rgb(31, 31, 40),
|
||
surface0: Color::Rgb(42, 42, 55),
|
||
surface1: Color::Rgb(54, 54, 70),
|
||
surface_dim: Color::Rgb(31, 31, 40),
|
||
overlay0: Color::Rgb(114, 113, 105),
|
||
overlay1: Color::Rgb(135, 134, 125),
|
||
text: Color::Rgb(220, 215, 186),
|
||
subtext0: Color::Rgb(200, 195, 170),
|
||
mauve: Color::Rgb(149, 127, 184),
|
||
green: Color::Rgb(118, 148, 106),
|
||
yellow: Color::Rgb(192, 163, 110),
|
||
red: Color::Rgb(195, 64, 67),
|
||
blue: Color::Rgb(126, 156, 216),
|
||
teal: Color::Rgb(127, 180, 202),
|
||
peach: Color::Rgb(255, 160, 102),
|
||
}
|
||
}
|
||
|
||
/// Kanagawa Lotus — the light Kanagawa variant.
|
||
pub fn kanagawa_lotus() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(77, 105, 155),
|
||
panel_bg: Color::Rgb(242, 236, 188),
|
||
surface0: Color::Rgb(220, 213, 172),
|
||
surface1: Color::Rgb(201, 203, 209),
|
||
surface_dim: Color::Rgb(213, 206, 163),
|
||
overlay0: Color::Rgb(160, 156, 172),
|
||
overlay1: Color::Rgb(138, 137, 128),
|
||
text: Color::Rgb(84, 84, 100),
|
||
subtext0: Color::Rgb(67, 67, 108),
|
||
mauve: Color::Rgb(98, 76, 131),
|
||
green: Color::Rgb(111, 137, 78),
|
||
yellow: Color::Rgb(119, 113, 63),
|
||
red: Color::Rgb(200, 64, 83),
|
||
blue: Color::Rgb(77, 105, 155),
|
||
teal: Color::Rgb(78, 140, 162),
|
||
peach: Color::Rgb(204, 109, 0),
|
||
}
|
||
}
|
||
|
||
/// Rosé Pine — muted, elegant.
|
||
pub fn rose_pine() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(196, 167, 231), // iris
|
||
panel_bg: Color::Rgb(25, 23, 36),
|
||
surface0: Color::Rgb(31, 29, 46),
|
||
surface1: Color::Rgb(38, 35, 58),
|
||
surface_dim: Color::Rgb(25, 23, 36),
|
||
overlay0: Color::Rgb(110, 106, 134),
|
||
overlay1: Color::Rgb(144, 140, 170),
|
||
text: Color::Rgb(224, 222, 244),
|
||
subtext0: Color::Rgb(200, 197, 220),
|
||
mauve: Color::Rgb(196, 167, 231), // iris
|
||
green: Color::Rgb(49, 116, 143), // pine
|
||
yellow: Color::Rgb(246, 193, 119), // gold
|
||
red: Color::Rgb(235, 111, 146), // love
|
||
blue: Color::Rgb(49, 116, 143), // pine
|
||
teal: Color::Rgb(156, 207, 216), // foam
|
||
peach: Color::Rgb(234, 154, 151), // rose
|
||
}
|
||
}
|
||
|
||
/// Rosé Pine Dawn — the light Rosé Pine variant.
|
||
pub fn rose_pine_dawn() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(144, 122, 169),
|
||
panel_bg: Color::Rgb(250, 244, 237),
|
||
surface0: Color::Rgb(242, 233, 225),
|
||
surface1: Color::Rgb(255, 250, 243),
|
||
surface_dim: Color::Rgb(242, 233, 225),
|
||
overlay0: Color::Rgb(152, 147, 165),
|
||
overlay1: Color::Rgb(121, 117, 147),
|
||
text: Color::Rgb(70, 66, 97),
|
||
subtext0: Color::Rgb(121, 117, 147),
|
||
mauve: Color::Rgb(144, 122, 169),
|
||
green: Color::Rgb(40, 105, 131),
|
||
yellow: Color::Rgb(234, 157, 52),
|
||
red: Color::Rgb(180, 99, 122),
|
||
blue: Color::Rgb(40, 105, 131),
|
||
teal: Color::Rgb(86, 148, 159),
|
||
peach: Color::Rgb(215, 130, 126),
|
||
}
|
||
}
|
||
|
||
/// Vesper — minimal high-contrast monochrome with peach and mint accents.
|
||
pub fn vesper() -> Self {
|
||
Self {
|
||
accent: Color::Rgb(255, 199, 153),
|
||
panel_bg: Color::Rgb(26, 26, 26),
|
||
surface0: Color::Rgb(35, 35, 35),
|
||
surface1: Color::Rgb(40, 40, 40),
|
||
surface_dim: Color::Rgb(16, 16, 16),
|
||
overlay0: Color::Rgb(92, 92, 92),
|
||
overlay1: Color::Rgb(126, 126, 126),
|
||
text: Color::Rgb(255, 255, 255),
|
||
subtext0: Color::Rgb(160, 160, 160),
|
||
mauve: Color::Rgb(255, 209, 168),
|
||
green: Color::Rgb(153, 255, 228),
|
||
yellow: Color::Rgb(255, 199, 153),
|
||
red: Color::Rgb(255, 128, 128),
|
||
blue: Color::Rgb(176, 176, 176),
|
||
teal: Color::Rgb(102, 221, 204),
|
||
peach: Color::Rgb(255, 199, 153),
|
||
}
|
||
}
|
||
|
||
/// Resolve a theme by name. Returns None for unknown names.
|
||
pub fn from_name(name: &str) -> Option<Self> {
|
||
match name.to_lowercase().replace([' ', '_'], "-").as_str() {
|
||
"catppuccin" | "catppuccin-mocha" => Some(Self::catppuccin()),
|
||
"catppuccin-latte" | "latte" | "light" => Some(Self::catppuccin_latte()),
|
||
"terminal" => Some(Self::terminal()),
|
||
"tokyo-night" | "tokyonight" => Some(Self::tokyo_night()),
|
||
"tokyo-night-day" | "tokyo-day" | "tokyonight-day" => Some(Self::tokyo_night_day()),
|
||
"dracula" => Some(Self::dracula()),
|
||
"nord" => Some(Self::nord()),
|
||
"gruvbox" | "gruvbox-dark" => Some(Self::gruvbox()),
|
||
"gruvbox-light" => Some(Self::gruvbox_light()),
|
||
"one-dark" | "onedark" => Some(Self::one_dark()),
|
||
"one-light" | "onelight" => Some(Self::one_light()),
|
||
"solarized" | "solarized-dark" => Some(Self::solarized()),
|
||
"solarized-light" => Some(Self::solarized_light()),
|
||
"kanagawa" => Some(Self::kanagawa()),
|
||
"kanagawa-lotus" | "lotus" => Some(Self::kanagawa_lotus()),
|
||
"rose-pine" | "rosepine" => Some(Self::rose_pine()),
|
||
"rose-pine-dawn" | "rosepine-dawn" | "dawn" => Some(Self::rose_pine_dawn()),
|
||
"vesper" => Some(Self::vesper()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Apply custom color overrides on top of this palette.
|
||
pub fn with_overrides(mut self, custom: &crate::config::CustomThemeColors) -> Self {
|
||
use crate::config::parse_color;
|
||
if let Some(c) = &custom.accent {
|
||
self.accent = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.panel_bg {
|
||
self.panel_bg = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.surface0 {
|
||
self.surface0 = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.surface1 {
|
||
self.surface1 = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.surface_dim {
|
||
self.surface_dim = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.overlay0 {
|
||
self.overlay0 = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.overlay1 {
|
||
self.overlay1 = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.text {
|
||
self.text = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.subtext0 {
|
||
self.subtext0 = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.mauve {
|
||
self.mauve = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.green {
|
||
self.green = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.yellow {
|
||
self.yellow = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.red {
|
||
self.red = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.blue {
|
||
self.blue = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.teal {
|
||
self.teal = parse_color(c);
|
||
}
|
||
if let Some(c) = &custom.peach {
|
||
self.peach = parse_color(c);
|
||
}
|
||
self
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct WorkspaceCardArea {
|
||
pub ws_idx: usize,
|
||
pub rect: Rect,
|
||
pub indented: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct WorktreeCreateState {
|
||
pub source_workspace_id: String,
|
||
pub source_checkout_path: std::path::PathBuf,
|
||
pub source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>,
|
||
pub source_repo_root: std::path::PathBuf,
|
||
pub repo_key: String,
|
||
pub repo_name: String,
|
||
pub branch: String,
|
||
pub checkout_path: std::path::PathBuf,
|
||
pub error: Option<String>,
|
||
pub creating: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct WorktreeRemoveState {
|
||
pub workspace_id: String,
|
||
pub repo_root: std::path::PathBuf,
|
||
pub path: std::path::PathBuf,
|
||
pub error: Option<String>,
|
||
pub removing: bool,
|
||
pub force_confirmation: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct WorktreeOpenEntry {
|
||
pub path: std::path::PathBuf,
|
||
pub branch: Option<String>,
|
||
pub is_linked_worktree: bool,
|
||
pub already_open_ws_idx: Option<usize>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct WorktreeOpenState {
|
||
pub source_workspace_id: String,
|
||
pub source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>,
|
||
pub source_checkout_path: std::path::PathBuf,
|
||
pub source_repo_root: std::path::PathBuf,
|
||
pub repo_key: String,
|
||
pub repo_name: String,
|
||
pub entries: Vec<WorktreeOpenEntry>,
|
||
pub selected: usize,
|
||
pub error: Option<String>,
|
||
}
|
||
|
||
/// Computed view geometry — derived from AppState + terminal size.
|
||
/// Updated before each render, consumed by render and mouse handling.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ViewLayout {
|
||
Desktop,
|
||
Mobile,
|
||
}
|
||
|
||
pub struct ViewState {
|
||
pub layout: ViewLayout,
|
||
pub sidebar_rect: Rect,
|
||
pub workspace_card_areas: Vec<WorkspaceCardArea>,
|
||
pub tab_bar_rect: Rect,
|
||
pub tab_hit_areas: Vec<Rect>,
|
||
pub tab_scroll_left_hit_area: Rect,
|
||
pub tab_scroll_right_hit_area: Rect,
|
||
pub new_tab_hit_area: Rect,
|
||
pub terminal_area: Rect,
|
||
pub mobile_header_rect: Rect,
|
||
pub mobile_menu_hit_area: Rect,
|
||
pub toast_hit_area: Rect,
|
||
pub pane_infos: Vec<PaneInfo>,
|
||
pub split_borders: Vec<SplitBorder>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Mode {
|
||
Onboarding,
|
||
ReleaseNotes,
|
||
ProductAnnouncement,
|
||
Navigate,
|
||
Prefix,
|
||
Terminal,
|
||
RenameWorkspace,
|
||
RenameTab,
|
||
RenamePane,
|
||
NewLinkedWorktree,
|
||
OpenExistingWorktree,
|
||
ConfirmRemoveWorktree,
|
||
Resize,
|
||
ConfirmClose,
|
||
ContextMenu,
|
||
Settings,
|
||
GlobalMenu,
|
||
KeybindHelp,
|
||
Navigator,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub(crate) enum NavigatorTarget {
|
||
Workspace {
|
||
ws_idx: usize,
|
||
},
|
||
Tab {
|
||
ws_idx: usize,
|
||
tab_idx: usize,
|
||
},
|
||
Pane {
|
||
ws_idx: usize,
|
||
tab_idx: usize,
|
||
pane_id: PaneId,
|
||
},
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub(crate) struct NavigatorRow {
|
||
pub target: NavigatorTarget,
|
||
pub depth: u8,
|
||
pub label: String,
|
||
pub meta: String,
|
||
pub status: AgentState,
|
||
pub seen: bool,
|
||
pub is_current: bool,
|
||
pub is_workspace: bool,
|
||
pub is_tab: bool,
|
||
pub expanded: bool,
|
||
pub search_text: String,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) enum NavigatorStateFilter {
|
||
Blocked,
|
||
Working,
|
||
Idle,
|
||
Done,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default)]
|
||
pub(crate) struct NavigatorState {
|
||
pub query: String,
|
||
pub selected: usize,
|
||
pub scroll: usize,
|
||
pub search_focused: bool,
|
||
pub state_filter: Option<NavigatorStateFilter>,
|
||
pub expanded_workspaces: std::collections::HashSet<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
pub enum AgentPanelScope {
|
||
CurrentWorkspace,
|
||
#[default]
|
||
AllWorkspaces,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Settings UI state
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Which section of the settings panel is focused.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum SettingsSection {
|
||
Theme,
|
||
Sound,
|
||
Toast,
|
||
PaneLabels,
|
||
Experiments,
|
||
Integrations,
|
||
}
|
||
|
||
impl SettingsSection {
|
||
pub const ALL: &[Self] = &[
|
||
Self::Theme,
|
||
Self::Sound,
|
||
Self::Toast,
|
||
Self::PaneLabels,
|
||
Self::Integrations,
|
||
Self::Experiments,
|
||
];
|
||
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Theme => "theme",
|
||
Self::Sound => "sound",
|
||
Self::Toast => "toasts",
|
||
Self::PaneLabels => "pane labels",
|
||
Self::Experiments => "experiments",
|
||
Self::Integrations => "integrations",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// All built-in theme names in display order.
|
||
pub const THEME_NAMES: &[&str] = &[
|
||
"catppuccin",
|
||
"catppuccin-latte",
|
||
"terminal",
|
||
"tokyo-night",
|
||
"tokyo-night-day",
|
||
"dracula",
|
||
"nord",
|
||
"gruvbox",
|
||
"gruvbox-light",
|
||
"one-dark",
|
||
"one-light",
|
||
"solarized",
|
||
"solarized-light",
|
||
"kanagawa",
|
||
"kanagawa-lotus",
|
||
"rose-pine",
|
||
"rose-pine-dawn",
|
||
"vesper",
|
||
];
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct MenuListState {
|
||
pub highlighted: usize,
|
||
}
|
||
|
||
impl MenuListState {
|
||
pub fn new(highlighted: usize) -> Self {
|
||
Self { highlighted }
|
||
}
|
||
|
||
pub fn move_prev(&mut self) {
|
||
self.highlighted = self.highlighted.saturating_sub(1);
|
||
}
|
||
|
||
pub fn move_next(&mut self, item_count: usize) {
|
||
if item_count > 0 {
|
||
self.highlighted = (self.highlighted + 1).min(item_count - 1);
|
||
}
|
||
}
|
||
|
||
pub fn hover(&mut self, idx: Option<usize>) {
|
||
if let Some(idx) = idx {
|
||
self.highlighted = idx;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct SelectionListState {
|
||
pub selected: usize,
|
||
}
|
||
|
||
impl SelectionListState {
|
||
pub fn new(selected: usize) -> Self {
|
||
Self { selected }
|
||
}
|
||
|
||
pub fn move_prev(&mut self) {
|
||
self.selected = self.selected.saturating_sub(1);
|
||
}
|
||
|
||
pub fn move_next(&mut self, item_count: usize) {
|
||
if item_count > 0 {
|
||
self.selected = (self.selected + 1).min(item_count - 1);
|
||
}
|
||
}
|
||
|
||
pub fn select(&mut self, idx: usize) {
|
||
self.selected = idx;
|
||
}
|
||
}
|
||
|
||
pub struct SettingsState {
|
||
/// Which section tab is active.
|
||
pub section: SettingsSection,
|
||
/// Selected item index within the current section.
|
||
pub list: SelectionListState,
|
||
/// The palette before opening settings (for cancel/restore).
|
||
pub original_palette: Option<Palette>,
|
||
/// The theme name before opening settings.
|
||
pub original_theme: Option<String>,
|
||
}
|
||
|
||
pub(crate) enum DragTarget {
|
||
WorkspaceReorder {
|
||
source_ws_idx: usize,
|
||
insert_idx: Option<usize>,
|
||
},
|
||
TabReorder {
|
||
ws_idx: usize,
|
||
source_tab_idx: usize,
|
||
insert_idx: Option<usize>,
|
||
},
|
||
WorkspaceListScrollbar {
|
||
grab_row_offset: u16,
|
||
},
|
||
AgentPanelScrollbar {
|
||
grab_row_offset: u16,
|
||
},
|
||
PaneSplit {
|
||
path: Vec<bool>,
|
||
direction: Direction,
|
||
area: Rect,
|
||
},
|
||
PaneScrollbar {
|
||
pane_id: crate::layout::PaneId,
|
||
grab_row_offset: u16,
|
||
},
|
||
ReleaseNotesScrollbar {
|
||
grab_row_offset: u16,
|
||
},
|
||
ProductAnnouncementScrollbar {
|
||
grab_row_offset: u16,
|
||
},
|
||
KeybindHelpScrollbar {
|
||
grab_row_offset: u16,
|
||
},
|
||
SidebarDivider,
|
||
SidebarSectionDivider,
|
||
}
|
||
|
||
/// Active mouse drag on a split border or sidebar divider.
|
||
pub(crate) struct DragState {
|
||
pub target: DragTarget,
|
||
}
|
||
|
||
pub(crate) struct WorkspacePressState {
|
||
pub ws_idx: usize,
|
||
pub start_col: u16,
|
||
pub start_row: u16,
|
||
}
|
||
|
||
pub(crate) struct TabPressState {
|
||
pub ws_idx: usize,
|
||
pub tab_idx: usize,
|
||
pub start_col: u16,
|
||
pub start_row: u16,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum ContextMenuKind {
|
||
Workspace {
|
||
ws_idx: usize,
|
||
},
|
||
GitWorkspace {
|
||
ws_idx: usize,
|
||
is_linked_worktree: bool,
|
||
has_worktree_children: bool,
|
||
collapsed: bool,
|
||
},
|
||
Tab {
|
||
ws_idx: usize,
|
||
tab_idx: usize,
|
||
},
|
||
Pane {
|
||
pane_id: PaneId,
|
||
has_manual_label: bool,
|
||
},
|
||
}
|
||
|
||
/// Right-click context menu state.
|
||
pub struct ContextMenuState {
|
||
pub kind: ContextMenuKind,
|
||
pub x: u16,
|
||
pub y: u16,
|
||
pub list: MenuListState,
|
||
}
|
||
|
||
impl ContextMenuState {
|
||
pub fn items(&self) -> &'static [&'static str] {
|
||
match self.kind {
|
||
ContextMenuKind::Workspace { .. } => &["Rename", "Close"],
|
||
ContextMenuKind::GitWorkspace {
|
||
is_linked_worktree: false,
|
||
has_worktree_children: false,
|
||
..
|
||
} => &["Rename", "Close", "New worktree", "Open worktree..."],
|
||
ContextMenuKind::GitWorkspace {
|
||
is_linked_worktree: true,
|
||
..
|
||
} => &["Rename", "Close", "Delete worktree checkout..."],
|
||
ContextMenuKind::GitWorkspace {
|
||
is_linked_worktree: false,
|
||
has_worktree_children: true,
|
||
collapsed: true,
|
||
..
|
||
} => &[
|
||
"Rename",
|
||
"Close group",
|
||
"New worktree",
|
||
"Open worktree...",
|
||
"Expand",
|
||
],
|
||
ContextMenuKind::GitWorkspace {
|
||
is_linked_worktree: false,
|
||
has_worktree_children: true,
|
||
collapsed: false,
|
||
..
|
||
} => &[
|
||
"Rename",
|
||
"Close group",
|
||
"New worktree",
|
||
"Open worktree...",
|
||
"Collapse",
|
||
],
|
||
ContextMenuKind::Tab { .. } => &["New tab", "Rename", "Close"],
|
||
ContextMenuKind::Pane {
|
||
has_manual_label: true,
|
||
..
|
||
} => &[
|
||
"Rename pane",
|
||
"Clear pane name",
|
||
"Split vertical",
|
||
"Split horizontal",
|
||
"Zoom",
|
||
"Close pane",
|
||
],
|
||
ContextMenuKind::Pane {
|
||
has_manual_label: false,
|
||
..
|
||
} => &[
|
||
"Rename pane",
|
||
"Split vertical",
|
||
"Split horizontal",
|
||
"Zoom",
|
||
"Close pane",
|
||
],
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ToastKind {
|
||
NeedsAttention,
|
||
Finished,
|
||
UpdateInstalled,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct ToastTarget {
|
||
pub workspace_id: String,
|
||
pub pane_id: PaneId,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct ToastNotification {
|
||
pub kind: ToastKind,
|
||
pub title: String,
|
||
pub context: String,
|
||
pub target: Option<ToastTarget>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct CopyFeedback {
|
||
pub message: String,
|
||
}
|
||
|
||
pub struct ReleaseNotesState {
|
||
pub version: String,
|
||
pub body: String,
|
||
pub scroll: u16,
|
||
pub preview: bool,
|
||
}
|
||
|
||
pub struct ProductAnnouncementState {
|
||
pub version: String,
|
||
pub id: String,
|
||
pub title: String,
|
||
pub body: String,
|
||
pub scroll: u16,
|
||
pub preview: bool,
|
||
}
|
||
|
||
pub struct KeybindHelpState {
|
||
pub scroll: u16,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum SidebarWidthSource {
|
||
ConfigDefault,
|
||
Persisted,
|
||
Manual,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub(crate) struct PaneFocusTarget {
|
||
pub workspace_id: String,
|
||
pub pane_id: PaneId,
|
||
}
|
||
|
||
/// All application state — pure data, no channels or async runtime.
|
||
/// Testable without PTYs or a tokio runtime.
|
||
pub struct AppState {
|
||
pub terminals:
|
||
std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalState>,
|
||
/// Terminal ids whose size is currently owned by a direct attach client.
|
||
pub direct_attach_resize_locks: std::collections::HashSet<crate::terminal::TerminalId>,
|
||
pub(crate) pane_id_aliases: std::collections::HashMap<u32, PaneId>,
|
||
pub workspaces: Vec<Workspace>,
|
||
pub active: Option<usize>,
|
||
pub(crate) previous_pane_focus: Option<PaneFocusTarget>,
|
||
pub selected: usize,
|
||
pub mode: Mode,
|
||
pub should_quit: bool,
|
||
/// In monolithic --no-session mode, detach exits the app because there is no server to detach from.
|
||
pub detach_exits: bool,
|
||
/// Set when the current client should detach from the persistent session.
|
||
/// The server's event loop checks this and handles client detach.
|
||
pub detach_requested: bool,
|
||
pub request_new_workspace: bool,
|
||
pub request_new_tab: bool,
|
||
pub request_new_linked_worktree: Option<usize>,
|
||
pub request_open_existing_worktree: Option<usize>,
|
||
pub request_new_workspace_cwd: Option<std::path::PathBuf>,
|
||
pub request_remove_linked_worktree: Option<usize>,
|
||
pub request_submit_worktree_create: bool,
|
||
pub request_submit_worktree_open: bool,
|
||
pub request_submit_worktree_remove: bool,
|
||
pub request_reload_config: bool,
|
||
/// Set when the headless server should ask attached clients to reload
|
||
/// their client-local sound config from disk.
|
||
pub request_client_config_reload: bool,
|
||
/// Set when UI interaction requested a clipboard write that must be
|
||
/// handled by the outer App/event loop instead of directly from AppState.
|
||
pub request_clipboard_write: Option<Vec<u8>>,
|
||
pub creating_new_tab: bool,
|
||
pub requested_new_tab_name: Option<String>,
|
||
pub rename_pane_target: Option<PaneId>,
|
||
pub worktree_create: Option<WorktreeCreateState>,
|
||
pub worktree_open: Option<WorktreeOpenState>,
|
||
pub worktree_remove: Option<WorktreeRemoveState>,
|
||
pub worktree_directory: std::path::PathBuf,
|
||
pub collapsed_space_keys: std::collections::HashSet<String>,
|
||
pub request_complete_onboarding: bool,
|
||
pub name_input: String,
|
||
pub name_input_replace_on_type: bool,
|
||
pub release_notes: Option<ReleaseNotesState>,
|
||
pub product_announcement: Option<ProductAnnouncementState>,
|
||
pub keybind_help: KeybindHelpState,
|
||
pub navigator: NavigatorState,
|
||
pub workspace_scroll: usize,
|
||
pub agent_panel_scroll: usize,
|
||
pub tab_scroll: usize,
|
||
pub tab_scroll_follow_active: bool,
|
||
pub mobile_switcher_scroll: usize,
|
||
// View geometry (computed before render, consumed by render + mouse)
|
||
pub view: ViewState,
|
||
pub(crate) drag: Option<DragState>,
|
||
pub(crate) workspace_press: Option<WorkspacePressState>,
|
||
pub(crate) tab_press: Option<TabPressState>,
|
||
pub selection: Option<Selection>,
|
||
pub selection_autoscroll: Option<SelectionAutoscroll>,
|
||
pub context_menu: Option<ContextMenuState>,
|
||
// Notifications
|
||
pub update_available: Option<String>,
|
||
pub update_install_command: String,
|
||
pub latest_release_notes_available: bool,
|
||
pub update_dismissed: bool,
|
||
pub config_diagnostic: Option<String>,
|
||
pub toast: Option<ToastNotification>,
|
||
pub copy_feedback: Option<CopyFeedback>,
|
||
/// Last reported focus state for the outer terminal hosting herdr.
|
||
/// None means unsupported or not yet reported, which preserves active-pane suppression.
|
||
pub outer_terminal_focus: Option<bool>,
|
||
// Config
|
||
pub prefix_code: KeyCode,
|
||
pub prefix_mods: KeyModifiers,
|
||
pub default_sidebar_width: u16,
|
||
pub sidebar_width: u16,
|
||
pub sidebar_min_width: u16,
|
||
pub sidebar_max_width: u16,
|
||
pub mobile_width_threshold: u16,
|
||
pub sidebar_width_source: SidebarWidthSource,
|
||
pub sidebar_width_auto: bool,
|
||
pub sidebar_collapsed: bool,
|
||
/// Ratio of sidebar height allocated to the workspaces section.
|
||
pub sidebar_section_split: f32,
|
||
pub agent_panel_scope: AgentPanelScope,
|
||
/// Capture mouse input for Herdr's own mouse UI. When false, Herdr only
|
||
/// captures mouse while the focused pane app requests mouse reporting.
|
||
pub mouse_capture: bool,
|
||
pub redraw_on_focus_gained: bool,
|
||
pub mouse_scroll_lines: usize,
|
||
pub confirm_close: bool,
|
||
pub prompt_new_tab_name: bool,
|
||
pub show_agent_labels_on_pane_borders: bool,
|
||
pub pane_history_persistence: bool,
|
||
/// Expose the focused pane's cursor anchor to the outer terminal even when
|
||
/// the pane requested `?25l`. See `[experimental] reveal_hidden_cursor_for_cjk_ime`.
|
||
pub reveal_hidden_cursor_for_cjk_ime: bool,
|
||
/// Restrict cursor reveal to focused panes whose detected agent matches
|
||
/// one of these. When false, apply to any focused pane.
|
||
pub cjk_ime_agent_filter_configured: bool,
|
||
pub cjk_ime_agents: Vec<crate::detect::Agent>,
|
||
/// DECSCUSR shape parameter (1–6) for the IME anchor cursor.
|
||
pub cjk_ime_cursor_shape: u8,
|
||
pub kitty_graphics_enabled: bool,
|
||
pub default_shell: String,
|
||
pub new_terminal_cwd: NewTerminalCwdConfig,
|
||
pub pane_scrollback_limit_bytes: usize,
|
||
#[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth
|
||
pub accent: Color,
|
||
pub sound: SoundConfig,
|
||
pub local_sound_playback: bool,
|
||
pub toast_config: ToastConfig,
|
||
pub keybinds: Keybinds,
|
||
/// Frame counter for spinner animations (wraps around).
|
||
pub spinner_tick: u32,
|
||
/// UI color palette — all sidebar/UI colors centralized for theming.
|
||
pub palette: Palette,
|
||
/// Currently applied theme name (for settings UI).
|
||
pub theme_name: String,
|
||
/// Settings panel state.
|
||
pub settings: SettingsState,
|
||
/// Cached integration recommendations for onboarding/settings UI.
|
||
pub integration_recommendations: Vec<crate::integration::IntegrationRecommendation>,
|
||
/// Result messages from the latest integration install action.
|
||
pub integration_install_messages: Vec<String>,
|
||
/// Highlight state for the bottom-right global launcher menu.
|
||
pub global_menu: MenuListState,
|
||
/// Resolved host terminal default colors for theming embedded panes.
|
||
pub host_terminal_theme: TerminalTheme,
|
||
/// Set when a persisted session snapshot would change.
|
||
pub session_dirty: bool,
|
||
/// Terminal runtimes that should be shut down by the app/runtime layer
|
||
/// after state has detached their terminal metadata.
|
||
pub(crate) terminal_runtime_shutdowns: Vec<crate::terminal::TerminalId>,
|
||
}
|
||
|
||
impl AppState {
|
||
pub(crate) fn mark_session_dirty(&mut self) {
|
||
self.session_dirty = true;
|
||
}
|
||
|
||
pub(crate) fn remove_alias_shadowed_by_new_pane(&mut self, pane_id: PaneId) {
|
||
self.pane_id_aliases.remove(&pane_id.raw());
|
||
}
|
||
|
||
pub fn sound_enabled(&self) -> bool {
|
||
self.sound.enabled
|
||
}
|
||
|
||
pub fn toast_delivery(&self) -> ToastDelivery {
|
||
self.toast_config.delivery
|
||
}
|
||
|
||
pub fn agent_border_labels_enabled(&self) -> bool {
|
||
self.show_agent_labels_on_pane_borders
|
||
}
|
||
|
||
pub fn pane_history_persistence_enabled(&self) -> bool {
|
||
self.pane_history_persistence
|
||
}
|
||
|
||
pub(crate) fn integration_updates_available(&self) -> bool {
|
||
self.integration_recommendations
|
||
.iter()
|
||
.any(|item| item.state == crate::integration::IntegrationStatusKind::Outdated)
|
||
}
|
||
|
||
pub(crate) fn global_menu_attention_badge_visible(&self) -> bool {
|
||
self.update_available.is_some() || self.integration_updates_available()
|
||
}
|
||
|
||
pub(crate) fn global_menu_item_has_badge(&self, item: &str) -> bool {
|
||
(item == "update ready" && self.update_available.is_some())
|
||
|| (item == "settings" && self.integration_updates_available())
|
||
}
|
||
|
||
pub(crate) fn settings_section_has_badge(&self, section: SettingsSection) -> bool {
|
||
section == SettingsSection::Integrations && self.integration_updates_available()
|
||
}
|
||
|
||
pub(crate) fn focused_pane_requests_mouse_capture_from(
|
||
&self,
|
||
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
||
) -> bool {
|
||
self.mode == Mode::Terminal
|
||
&& self
|
||
.active
|
||
.and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx))
|
||
.and_then(crate::terminal::TerminalRuntime::input_state)
|
||
.is_some_and(crate::pane::InputState::mouse_reporting_enabled)
|
||
}
|
||
|
||
pub(crate) fn should_capture_host_mouse_from(
|
||
&self,
|
||
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
||
) -> bool {
|
||
self.mouse_capture || self.focused_pane_requests_mouse_capture_from(terminal_runtimes)
|
||
}
|
||
|
||
pub fn is_prefix_key(&self, key: crate::input::TerminalKey) -> bool {
|
||
crate::config::terminal_key_matches_combo(key, (self.prefix_code, self.prefix_mods))
|
||
}
|
||
|
||
pub fn estimate_pane_size(&self) -> (u16, u16) {
|
||
if let Some(info) = self.view.pane_infos.first() {
|
||
(info.rect.height, info.rect.width)
|
||
} else {
|
||
(24, 80)
|
||
}
|
||
}
|
||
|
||
/// Returns true when the given (workspace, tab, pane) refers to the
|
||
/// currently focused pane in the active workspace's active tab.
|
||
pub(crate) fn runtime_for_pane_in_workspace<'a>(
|
||
&'a self,
|
||
terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry,
|
||
ws_idx: usize,
|
||
pane_id: crate::layout::PaneId,
|
||
) -> Option<&'a crate::terminal::TerminalRuntime> {
|
||
#[cfg(test)]
|
||
if let Some(runtime) = self.workspaces.get(ws_idx)?.test_runtimes.get(&pane_id) {
|
||
return Some(runtime);
|
||
}
|
||
#[cfg(test)]
|
||
if let Some(runtime) = self
|
||
.workspaces
|
||
.get(ws_idx)?
|
||
.tabs
|
||
.iter()
|
||
.find_map(|tab| tab.runtimes.get(&pane_id))
|
||
{
|
||
return Some(runtime);
|
||
}
|
||
let terminal_id = self.workspaces.get(ws_idx)?.terminal_id(pane_id)?;
|
||
terminal_runtimes.get(terminal_id)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn runtime_for_pane<'a>(
|
||
&'a self,
|
||
terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry,
|
||
pane_id: crate::layout::PaneId,
|
||
) -> Option<&'a crate::terminal::TerminalRuntime> {
|
||
self.workspaces.iter().find_map(|ws| {
|
||
#[cfg(test)]
|
||
if let Some(runtime) = ws.test_runtimes.get(&pane_id) {
|
||
return Some(runtime);
|
||
}
|
||
#[cfg(test)]
|
||
if let Some(runtime) = ws.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id)) {
|
||
return Some(runtime);
|
||
}
|
||
let terminal_id = ws.terminal_id(pane_id)?;
|
||
terminal_runtimes.get(terminal_id)
|
||
})
|
||
}
|
||
|
||
pub(crate) fn focused_runtime_in_workspace<'a>(
|
||
&'a self,
|
||
terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry,
|
||
ws_idx: usize,
|
||
) -> Option<&'a crate::terminal::TerminalRuntime> {
|
||
let ws = self.workspaces.get(ws_idx)?;
|
||
let pane_id = ws.focused_pane_id()?;
|
||
self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)
|
||
}
|
||
|
||
pub fn is_active_pane(
|
||
&self,
|
||
ws_idx: usize,
|
||
tab_idx: usize,
|
||
pane_id: crate::layout::PaneId,
|
||
) -> bool {
|
||
let Some(active_ws_idx) = self.active else {
|
||
return false;
|
||
};
|
||
if ws_idx != active_ws_idx {
|
||
return false;
|
||
}
|
||
let Some(ws) = self.workspaces.get(ws_idx) else {
|
||
return false;
|
||
};
|
||
if tab_idx != ws.active_tab_index() {
|
||
return false;
|
||
}
|
||
ws.active_tab().map(|tab| tab.layout.focused()) == Some(pane_id)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub fn key_matches(
|
||
key: &crossterm::event::KeyEvent,
|
||
expected_code: KeyCode,
|
||
expected_mods: KeyModifiers,
|
||
) -> bool {
|
||
crate::config::terminal_key_matches_combo(
|
||
crate::input::TerminalKey::from(*key),
|
||
(expected_code, expected_mods),
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
impl AppState {
|
||
/// Create an AppState for testing — no channels, no PTYs.
|
||
pub fn test_new() -> Self {
|
||
Self {
|
||
terminals: std::collections::HashMap::new(),
|
||
direct_attach_resize_locks: std::collections::HashSet::new(),
|
||
pane_id_aliases: std::collections::HashMap::new(),
|
||
workspaces: Vec::new(),
|
||
active: None,
|
||
previous_pane_focus: None,
|
||
selected: 0,
|
||
mode: Mode::Navigate,
|
||
should_quit: false,
|
||
detach_exits: false,
|
||
detach_requested: false,
|
||
request_new_workspace: false,
|
||
request_new_tab: false,
|
||
request_new_linked_worktree: None,
|
||
request_open_existing_worktree: None,
|
||
request_new_workspace_cwd: None,
|
||
request_remove_linked_worktree: None,
|
||
request_submit_worktree_create: false,
|
||
request_submit_worktree_open: false,
|
||
request_submit_worktree_remove: false,
|
||
request_reload_config: false,
|
||
request_client_config_reload: false,
|
||
request_clipboard_write: None,
|
||
creating_new_tab: false,
|
||
requested_new_tab_name: None,
|
||
rename_pane_target: None,
|
||
worktree_create: None,
|
||
worktree_open: None,
|
||
worktree_remove: None,
|
||
worktree_directory: std::path::PathBuf::from("/tmp/herdr-worktrees"),
|
||
collapsed_space_keys: std::collections::HashSet::new(),
|
||
request_complete_onboarding: false,
|
||
name_input: String::new(),
|
||
name_input_replace_on_type: false,
|
||
release_notes: None,
|
||
product_announcement: None,
|
||
keybind_help: KeybindHelpState { scroll: 0 },
|
||
navigator: NavigatorState::default(),
|
||
workspace_scroll: 0,
|
||
agent_panel_scroll: 0,
|
||
tab_scroll: 0,
|
||
tab_scroll_follow_active: true,
|
||
mobile_switcher_scroll: 0,
|
||
view: ViewState {
|
||
layout: ViewLayout::Desktop,
|
||
sidebar_rect: Rect::default(),
|
||
workspace_card_areas: Vec::new(),
|
||
tab_bar_rect: Rect::default(),
|
||
tab_hit_areas: Vec::new(),
|
||
tab_scroll_left_hit_area: Rect::default(),
|
||
tab_scroll_right_hit_area: Rect::default(),
|
||
new_tab_hit_area: Rect::default(),
|
||
terminal_area: Rect::default(),
|
||
mobile_header_rect: Rect::default(),
|
||
mobile_menu_hit_area: Rect::default(),
|
||
toast_hit_area: Rect::default(),
|
||
pane_infos: Vec::new(),
|
||
split_borders: Vec::new(),
|
||
},
|
||
drag: None,
|
||
workspace_press: None,
|
||
tab_press: None,
|
||
selection: None,
|
||
selection_autoscroll: None,
|
||
context_menu: None,
|
||
update_available: None,
|
||
update_install_command: "herdr update".into(),
|
||
latest_release_notes_available: false,
|
||
update_dismissed: false,
|
||
config_diagnostic: None,
|
||
toast: None,
|
||
copy_feedback: None,
|
||
outer_terminal_focus: None,
|
||
prefix_code: KeyCode::Char('b'),
|
||
prefix_mods: KeyModifiers::CONTROL,
|
||
default_sidebar_width: 26,
|
||
sidebar_width: 26,
|
||
sidebar_min_width: 18,
|
||
sidebar_max_width: 36,
|
||
mobile_width_threshold: crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD,
|
||
sidebar_width_source: SidebarWidthSource::ConfigDefault,
|
||
sidebar_width_auto: false,
|
||
sidebar_collapsed: false,
|
||
sidebar_section_split: 0.5,
|
||
agent_panel_scope: AgentPanelScope::AllWorkspaces,
|
||
mouse_capture: true,
|
||
redraw_on_focus_gained: true,
|
||
mouse_scroll_lines: crate::config::DEFAULT_MOUSE_SCROLL_LINES,
|
||
confirm_close: true,
|
||
prompt_new_tab_name: true,
|
||
show_agent_labels_on_pane_borders: false,
|
||
pane_history_persistence: false,
|
||
reveal_hidden_cursor_for_cjk_ime: false,
|
||
cjk_ime_agent_filter_configured: false,
|
||
cjk_ime_agents: Vec::new(),
|
||
cjk_ime_cursor_shape: 2, // steady_block
|
||
kitty_graphics_enabled: false,
|
||
default_shell: String::new(),
|
||
new_terminal_cwd: NewTerminalCwdConfig::Follow,
|
||
pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES,
|
||
accent: Color::Cyan,
|
||
sound: SoundConfig {
|
||
enabled: false,
|
||
..SoundConfig::default()
|
||
},
|
||
local_sound_playback: false,
|
||
toast_config: ToastConfig::default(),
|
||
keybinds: Keybinds::default(),
|
||
spinner_tick: 0,
|
||
palette: Palette::catppuccin(),
|
||
theme_name: "catppuccin".to_string(),
|
||
settings: SettingsState {
|
||
section: SettingsSection::Theme,
|
||
list: SelectionListState::new(0),
|
||
original_palette: None,
|
||
original_theme: None,
|
||
},
|
||
integration_recommendations: Vec::new(),
|
||
integration_install_messages: Vec::new(),
|
||
global_menu: MenuListState::new(0),
|
||
host_terminal_theme: TerminalTheme::default(),
|
||
session_dirty: false,
|
||
terminal_runtime_shutdowns: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Populate missing `TerminalState` entries for every pane so tests that
|
||
/// read or write terminal metadata don't need to manually create them.
|
||
pub fn ensure_test_terminals(&mut self) {
|
||
use crate::terminal::TerminalState;
|
||
for ws in &self.workspaces {
|
||
for tab in &ws.tabs {
|
||
for pane in tab.panes.values() {
|
||
if !self.terminals.contains_key(&pane.attached_terminal_id) {
|
||
let cwd = ws.identity_cwd.clone();
|
||
self.terminals.insert(
|
||
pane.attached_terminal_id.clone(),
|
||
TerminalState::new(pane.attached_terminal_id.clone(), cwd),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn insert_test_runtime(
|
||
&mut self,
|
||
pane_id: crate::layout::PaneId,
|
||
runtime: crate::terminal::TerminalRuntime,
|
||
) {
|
||
if let Some(ws) = self
|
||
.workspaces
|
||
.iter_mut()
|
||
.find(|ws| ws.terminal_id(pane_id).is_some())
|
||
{
|
||
ws.insert_test_runtime(pane_id, runtime);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crossterm::event::KeyEvent;
|
||
|
||
#[test]
|
||
fn built_in_theme_names_resolve() {
|
||
for name in THEME_NAMES {
|
||
assert!(
|
||
Palette::from_name(name).is_some(),
|
||
"theme should resolve: {name}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn light_theme_aliases_resolve() {
|
||
for name in ["light", "latte", "tokyo-day", "onelight", "lotus", "dawn"] {
|
||
assert!(
|
||
Palette::from_name(name).is_some(),
|
||
"theme should resolve: {name}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn key_matches_requires_exact_modifiers() {
|
||
assert!(key_matches(
|
||
&KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
|
||
KeyCode::Char('b'),
|
||
KeyModifiers::CONTROL,
|
||
));
|
||
|
||
assert!(!key_matches(
|
||
&KeyEvent::new(
|
||
KeyCode::Char('b'),
|
||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||
),
|
||
KeyCode::Char('b'),
|
||
KeyModifiers::CONTROL,
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn key_matches_letters_case_insensitively() {
|
||
assert!(key_matches(
|
||
&KeyEvent::new(KeyCode::Char('B'), KeyModifiers::SHIFT),
|
||
KeyCode::Char('b'),
|
||
KeyModifiers::SHIFT,
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn linked_worktree_context_menu_keeps_safe_close_and_explicit_remove() {
|
||
let menu = ContextMenuState {
|
||
kind: ContextMenuKind::GitWorkspace {
|
||
ws_idx: 0,
|
||
is_linked_worktree: true,
|
||
has_worktree_children: false,
|
||
collapsed: false,
|
||
},
|
||
x: 0,
|
||
y: 0,
|
||
list: MenuListState::new(0),
|
||
};
|
||
|
||
assert_eq!(
|
||
menu.items(),
|
||
&["Rename", "Close", "Delete worktree checkout..."]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn git_workspace_context_menu_keeps_remove_for_managed_worktrees_only() {
|
||
let menu = ContextMenuState {
|
||
kind: ContextMenuKind::GitWorkspace {
|
||
ws_idx: 0,
|
||
is_linked_worktree: false,
|
||
has_worktree_children: false,
|
||
collapsed: false,
|
||
},
|
||
x: 0,
|
||
y: 0,
|
||
list: MenuListState::new(0),
|
||
};
|
||
|
||
assert_eq!(
|
||
menu.items(),
|
||
&["Rename", "Close", "New worktree", "Open worktree..."]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parent_worktree_context_menu_uses_repo_actions() {
|
||
let menu = ContextMenuState {
|
||
kind: ContextMenuKind::GitWorkspace {
|
||
ws_idx: 0,
|
||
is_linked_worktree: false,
|
||
has_worktree_children: true,
|
||
collapsed: false,
|
||
},
|
||
x: 0,
|
||
y: 0,
|
||
list: MenuListState::new(0),
|
||
};
|
||
|
||
assert_eq!(
|
||
menu.items(),
|
||
&[
|
||
"Rename",
|
||
"Close group",
|
||
"New worktree",
|
||
"Open worktree...",
|
||
"Collapse"
|
||
]
|
||
);
|
||
}
|
||
}
|