Skip to main content

tui_lipan/style/
theme.rs

1use std::any::{Any, TypeId};
2use std::collections::HashMap;
3use std::fmt;
4use std::hash::{Hash, Hasher};
5use std::sync::Arc;
6
7use crate::app::ContrastPolicy;
8
9use super::{Color, HostTerminalColors, Paint};
10
11/// A relative transform applied to an already-resolved color.
12#[cfg_attr(
13    feature = "terminal-serde",
14    derive(serde::Serialize, serde::Deserialize)
15)]
16#[derive(Clone, Copy, Debug)]
17pub enum ColorTransform {
18    /// Dim toward black by an amount in `[0.0, 1.0]`.
19    Dim(f32),
20    /// Lighten toward white by an amount in `[0.0, 1.0]`.
21    Lighten(f32),
22    /// Blend toward the resolved background by alpha `(1.0 - opacity)`.
23    ///
24    /// `1.0` keeps the original color unchanged, while `0.0` fully washes it
25    /// into the current background. This is most useful for foreground colors
26    /// on both dark and light themes.
27    Opacity(f32),
28    /// Like [`Self::Opacity`], but blend toward a fixed `target` instead of the cell backdrop.
29    OpacityToward {
30        /// Same semantics as [`Self::Opacity`]: `1.0` is unchanged, `0.0` is fully `target`.
31        factor: f32,
32        /// Destination color when `factor` approaches `0.0`.
33        target: Color,
34    },
35    /// Blend toward `color` by `alpha` in `[0.0, 1.0]`.
36    Tint(Color, f32),
37}
38
39impl PartialEq for ColorTransform {
40    fn eq(&self, other: &Self) -> bool {
41        match (*self, *other) {
42            (Self::Dim(a), Self::Dim(b))
43            | (Self::Lighten(a), Self::Lighten(b))
44            | (Self::Opacity(a), Self::Opacity(b)) => a.to_bits() == b.to_bits(),
45            (
46                Self::OpacityToward {
47                    factor: fa,
48                    target: ta,
49                },
50                Self::OpacityToward {
51                    factor: fb,
52                    target: tb,
53                },
54            ) => fa.to_bits() == fb.to_bits() && ta == tb,
55            (Self::Tint(color_a, alpha_a), Self::Tint(color_b, alpha_b)) => {
56                color_a == color_b && alpha_a.to_bits() == alpha_b.to_bits()
57            }
58            _ => false,
59        }
60    }
61}
62
63impl Eq for ColorTransform {}
64
65impl Hash for ColorTransform {
66    fn hash<H: Hasher>(&self, state: &mut H) {
67        match *self {
68            Self::Dim(amount) => {
69                0u8.hash(state);
70                amount.to_bits().hash(state);
71            }
72            Self::Lighten(amount) => {
73                1u8.hash(state);
74                amount.to_bits().hash(state);
75            }
76            Self::Opacity(amount) => {
77                2u8.hash(state);
78                amount.to_bits().hash(state);
79            }
80            Self::OpacityToward { factor, target } => {
81                4u8.hash(state);
82                factor.to_bits().hash(state);
83                target.hash(state);
84            }
85            Self::Tint(color, alpha) => {
86                3u8.hash(state);
87                color.hash(state);
88                alpha.to_bits().hash(state);
89            }
90        }
91    }
92}
93
94impl ColorTransform {
95    /// Apply this transform to `color`.
96    pub fn apply(self, color: Color) -> Color {
97        self.apply_with_backdrop(color, None)
98    }
99
100    /// Apply this transform to `color`, optionally using a resolved backdrop.
101    pub fn apply_with_backdrop(self, color: Color, backdrop: Option<Color>) -> Color {
102        if matches!(color, Color::Transparent | Color::Backdrop) {
103            return color;
104        }
105        match self {
106            Self::Dim(amount) => color.dim_by(amount),
107            Self::Lighten(amount) => color.lighten_by(amount),
108            Self::Opacity(opacity) => backdrop.map_or(color, |bg| {
109                color.blend_toward(bg, (1.0 - opacity).clamp(0.0, 1.0))
110            }),
111            Self::OpacityToward { factor, target } => {
112                color.blend_toward(target, (1.0 - factor).clamp(0.0, 1.0))
113            }
114            Self::Tint(target, alpha) => color.blend_toward(target, alpha),
115        }
116    }
117
118    /// Apply this transform to `paint`.
119    ///
120    /// Pigment transforms preserve the paint alpha; [`Self::Opacity`] composes
121    /// with the existing alpha by multiplying it by the opacity factor.
122    pub fn apply_paint(self, paint: Paint) -> Paint {
123        self.apply_paint_with_backdrop(paint, None)
124    }
125
126    /// Apply this transform to `paint`, optionally using a resolved backdrop paint.
127    pub fn apply_paint_with_backdrop(self, paint: Paint, backdrop: Option<Paint>) -> Paint {
128        if matches!(paint, Paint::Solid(Color::Transparent | Color::Backdrop)) {
129            return paint;
130        }
131        if let Self::Opacity(opacity) = self {
132            let alpha = (paint.alpha_u8() as f32 * opacity.clamp(0.0, 1.0))
133                .round()
134                .clamp(0.0, 255.0) as u8;
135            return Paint::from_color_alpha_u8(paint.color(), alpha);
136        }
137        let backdrop = backdrop.map(Paint::color);
138        match paint {
139            Paint::Solid(color) => Paint::Solid(self.apply_with_backdrop(color, backdrop)),
140            Paint::Alpha { color, alpha } => {
141                Paint::from_color_alpha_u8(self.apply_with_backdrop(color, backdrop), alpha)
142            }
143        }
144    }
145
146    pub(crate) fn needs_backdrop(self) -> bool {
147        matches!(self, Self::Opacity(_))
148    }
149
150    fn normalized(self) -> Self {
151        match self {
152            Self::Dim(amount) => Self::Dim(amount.clamp(0.0, 1.0)),
153            Self::Lighten(amount) => Self::Lighten(amount.clamp(0.0, 1.0)),
154            Self::Opacity(opacity) => Self::Opacity(opacity.clamp(0.0, 1.0)),
155            Self::OpacityToward { factor, target } => Self::OpacityToward {
156                factor: factor.clamp(0.0, 1.0),
157                target,
158            },
159            Self::Tint(color, alpha) => Self::Tint(color, alpha.clamp(0.0, 1.0)),
160        }
161    }
162}
163
164/// Marker trait for typed app-specific theme data stored inside [`Theme`].
165///
166/// Use this when your app needs semantic theme tokens that are not part of the
167/// core framework palettes, while still keeping those tokens inside the active
168/// framework theme rather than a parallel global palette.
169pub trait ThemeExtension: Clone + fmt::Debug + PartialEq + 'static {}
170
171impl<T> ThemeExtension for T where T: Clone + fmt::Debug + PartialEq + 'static {}
172
173trait ThemeExtensionValue: Any {
174    fn as_any(&self) -> &dyn Any;
175    fn eq_value(&self, other: &dyn ThemeExtensionValue) -> bool;
176}
177
178impl<T> ThemeExtensionValue for T
179where
180    T: ThemeExtension,
181{
182    fn as_any(&self) -> &dyn Any {
183        self
184    }
185
186    fn eq_value(&self, other: &dyn ThemeExtensionValue) -> bool {
187        other.as_any().downcast_ref::<T>() == Some(self)
188    }
189}
190
191#[derive(Clone, Default)]
192#[doc(hidden)]
193pub struct ThemeExtensions(HashMap<TypeId, Arc<dyn ThemeExtensionValue>>);
194
195impl ThemeExtensions {
196    fn insert<T>(&mut self, extension: T)
197    where
198        T: ThemeExtension,
199    {
200        self.0.insert(TypeId::of::<T>(), Arc::new(extension));
201    }
202
203    fn get<T>(&self) -> Option<&T>
204    where
205        T: ThemeExtension,
206    {
207        self.0
208            .get(&TypeId::of::<T>())
209            .and_then(|value| value.as_any().downcast_ref::<T>())
210    }
211
212    fn remove<T>(&mut self)
213    where
214        T: ThemeExtension,
215    {
216        self.0.remove(&TypeId::of::<T>());
217    }
218}
219
220impl PartialEq for ThemeExtensions {
221    fn eq(&self, other: &Self) -> bool {
222        self.0.len() == other.0.len()
223            && self.0.iter().all(|(type_id, value)| {
224                other
225                    .0
226                    .get(type_id)
227                    .is_some_and(|other_value| value.eq_value(other_value.as_ref()))
228            })
229    }
230}
231
232impl Eq for ThemeExtensions {}
233
234impl fmt::Debug for ThemeExtensions {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        f.debug_struct("ThemeExtensions")
237            .field("count", &self.0.len())
238            .finish()
239    }
240}
241
242/// Styling information (kept backend-agnostic).
243#[cfg_attr(
244    feature = "terminal-serde",
245    derive(serde::Serialize, serde::Deserialize)
246)]
247#[derive(Clone, Copy, Debug, Default)]
248pub struct Style {
249    /// Foreground color.
250    pub fg: Option<Paint>,
251    /// Background color.
252    pub bg: Option<Paint>,
253    /// Relative transform applied to the resolved foreground color.
254    pub fg_transform: Option<ColorTransform>,
255    /// Relative transform applied to the resolved background color.
256    pub bg_transform: Option<ColorTransform>,
257    /// Contrast override applied after color transforms resolve.
258    pub contrast_policy: Option<ContrastPolicy>,
259    /// Bold modifier.
260    pub bold: Option<bool>,
261    /// Dim modifier.
262    pub dim: Option<bool>,
263    /// Italic modifier.
264    pub italic: Option<bool>,
265    /// Underline modifier.
266    pub underline: Option<bool>,
267    /// Reverse video modifier.
268    pub reverse: Option<bool>,
269    /// Strikethrough modifier.
270    pub strikethrough: Option<bool>,
271    /// Underline color (requires underline to be enabled).
272    pub underline_color: Option<Paint>,
273    /// Cell-level dim amount in `[0.0, 1.0]`.
274    ///
275    /// When set, the renderer scales the existing rendered colors of every cell
276    /// in the area by `(1.0 - dim_amount)` before drawing this style on top.
277    /// This makes `dim_by` work even when no explicit fg/bg colors are set,
278    /// which is the typical backdrop use-case.
279    pub dim_amount: Option<f32>,
280    /// Tint color and blend alpha in `[0.0, 1.0]`.
281    ///
282    /// When set, the renderer blends every existing cell color in the area
283    /// toward this color by the given alpha before drawing this style on top.
284    /// `Color::Reset` cell backgrounds are treated as black `(0, 0, 0)` for
285    /// blending, so the tint is visible even on transparent-background
286    /// terminals.
287    pub tint: Option<(Color, f32)>,
288}
289
290impl PartialEq for Style {
291    fn eq(&self, other: &Self) -> bool {
292        self.fg == other.fg
293            && self.bg == other.bg
294            && self.fg_transform == other.fg_transform
295            && self.bg_transform == other.bg_transform
296            && self.contrast_policy == other.contrast_policy
297            && self.bold == other.bold
298            && self.dim == other.dim
299            && self.italic == other.italic
300            && self.underline == other.underline
301            && self.reverse == other.reverse
302            && self.strikethrough == other.strikethrough
303            && self.underline_color == other.underline_color
304            && self.dim_amount.map(f32::to_bits) == other.dim_amount.map(f32::to_bits)
305            && self.tint.map(|(c, a)| (c, a.to_bits())) == other.tint.map(|(c, a)| (c, a.to_bits()))
306    }
307}
308
309impl Eq for Style {}
310
311#[cfg(all(test, feature = "terminal-serde"))]
312mod terminal_serde_tests {
313    use super::*;
314
315    #[test]
316    fn color_transform_round_trips() {
317        let transform = ColorTransform::OpacityToward {
318            factor: 0.42,
319            target: Color::rgb(1, 2, 3),
320        };
321        let json = serde_json::to_string(&transform).unwrap();
322        assert_eq!(
323            serde_json::from_str::<ColorTransform>(&json).unwrap(),
324            transform
325        );
326    }
327
328    #[test]
329    fn style_round_trips() {
330        let style = Style::default()
331            .fg(Paint::rgb(20, 30, 40))
332            .bg(Paint::rgba(1, 2, 3, 180))
333            .bold()
334            .underline()
335            .contrast_policy(ContrastPolicy::BlackOrWhite)
336            .tint_by(Color::Cyan, 0.25);
337        let json = serde_json::to_string(&style).unwrap();
338        assert_eq!(serde_json::from_str::<Style>(&json).unwrap(), style);
339    }
340}
341
342/// Describes how a widget-owned style slot consumes the active theme.
343///
344/// `Style` itself remains a partial overlay where `None` means “fall through to
345/// the layer below”. `StyleSlot` adds the missing slot-level intent for themed
346/// state styles such as selection, hover, focus, and active rows.
347#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
348pub enum StyleSlot {
349    /// Use the active theme role verbatim.
350    #[default]
351    Inherit,
352    /// Patch this style on top of the active theme role.
353    Extend(Style),
354    /// Use this style as the complete slot overlay; ignore the theme role.
355    Replace(Style),
356}
357
358impl StyleSlot {
359    /// Create a replacement slot from a style.
360    pub fn replace(style: Style) -> Self {
361        Self::Replace(style)
362    }
363
364    /// Create an extending slot from a style.
365    pub fn extend(style: Style) -> Self {
366        Self::Extend(style)
367    }
368
369    /// Return the explicit style when this slot carries one.
370    pub fn explicit_style(self) -> Option<Style> {
371        match self {
372            Self::Inherit => None,
373            Self::Extend(style) | Self::Replace(style) => Some(style),
374        }
375    }
376
377    /// Whether this slot carries a non-empty explicit style.
378    pub fn has_explicit_style(self) -> bool {
379        self.explicit_style().is_some_and(|style| !style.is_empty())
380    }
381
382    /// Whether this slot is guaranteed to resolve to an empty overlay without theme lookup.
383    pub fn is_empty(self) -> bool {
384        matches!(self, Self::Replace(style) if style.is_empty())
385    }
386}
387
388impl From<Style> for StyleSlot {
389    fn from(style: Style) -> Self {
390        Self::Replace(style)
391    }
392}
393
394/// Semantic style roles exposed by [`Theme`].
395#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
396pub enum ThemeRole {
397    /// Default widget text/surface style.
398    Base,
399    /// Interactive accent/emphasis style.
400    Accent,
401    /// Selected/current item style.
402    Selection,
403    /// Text/range selection style.
404    TextSelection,
405    /// Selection style for unfocused widgets. Falls back to [`Selection`](Self::Selection).
406    UnfocusedSelection,
407    /// Hover state style.
408    Hover,
409    /// Drag-source active style used while a source is being dragged.
410    ///
411    /// Currently resolves to the same palette as [`Hover`](Self::Hover) so existing
412    /// drag feedback stays unchanged, but it is semantically separate from pointer
413    /// hover and may receive a dedicated palette in a future theme revision.
414    DragSource,
415    /// Drop-zone affordance style for inactive but available drop targets.
416    ///
417    /// This role is intended for future always-visible or pre-active drop-zone
418    /// affordances. It currently resolves like [`Hover`](Self::Hover) and may be
419    /// unused by widgets until inactive drop-zone styling is introduced.
420    DropTarget,
421    /// Active drop-target style used while a compatible drag is over the target.
422    ///
423    /// Currently resolves to the same palette as [`Hover`](Self::Hover) so existing
424    /// drop highlight feedback stays unchanged, but it is semantically separate
425    /// from genuine pointer hover.
426    DropTargetActive,
427    /// Focus state style.
428    Focus,
429    /// Active state style. Currently resolves to the selection role by default.
430    Active,
431    /// Per-item hover state style.
432    ItemHover,
433    /// Border/frame style.
434    Border,
435    /// Disabled or muted secondary-content style.
436    Disabled,
437    /// Muted secondary-content style.
438    Muted,
439    /// Error/status style.
440    Error,
441    /// Focused input content style.
442    InputFocusContent,
443    /// Focused text-area content style.
444    TextAreaFocusContent,
445    /// Focused document-view content style.
446    DocumentViewFocusContent,
447    /// Focused hex-area content style.
448    HexAreaFocusContent,
449    /// Hex-area cursor style.
450    HexAreaCursor,
451    /// Focused terminal content style.
452    TerminalFocusContent,
453    /// Scrollbar thumb style.
454    ScrollbarThumb,
455    /// Focused scrollbar thumb style.
456    ScrollbarThumbFocus,
457    /// Scrollbar track style.
458    ScrollbarTrack,
459    /// Splitter hover handle style.
460    SplitterHover,
461    /// Splitter active handle style.
462    SplitterActive,
463}
464
465impl Hash for Style {
466    fn hash<H: Hasher>(&self, state: &mut H) {
467        self.fg.hash(state);
468        self.bg.hash(state);
469        self.fg_transform.hash(state);
470        self.bg_transform.hash(state);
471        self.contrast_policy.hash(state);
472        self.bold.hash(state);
473        self.dim.hash(state);
474        self.italic.hash(state);
475        self.underline.hash(state);
476        self.reverse.hash(state);
477        self.strikethrough.hash(state);
478        self.underline_color.hash(state);
479        self.dim_amount.map(f32::to_bits).hash(state);
480        if let Some((c, a)) = self.tint {
481            c.hash(state);
482            a.to_bits().hash(state);
483        }
484    }
485}
486
487impl Style {
488    /// Create a new, empty style.
489    pub fn new() -> Self {
490        Self::default()
491    }
492
493    /// Set foreground color.
494    pub fn fg(mut self, color: impl Into<Paint>) -> Self {
495        self.fg = Some(color.into());
496        self
497    }
498
499    /// Set background color.
500    pub fn bg(mut self, color: impl Into<Paint>) -> Self {
501        self.bg = Some(color.into());
502        self
503    }
504
505    /// Set foreground color with alpha in `[0.0, 1.0]`.
506    pub fn fg_alpha(mut self, color: Color, alpha: f32) -> Self {
507        self.fg = Some(Paint::from_color_alpha(color, alpha));
508        self
509    }
510
511    /// Set background color with alpha in `[0.0, 1.0]`.
512    pub fn bg_alpha(mut self, color: Color, alpha: f32) -> Self {
513        self.bg = Some(Paint::from_color_alpha(color, alpha));
514        self
515    }
516
517    /// Apply a relative transform to the resolved foreground color.
518    pub fn transform_fg(mut self, transform: ColorTransform) -> Self {
519        self.fg_transform = Some(transform.normalized());
520        self
521    }
522
523    /// Apply a relative transform to the resolved background color.
524    pub fn transform_bg(mut self, transform: ColorTransform) -> Self {
525        self.bg_transform = Some(transform.normalized());
526        self
527    }
528
529    /// Override contrast adjustment for this style only.
530    pub fn contrast_policy(mut self, policy: ContrastPolicy) -> Self {
531        self.contrast_policy = Some(policy);
532        self
533    }
534
535    /// Enable bold.
536    pub fn bold(mut self) -> Self {
537        self.bold = Some(true);
538        self
539    }
540
541    /// Explicitly disable bold.
542    ///
543    /// Sets `bold` to `Some(false)`, which prevents renderer-level bold
544    /// fallbacks from triggering and removes bold when patched onto a style
545    /// that already has it.
546    pub fn not_bold(mut self) -> Self {
547        self.bold = Some(false);
548        self
549    }
550
551    /// Enable dim.
552    pub fn dim(mut self) -> Self {
553        self.dim = Some(true);
554        self
555    }
556
557    /// Dim by an explicit amount in `[0.0, 1.0]`.
558    ///
559    /// - For explicit `fg`/`bg` colors the channels are scaled in color-space.
560    /// - Additionally, `dim_amount` is stored so the renderer can scale the
561    ///   existing rendered colors of every cell in the area (e.g. a backdrop)
562    ///   even when no explicit colors are set on this style.
563    pub fn dim_by(mut self, amount: f32) -> Self {
564        let amount = amount.clamp(0.0, 1.0);
565        self.fg_transform = Some(ColorTransform::Dim(amount));
566        self.bg_transform = Some(ColorTransform::Dim(amount));
567        self.dim_amount = Some(amount);
568        self
569    }
570
571    /// Tint backdrop by blending existing rendered cell colors toward `color`
572    /// by `alpha` in `[0.0, 1.0]`.
573    ///
574    /// - `0.0` leaves cells unchanged.
575    /// - `1.0` replaces all cell colors with `color`.
576    ///
577    /// Unlike `dim_by`, this blends toward a specific color rather than black,
578    /// making it visible even on terminals that use `Color::Reset` as their
579    /// background (Reset is treated as black for blending).
580    ///
581    /// This sets the compositor hook only and does not modify any explicit
582    /// `fg`/`bg` colors on this style.
583    pub fn tint_by(mut self, color: Color, alpha: f32) -> Self {
584        let alpha = alpha.clamp(0.0, 1.0);
585        self.tint = Some((color, alpha));
586        self
587    }
588
589    /// Lighten explicit `fg`/`bg` colors by an amount in `[0.0, 1.0]`.
590    ///
591    /// - `0.0` keeps explicit colors unchanged.
592    /// - `1.0` moves explicit colors to white.
593    ///
594    /// Unlike `dim_by`, this only affects colors explicitly set on this style.
595    pub fn lighten_by(mut self, amount: f32) -> Self {
596        let amount = amount.clamp(0.0, 1.0);
597        self.fg_transform = Some(ColorTransform::Lighten(amount));
598        self.bg_transform = Some(ColorTransform::Lighten(amount));
599        self
600    }
601
602    /// Enable italic.
603    pub fn italic(mut self) -> Self {
604        self.italic = Some(true);
605        self
606    }
607
608    /// Enable underline.
609    pub fn underline(mut self) -> Self {
610        self.underline = Some(true);
611        self
612    }
613
614    /// Enable reverse video.
615    pub fn reverse(mut self) -> Self {
616        self.reverse = Some(true);
617        self
618    }
619
620    /// Enable strikethrough.
621    pub fn strikethrough(mut self) -> Self {
622        self.strikethrough = Some(true);
623        self
624    }
625
626    /// Set underline color. Also enables underline automatically.
627    pub fn underline_color(mut self, color: impl Into<Paint>) -> Self {
628        self.underline_color = Some(color.into());
629        self.underline = Some(true);
630        self
631    }
632
633    /// Returns `true` if this style has no colors or modifiers set.
634    ///
635    /// This is used to check whether a hover/focus style would have any effect.
636    pub fn is_empty(&self) -> bool {
637        self.fg.is_none()
638            && self.bg.is_none()
639            && self.fg_transform.is_none()
640            && self.bg_transform.is_none()
641            && self.contrast_policy.is_none()
642            && self.bold.is_none()
643            && self.dim.is_none()
644            && self.italic.is_none()
645            && self.underline.is_none()
646            && self.reverse.is_none()
647            && self.strikethrough.is_none()
648            && self.underline_color.is_none()
649            && self.dim_amount.is_none()
650            && self.tint.is_none()
651    }
652
653    /// Merge another style on top of this one.
654    ///
655    /// Colors from `other` take precedence if set.
656    /// Modifiers from `other` take precedence if set (Some).
657    ///
658    /// Color transforms (`transform_fg` / `transform_bg`) on `other` compose
659    /// on the *previous resolved color*: if `self` has an explicit color and
660    /// `other` only has a transform, the transform is applied to that resolved
661    /// color and baked in. Chaining `.patch()` calls therefore stacks
662    /// transforms in cascade order (e.g. `base.patch(hover).patch(focus)`
663    /// applies hover's transform to base, then focus's transform to the
664    /// hover-resolved color). Transforms only carry forward unresolved when
665    /// no color is yet available - see `merge_channel`.
666    pub fn patch(self, other: Style) -> Self {
667        let (bg, bg_transform) = merge_channel(
668            self.bg,
669            self.bg_transform,
670            other.bg,
671            other.bg_transform,
672            None,
673        );
674        let backdrop = bg.or(other.bg).or(self.bg);
675        let (fg, fg_transform) = merge_channel(
676            self.fg,
677            self.fg_transform,
678            other.fg,
679            other.fg_transform,
680            backdrop,
681        );
682
683        Self {
684            fg,
685            bg,
686            fg_transform,
687            bg_transform,
688            contrast_policy: other.contrast_policy.or(self.contrast_policy),
689            bold: other.bold.or(self.bold),
690            dim: other.dim.or(self.dim),
691            italic: other.italic.or(self.italic),
692            underline: other.underline.or(self.underline),
693            reverse: other.reverse.or(self.reverse),
694            strikethrough: other.strikethrough.or(self.strikethrough),
695            underline_color: merge_underline_color(other.underline_color, self.underline_color),
696            dim_amount: other.dim_amount.or(self.dim_amount),
697            tint: other.tint.or(self.tint),
698        }
699    }
700
701    pub(crate) fn resolve_color_transforms(self) -> Self {
702        let bg = resolve_channel(self.bg, self.bg_transform, None);
703        let mut fg = resolve_channel(self.fg, self.fg_transform, bg);
704        let mut fg_transform_remaining = None;
705
706        if matches!(fg, Some(Paint::Solid(Color::Transparent))) {
707            if let Some(c) = bg
708                && !matches!(c, Paint::Solid(Color::Transparent | Color::Backdrop))
709            {
710                fg = if let Some(t) = self.fg_transform {
711                    Some(t.apply_paint_with_backdrop(c, bg))
712                } else {
713                    Some(c)
714                };
715            } else {
716                fg_transform_remaining = self.fg_transform;
717            }
718        }
719        Self {
720            fg,
721            bg,
722            fg_transform: fg_transform_remaining,
723            bg_transform: None,
724            ..self
725        }
726    }
727}
728
729fn merge_underline_color(overlay: Option<Paint>, base: Option<Paint>) -> Option<Paint> {
730    match overlay {
731        None => base,
732        Some(Paint::Solid(Color::Transparent)) => base,
733        Some(c) => Some(c),
734    }
735}
736
737pub(crate) fn merge_channel(
738    base_color: Option<Paint>,
739    base_transform: Option<ColorTransform>,
740    overlay_color: Option<Paint>,
741    overlay_transform: Option<ColorTransform>,
742    backdrop: Option<Paint>,
743) -> (Option<Paint>, Option<ColorTransform>) {
744    let mut color = resolve_channel(base_color, base_transform, backdrop);
745    let mut transform = None;
746
747    if let Some(overlay_color) = overlay_color
748        && !matches!(overlay_color, Paint::Solid(Color::Transparent))
749    {
750        color = Some(overlay_color);
751    }
752
753    if let Some(overlay_transform) = overlay_transform {
754        if let Some(current) = color
755            && (!overlay_transform.needs_backdrop() || backdrop.is_some())
756        {
757            color = Some(overlay_transform.apply_paint_with_backdrop(current, backdrop));
758        } else {
759            transform = Some(overlay_transform.normalized());
760        }
761    }
762
763    (color, transform)
764}
765
766pub(crate) fn resolve_channel(
767    color: Option<Paint>,
768    transform: Option<ColorTransform>,
769    backdrop: Option<Paint>,
770) -> Option<Paint> {
771    match (color, transform) {
772        (Some(color), Some(transform)) => {
773            Some(transform.apply_paint_with_backdrop(color, backdrop))
774        }
775        (color, None) => color,
776        (None, Some(_)) => None,
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use super::{ColorTransform, Style, Theme, ThemePalette, ThemeRole};
783    use crate::app::ContrastPolicy;
784    use crate::style::{Color, HostTerminalColors, Paint};
785
786    fn p(color: Color) -> Option<Paint> {
787        Some(Paint::Solid(color))
788    }
789
790    #[derive(Clone, Debug, PartialEq)]
791    struct BrandTheme {
792        accent_badge: Color,
793    }
794
795    #[test]
796    fn drag_drop_roles_initially_resolve_to_hover() {
797        let theme = Theme::default().hover(Style::new().fg(Color::White).bg(Color::Blue));
798
799        assert_eq!(theme.role(ThemeRole::DragSource), theme.hover);
800        assert_eq!(theme.role(ThemeRole::DropTarget), theme.hover);
801        assert_eq!(theme.role(ThemeRole::DropTargetActive), theme.hover);
802    }
803
804    #[test]
805    fn text_selection_role_is_distinct_from_item_selection() {
806        let theme = Theme::default()
807            .selection(Style::new().fg(Color::Red))
808            .text_selection(Style::new().fg(Color::Blue));
809
810        assert_eq!(theme.role(ThemeRole::Selection), theme.selection);
811        assert_eq!(theme.role(ThemeRole::TextSelection), theme.text_selection);
812        assert_ne!(
813            theme.role(ThemeRole::Selection),
814            theme.role(ThemeRole::TextSelection)
815        );
816    }
817
818    #[test]
819    fn theme_palette_derives_distinct_selection_colors() {
820        let theme = ThemePalette::new(Color::White, Color::Black, Color::Blue)
821            .selection(Color::Green)
822            .text_selection(Color::Magenta)
823            .into_theme();
824
825        assert_eq!(theme.selection.fg, p(Color::Green));
826        assert_eq!(theme.text_selection.fg, p(Color::Magenta));
827    }
828
829    #[test]
830    fn from_host_colors_uses_host_palette() {
831        let mut ansi = std::array::from_fn(|i| Color::rgb(i as u8, i as u8, i as u8));
832        ansi[1] = Color::rgb(210, 30, 40);
833        ansi[2] = Color::rgb(30, 210, 40);
834        ansi[3] = Color::rgb(210, 180, 40);
835        ansi[4] = Color::rgb(30, 80, 210);
836        let colors = HostTerminalColors {
837            fg: Color::rgb(230, 231, 232),
838            bg: Color::rgb(10, 11, 12),
839            ansi,
840        };
841
842        let theme = Theme::from_host_colors(colors);
843
844        assert_eq!(theme.primary.fg, p(colors.fg));
845        assert_eq!(theme.primary.bg, p(colors.bg));
846        assert_eq!(theme.accent.fg, p(colors.ansi[4]));
847        assert_eq!(theme.status.success, colors.ansi[2]);
848        assert_eq!(theme.status.warning, colors.ansi[3]);
849        assert_eq!(theme.status.error, colors.ansi[1]);
850        assert_eq!(theme.status.info, colors.ansi[4]);
851    }
852
853    #[test]
854    fn transform_fg_dims_inherited_color() {
855        let base = Style::new().fg(Color::rgb(100, 120, 140));
856        let overlay = Style::new().transform_fg(ColorTransform::Dim(0.5));
857
858        assert_eq!(
859            base.patch(overlay).resolve_color_transforms().fg,
860            p(Color::rgb(50, 60, 70))
861        );
862    }
863
864    #[test]
865    fn lower_fg_transform_does_not_affect_overlay_color() {
866        let base = Style::new()
867            .fg(Color::rgb(100, 120, 140))
868            .transform_fg(ColorTransform::Dim(0.5));
869        let overlay = Style::new().fg(Color::rgb(10, 20, 30));
870
871        assert_eq!(
872            base.patch(overlay).resolve_color_transforms().fg,
873            p(Color::rgb(10, 20, 30))
874        );
875    }
876
877    #[test]
878    fn patch_transparent_fg_preserves_base() {
879        let base = Style::new().fg(Color::rgb(10, 20, 30));
880        let overlay = Style::new().fg(Color::Transparent);
881        assert_eq!(
882            base.patch(overlay).resolve_color_transforms().fg,
883            p(Color::rgb(10, 20, 30))
884        );
885    }
886
887    #[test]
888    fn patch_transparent_bg_preserves_base() {
889        let base = Style::new().bg(Color::rgb(40, 50, 60));
890        let overlay = Style::new().bg(Color::Transparent);
891        assert_eq!(
892            base.patch(overlay).resolve_color_transforms().bg,
893            p(Color::rgb(40, 50, 60))
894        );
895    }
896
897    #[test]
898    fn patch_alpha_zero_bg_is_not_transparent_sentinel() {
899        let base = Style::new().bg(Color::rgb(40, 50, 60));
900        let overlay = Style::new().bg_alpha(Color::Red, 0.0);
901        assert_eq!(
902            base.patch(overlay).resolve_color_transforms().bg,
903            Some(Paint::Alpha {
904                color: Color::Red,
905                alpha: 0,
906            })
907        );
908    }
909
910    #[test]
911    fn color_transform_apply_paint_preserves_alpha() {
912        let paint = Paint::Alpha {
913            color: Color::rgb(100, 120, 140),
914            alpha: 128,
915        };
916
917        assert_eq!(
918            ColorTransform::Dim(0.5).apply_paint(paint),
919            Paint::Alpha {
920                color: Color::rgb(50, 60, 70),
921                alpha: 128,
922            }
923        );
924    }
925
926    #[test]
927    fn patch_transparent_underline_color_preserves_base() {
928        let base = Style::new().underline_color(Color::Red);
929        let overlay = Style::new().underline_color(Color::Transparent);
930        let patched = base.patch(overlay);
931        assert_eq!(patched.underline_color, p(Color::Red));
932    }
933
934    #[test]
935    fn builder_order_does_not_change_transform_result() {
936        let a = Style::new()
937            .transform_fg(ColorTransform::Dim(0.5))
938            .fg(Color::rgb(100, 120, 140));
939        let b = Style::new()
940            .fg(Color::rgb(100, 120, 140))
941            .transform_fg(ColorTransform::Dim(0.5));
942
943        assert_eq!(a.resolve_color_transforms(), b.resolve_color_transforms());
944        assert_eq!(a.resolve_color_transforms().fg, p(Color::rgb(50, 60, 70)));
945    }
946
947    #[test]
948    fn transform_chain_applies_in_patch_order() {
949        let style = Style::new()
950            .fg(Color::rgb(100, 120, 140))
951            .patch(Style::new().transform_fg(ColorTransform::Dim(0.5)))
952            .patch(Style::new().transform_fg(ColorTransform::Lighten(0.5)))
953            .resolve_color_transforms();
954
955        assert_eq!(style.fg, p(Color::rgb(153, 158, 163)));
956    }
957
958    #[test]
959    fn state_cascade_stacks_bg_transforms_on_resolved_color() {
960        // Models base.patch(hover).patch(focus) where hover and focus only
961        // carry transforms - each must compose on the previous resolved bg,
962        // not independently against the original base.
963        let style = Style::new()
964            .bg(Color::rgb(100, 100, 100))
965            .patch(Style::new().transform_bg(ColorTransform::Dim(0.5)))
966            .patch(Style::new().transform_bg(ColorTransform::Dim(0.5)))
967            .resolve_color_transforms();
968
969        assert_eq!(style.bg, p(Color::rgb(25, 25, 25)));
970    }
971
972    #[test]
973    fn opacity_turns_foreground_into_alpha_paint() {
974        let style = Style::new()
975            .fg(Color::rgb(245, 167, 66))
976            .bg(Color::rgb(255, 255, 255))
977            .transform_fg(ColorTransform::Opacity(0.6))
978            .resolve_color_transforms();
979
980        assert_eq!(
981            style.fg,
982            Some(Paint::Alpha {
983                color: Color::rgb(245, 167, 66),
984                alpha: 153,
985            })
986        );
987    }
988
989    #[test]
990    fn opacity_builder_order_is_independent_when_background_arrives_later() {
991        let a = Style::new()
992            .transform_fg(ColorTransform::Opacity(0.6))
993            .fg(Color::rgb(245, 167, 66))
994            .bg(Color::rgb(255, 255, 255));
995        let b = Style::new()
996            .fg(Color::rgb(245, 167, 66))
997            .bg(Color::rgb(255, 255, 255))
998            .transform_fg(ColorTransform::Opacity(0.6));
999
1000        assert_eq!(a.resolve_color_transforms(), b.resolve_color_transforms());
1001    }
1002
1003    #[test]
1004    fn opacity_toward_uses_fixed_target_not_backdrop() {
1005        let c = Color::rgb(0, 100, 200);
1006        let target = Color::rgb(200, 10, 30);
1007        assert_eq!(
1008            ColorTransform::OpacityToward {
1009                factor: 1.0,
1010                target,
1011            }
1012            .apply_with_backdrop(c, Some(Color::White)),
1013            c
1014        );
1015        assert_eq!(
1016            ColorTransform::OpacityToward {
1017                factor: 0.0,
1018                target,
1019            }
1020            .apply_with_backdrop(c, Some(Color::White)),
1021            target
1022        );
1023    }
1024
1025    #[test]
1026    fn patch_prefers_overlay_contrast_policy() {
1027        let base = Style::new().contrast_policy(ContrastPolicy::Wcag);
1028        let overlay = Style::new().contrast_policy(ContrastPolicy::Off);
1029
1030        assert_eq!(
1031            base.patch(overlay).contrast_policy,
1032            Some(ContrastPolicy::Off)
1033        );
1034    }
1035
1036    #[test]
1037    fn theme_extensions_roundtrip_and_affect_equality() {
1038        let a = Theme::default().with_extension(BrandTheme {
1039            accent_badge: Color::rgb(1, 2, 3),
1040        });
1041        let b = Theme::default().with_extension(BrandTheme {
1042            accent_badge: Color::rgb(1, 2, 3),
1043        });
1044        let c = Theme::default().with_extension(BrandTheme {
1045            accent_badge: Color::rgb(9, 8, 7),
1046        });
1047
1048        assert_eq!(a.extension::<BrandTheme>(), b.extension::<BrandTheme>());
1049        assert_eq!(a, b);
1050        assert_ne!(a, c);
1051    }
1052}
1053
1054/// Visual shape of the caret/cursor.
1055#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1056pub enum CaretShape {
1057    /// Block cursor (█), usually rendered with reverse video.
1058    #[default]
1059    Block,
1060    /// Bar cursor (│), rendered as a vertical line.
1061    Bar,
1062    /// Underline cursor (_), rendered as an underline.
1063    Underline,
1064}
1065
1066/// Custom glyphs for borders.
1067///
1068/// Mirrors `ratatui::symbols::border::Set`.
1069#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1070pub struct BorderGlyphs {
1071    /// Top left corner.
1072    pub top_left: &'static str,
1073    /// Top horizontal line.
1074    pub top: &'static str,
1075    /// Top right corner.
1076    pub top_right: &'static str,
1077    /// Left vertical line.
1078    pub left: &'static str,
1079    /// Right vertical line.
1080    pub right: &'static str,
1081    /// Bottom left corner.
1082    pub bottom_left: &'static str,
1083    /// Bottom horizontal line.
1084    pub bottom: &'static str,
1085    /// Bottom right corner.
1086    pub bottom_right: &'static str,
1087}
1088
1089impl Default for BorderGlyphs {
1090    fn default() -> Self {
1091        Self::PLAIN
1092    }
1093}
1094
1095impl BorderGlyphs {
1096    /// Standard plain border glyphs.
1097    pub const PLAIN: Self = Self {
1098        top_left: "┌",
1099        top: "─",
1100        top_right: "┐",
1101        left: "│",
1102        right: "│",
1103        bottom_left: "└",
1104        bottom: "─",
1105        bottom_right: "┘",
1106    };
1107
1108    /// Create a new custom border glyph set.
1109    pub fn new(parts: BorderGlyphsParts) -> Self {
1110        Self {
1111            top_left: parts.top_left,
1112            top: parts.top,
1113            top_right: parts.top_right,
1114            left: parts.left,
1115            right: parts.right,
1116            bottom_left: parts.bottom_left,
1117            bottom: parts.bottom,
1118            bottom_right: parts.bottom_right,
1119        }
1120    }
1121}
1122
1123/// Corner and edge glyphs for [`BorderGlyphs::new`].
1124pub struct BorderGlyphsParts {
1125    /// Top left corner.
1126    pub top_left: &'static str,
1127    /// Top horizontal line.
1128    pub top: &'static str,
1129    /// Top right corner.
1130    pub top_right: &'static str,
1131    /// Left vertical line.
1132    pub left: &'static str,
1133    /// Right vertical line.
1134    pub right: &'static str,
1135    /// Bottom left corner.
1136    pub bottom_left: &'static str,
1137    /// Bottom horizontal line.
1138    pub bottom: &'static str,
1139    /// Bottom right corner.
1140    pub bottom_right: &'static str,
1141}
1142
1143/// Border style for widgets.
1144#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1145pub enum BorderStyle {
1146    /// Standard single-line border (─│┌┐└┘).
1147    #[default]
1148    Plain,
1149    /// Rounded corners (─│╭╮╰╯).
1150    Rounded,
1151    /// Double-line border (═║╔╗╚╝).
1152    Double,
1153    /// Thick border.
1154    Thick,
1155    /// Light double-dashed border.
1156    LightDoubleDashed,
1157    /// Heavy double-dashed border.
1158    HeavyDoubleDashed,
1159    /// Light triple-dashed border.
1160    LightTripleDashed,
1161    /// Heavy triple-dashed border.
1162    HeavyTripleDashed,
1163    /// Light quadruple-dashed border.
1164    LightQuadrupleDashed,
1165    /// Heavy quadruple-dashed border.
1166    HeavyQuadrupleDashed,
1167    /// Custom border glyphs.
1168    Custom {
1169        /// The glyph set to use.
1170        glyphs: BorderGlyphs,
1171    },
1172}
1173
1174/// Scrollbar rendering variant.
1175#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1176pub enum ScrollbarVariant {
1177    /// Integrate scrollbar into the right border (lazygit-style).
1178    /// Falls back to `Standalone` when widget has no border.
1179    Integrated,
1180    /// Render scrollbar as a separate column consuming content width.
1181    #[default]
1182    Standalone,
1183}
1184
1185/// Scrollbar appearance (layout variant, gap, thumb, track styles).
1186///
1187/// Visibility is controlled by each widget's `.scrollbar(bool)` /
1188/// `.h_scrollbar(bool)`, not by this struct.
1189///
1190/// Node structs keep flat fields for efficient hot-path access; reconcile
1191/// unpacks `ScrollbarConfig` into individual fields.
1192#[derive(Clone, Debug, Default, PartialEq)]
1193pub struct ScrollbarConfig {
1194    /// Rendering variant (integrated into border or standalone column).
1195    pub variant: ScrollbarVariant,
1196    /// Empty cells reserved between content and a standalone scrollbar.
1197    pub gap: u16,
1198    /// Custom thumb character (default: '█').
1199    pub thumb: Option<char>,
1200    /// Custom thumb style.
1201    pub thumb_style: Option<Style>,
1202    /// Custom thumb style when the widget is focused.
1203    pub thumb_focus_style: Option<Style>,
1204    /// Custom track style.
1205    pub track_style: Option<Style>,
1206}
1207
1208impl ScrollbarConfig {
1209    /// Create a new `ScrollbarConfig` with defaults.
1210    pub fn new() -> Self {
1211        Self::default()
1212    }
1213
1214    /// Set scrollbar rendering variant.
1215    pub fn variant(mut self, variant: ScrollbarVariant) -> Self {
1216        self.variant = variant;
1217        self
1218    }
1219
1220    /// Reserve empty cells before a standalone scrollbar.
1221    pub fn gap(mut self, gap: u16) -> Self {
1222        self.gap = gap;
1223        self
1224    }
1225
1226    /// Set custom thumb character.
1227    pub fn thumb(mut self, ch: char) -> Self {
1228        self.thumb = Some(ch);
1229        self
1230    }
1231
1232    /// Set custom thumb style.
1233    pub fn thumb_style(mut self, style: Style) -> Self {
1234        self.thumb_style = Some(style);
1235        self
1236    }
1237
1238    /// Set custom thumb style when the widget is focused.
1239    pub fn thumb_focus_style(mut self, style: Style) -> Self {
1240        self.thumb_focus_style = Some(style);
1241        self
1242    }
1243
1244    /// Set custom track style.
1245    pub fn track_style(mut self, style: Style) -> Self {
1246        self.track_style = Some(style);
1247        self
1248    }
1249}
1250
1251/// Palette of semantic colors used for file icons.
1252///
1253/// These categories match the semantic grouping used by `mini.icons` in Neovim.
1254#[derive(Clone, Debug, PartialEq)]
1255pub struct FileIconPalette {
1256    /// Light blue (e.g. Markdown, documentation)
1257    pub azure: Color,
1258    /// Standard blue (e.g. Directories, CSS)
1259    pub blue: Color,
1260    /// Bright cyan (e.g. TypeScript, Docker)
1261    pub cyan: Color,
1262    /// Standard green (e.g. Go, Shell scripts)
1263    pub green: Color,
1264    /// Neutral grey (e.g. Lock files, logs)
1265    pub grey: Color,
1266    /// Vibrant orange (e.g. Java, HTML)
1267    pub orange: Color,
1268    /// Rich purple (e.g. C++, images)
1269    pub purple: Color,
1270    /// Standard red (e.g. Rust, Git files)
1271    pub red: Color,
1272    /// Bright yellow (e.g. JavaScript, Python)
1273    pub yellow: Color,
1274}
1275
1276impl Default for FileIconPalette {
1277    fn default() -> Self {
1278        Self {
1279            // One Dark inspired palette (vibrant and elastic)
1280            azure: Color::hex_u24(0x61AFEF),  // Light Blue
1281            blue: Color::hex_u24(0x4175E6),   // Standard Blue
1282            cyan: Color::hex_u24(0x56B6C2),   // Cyan
1283            green: Color::hex_u24(0x98C379),  // Green
1284            grey: Color::hex_u24(0xABB2BF),   // Grey
1285            orange: Color::hex_u24(0xD19A66), // Orange
1286            purple: Color::hex_u24(0xC678DD), // Purple
1287            red: Color::hex_u24(0xE06C75),    // Red
1288            yellow: Color::hex_u24(0xE5C07B), // Yellow
1289        }
1290    }
1291}
1292
1293/// Palette of colors for git status indicators.
1294#[derive(Clone, Copy, Debug, PartialEq)]
1295pub struct GitStatusPalette {
1296    /// Tracked file modified.
1297    pub modified: Color,
1298    /// New tracked file.
1299    pub added: Color,
1300    /// File deleted.
1301    pub deleted: Color,
1302    /// File renamed.
1303    pub renamed: Color,
1304    /// Untracked file.
1305    pub untracked: Color,
1306    /// Merge conflict.
1307    pub conflicted: Color,
1308}
1309
1310impl Default for GitStatusPalette {
1311    fn default() -> Self {
1312        Self {
1313            modified: Color::hex_u24(0xE5B767),
1314            added: Color::hex_u24(0x7EC699),
1315            deleted: Color::hex_u24(0xE57E7E),
1316            renamed: Color::hex_u24(0x76C5E5),
1317            untracked: Color::hex_u24(0xC59AE5),
1318            conflicted: Color::hex_u24(0xE57E7E),
1319        }
1320    }
1321}
1322
1323/// Palette of colors for scrollbars.
1324#[derive(Clone, Copy, Debug, PartialEq)]
1325pub struct ScrollbarPalette {
1326    /// Track color (the background of the scrollbar).
1327    pub track: Option<Color>,
1328    /// Thumb color (the draggable part).
1329    pub thumb: Color,
1330    /// Thumb color when focused.
1331    pub thumb_focus: Option<Color>,
1332}
1333
1334impl Default for ScrollbarPalette {
1335    fn default() -> Self {
1336        Self {
1337            track: None,
1338            thumb: Color::DarkGray,
1339            thumb_focus: Some(Color::Gray),
1340        }
1341    }
1342}
1343
1344/// Palette of colors for splitter interaction states.
1345#[derive(Clone, Copy, Debug, PartialEq)]
1346pub struct SplitterPalette {
1347    /// Handle/seam color when hovered.
1348    pub hover: Color,
1349    /// Handle/seam color while actively dragging.
1350    pub active: Color,
1351}
1352
1353impl Default for SplitterPalette {
1354    fn default() -> Self {
1355        Self {
1356            hover: Color::hex_u24(0x2563EB),
1357            active: Color::hex_u24(0x22D3EE),
1358        }
1359    }
1360}
1361
1362/// Surface colors used for layered UI chrome.
1363#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1364pub struct SurfacePalette {
1365    /// Base panel surface.
1366    pub panel: Color,
1367    /// Nested element/input surface.
1368    pub element: Color,
1369    /// Menu/popover surface.
1370    pub menu: Color,
1371    /// Backdrop/base underlay surface.
1372    pub backdrop: Color,
1373}
1374
1375/// Semantic status colors used across widgets and apps.
1376#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1377pub struct StatusPalette {
1378    /// Success state color.
1379    pub success: Color,
1380    /// Warning state color.
1381    pub warning: Color,
1382    /// Error state color.
1383    pub error: Color,
1384    /// Informational state color.
1385    pub info: Color,
1386}
1387
1388/// Semantic style palette for diff rendering.
1389#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1390pub struct DiffPalette {
1391    /// Style for unchanged/context lines.
1392    pub context: Style,
1393    /// Style for added lines.
1394    pub added: Style,
1395    /// Style for removed lines.
1396    pub removed: Style,
1397    /// Style for filler/empty lines in split diff layouts.
1398    pub empty: Style,
1399    /// Style for added word-level segments.
1400    pub added_word: Style,
1401    /// Style for removed word-level segments.
1402    pub removed_word: Style,
1403    /// Style for the added marker in the prefix gutter.
1404    pub added_marker: Style,
1405    /// Style for the removed marker in the prefix gutter.
1406    pub removed_marker: Style,
1407    /// Style for the line-number segment of unchanged/context lines in the gutter.
1408    pub context_line_number: Style,
1409    /// Style for the line-number segment of added lines in the gutter.
1410    pub added_line_number: Style,
1411    /// Style for the line-number segment of removed lines in the gutter.
1412    pub removed_line_number: Style,
1413    /// Style for context-collapse separator lines.
1414    pub context_separator_style: Style,
1415    /// Style for unified-diff `diff --git …` metadata (and inline file headers in multi-file patches).
1416    pub patch_header: Style,
1417}
1418
1419/// Semantic style palette for formatted documents and markdown.
1420#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1421pub struct DocumentPalette {
1422    /// Heading styles (h1 through h6).
1423    pub heading_styles: [Style; 6],
1424    /// Inline code style.
1425    pub code_inline: Style,
1426    /// Code block style.
1427    pub code_block: Style,
1428    /// Emphasis style.
1429    pub emphasis: Style,
1430    /// Strong style.
1431    pub strong: Style,
1432    /// Strikethrough style.
1433    pub strikethrough: Style,
1434    /// Link style.
1435    pub link: Style,
1436    /// Blockquote bar style.
1437    pub blockquote_bar: Style,
1438    /// Table border style.
1439    pub table_border: Style,
1440    /// Table header style.
1441    pub table_header: Style,
1442    /// Horizontal-rule style.
1443    pub hr: Style,
1444    /// Bullet point style for unordered lists.
1445    pub list_item: Style,
1446    /// Enumeration number style for ordered lists.
1447    pub list_enumeration: Style,
1448    /// Diagram node fill style.
1449    pub diagram_node_fill_style: Style,
1450    /// Diagram node border style.
1451    pub diagram_node_border_style: Style,
1452    /// Diagram node label style.
1453    pub diagram_node_label_style: Style,
1454    /// Diagram edge style.
1455    pub diagram_edge_style: Style,
1456    /// Diagram muted style for auxiliary glyphs (sequence lifelines, etc.).
1457    pub diagram_muted_style: Style,
1458}
1459
1460impl Default for DocumentPalette {
1461    fn default() -> Self {
1462        Self {
1463            heading_styles: [
1464                Style::new().bold().fg(Color::LightBlue),
1465                Style::new().bold().fg(Color::LightBlue),
1466                Style::new().bold().fg(Color::LightBlue),
1467                Style::new().bold(),
1468                Style::new().bold(),
1469                Style::new().bold().dim(),
1470            ],
1471            code_inline: Style::new().fg(Color::Green),
1472            code_block: Style::default(),
1473            emphasis: Style::new().italic(),
1474            strong: Style::new().bold(),
1475            strikethrough: Style::new().strikethrough(),
1476            link: Style::new().fg(Color::LightBlue).underline(),
1477            blockquote_bar: Style::new().fg(Color::DarkGray).dim(),
1478            table_border: Style::new().fg(Color::DarkGray).dim(),
1479            table_header: Style::new().bold(),
1480            hr: Style::new().fg(Color::DarkGray).dim(),
1481            list_item: Style::new().fg(Color::LightBlue).bold(),
1482            list_enumeration: Style::new().fg(Color::LightBlue).bold(),
1483            diagram_node_fill_style: Style::default(),
1484            diagram_node_border_style: Style::default(),
1485            diagram_node_label_style: Style::default(),
1486            diagram_edge_style: Style::default(),
1487            diagram_muted_style: Style::default(),
1488        }
1489    }
1490}
1491
1492/// Semantic style palette for syntax highlighting overlays.
1493#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1494pub struct SyntaxPalette {
1495    /// Style for comments and documentation text.
1496    pub comment: Style,
1497    /// Style for keywords and language control words.
1498    pub keyword: Style,
1499    /// Style for string literals.
1500    pub string: Style,
1501    /// Style for numeric literals.
1502    pub number: Style,
1503    /// Style for named constants, booleans, null-like values, and character literals.
1504    pub constant: Style,
1505    /// Style for function and method identifiers.
1506    pub function: Style,
1507    /// Style for built-in functions, types, classes, and constants.
1508    pub builtin: Style,
1509    /// Style for type names and class-like identifiers.
1510    pub type_name: Style,
1511    /// Style for regular identifiers/variables.
1512    pub variable: Style,
1513    /// Style for function parameters and argument-like bindings.
1514    pub parameter: Style,
1515    /// Style for operators and punctuation-like emphasis.
1516    pub operator: Style,
1517}
1518
1519impl Default for SyntaxPalette {
1520    fn default() -> Self {
1521        let number = Style::new().fg(Color::Yellow);
1522        let function = Style::new().fg(Color::Cyan);
1523        let variable = Style::new().fg(Color::White);
1524
1525        Self {
1526            comment: Style::new().fg(Color::DarkGray).italic().dim(),
1527            keyword: Style::new().fg(Color::LightMagenta),
1528            string: Style::new().fg(Color::Green),
1529            number,
1530            constant: number.lighten_by(0.12),
1531            function,
1532            builtin: function.italic(),
1533            type_name: Style::new().fg(Color::LightBlue),
1534            variable,
1535            parameter: variable.italic(),
1536            operator: Style::new().fg(Color::LightRed),
1537        }
1538    }
1539}
1540
1541/// Semantic interaction palette for single-line text inputs.
1542#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1543pub struct InputPalette {
1544    /// Style applied to focused input content when the theme opts in.
1545    ///
1546    /// This is intentionally empty by default so focused inputs keep their base
1547    /// text color unless the theme author explicitly requests otherwise.
1548    pub focus: Style,
1549}
1550
1551/// Semantic interaction palette for multi-line text editors.
1552#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1553pub struct TextAreaPalette {
1554    /// Style applied to focused text-area content when the theme opts in.
1555    ///
1556    /// This is intentionally empty by default so focused editors keep their
1557    /// base text color unless the theme author explicitly requests otherwise.
1558    pub focus: Style,
1559}
1560
1561/// Semantic interaction palette for read-only document surfaces.
1562#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1563pub struct DocumentViewPalette {
1564    /// Style applied to focused document content when the theme opts in.
1565    ///
1566    /// This is intentionally empty by default so focused documents keep their
1567    /// base text color unless the theme author explicitly requests otherwise.
1568    pub focus: Style,
1569}
1570
1571/// Semantic interaction palette for hex editors/viewers.
1572#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1573pub struct HexAreaPalette {
1574    /// Style applied to focused hex content when the theme opts in.
1575    pub focus: Style,
1576    /// Style applied to the active hex cursor/caret.
1577    pub cursor: Style,
1578}
1579
1580/// Semantic interaction palette for terminal surfaces.
1581#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1582pub struct TerminalPalette {
1583    /// Style applied to focused terminal content when the theme opts in.
1584    ///
1585    /// This is intentionally empty by default so focused terminals keep their
1586    /// base text color unless the theme author explicitly requests otherwise.
1587    pub focus: Style,
1588}
1589
1590/// Theme palette for common widget defaults.
1591#[derive(Clone, Debug, PartialEq)]
1592pub struct Theme {
1593    /// Primary style (e.g. text color).
1594    pub primary: Style,
1595    /// Style for interactive emphasis.
1596    ///
1597    /// Used for control hover, cursors, active glyphs, and other
1598    /// foreground-only emphasis that should not imply selection ownership.
1599    pub accent: Style,
1600    /// Style for selected/current items.
1601    pub selection: Style,
1602    /// Style for selected text or byte ranges.
1603    pub text_selection: Style,
1604    /// Style for focused widget chrome and focus affordances.
1605    ///
1606    /// Resolved at render time by widgets whose focus slot inherits or extends
1607    /// the theme role. Keep this empty to suppress theme-provided focus visuals
1608    /// while still allowing explicit widget-level `focus_style(...)` overrides.
1609    pub focus: Style,
1610    /// Style for hovered items.
1611    pub hover: Style,
1612    /// Style for borders and frames.
1613    ///
1614    /// When set, frame borders and dividers use this `fg` instead of
1615    /// `primary.fg`, letting you dim borders independently of text.
1616    pub border: Style,
1617    /// Style for muted/secondary content.
1618    ///
1619    /// Applied to placeholders, disabled widgets, line numbers, scroll
1620    /// indicators, and empty-state text.
1621    pub muted: Style,
1622    /// Derived layered UI surfaces.
1623    pub surface: SurfacePalette,
1624    /// Semantic status colors.
1625    pub status: StatusPalette,
1626    /// Active/emphasized border color.
1627    pub border_active: Color,
1628    /// Color palette for file icons.
1629    pub file_icons: FileIconPalette,
1630    /// Colors for git status.
1631    pub git_status: GitStatusPalette,
1632    /// Semantic styles for diff views.
1633    pub diff: DiffPalette,
1634    /// Semantic styles for formatted documents and markdown.
1635    pub document: DocumentPalette,
1636    /// Semantic styles for syntax token recoloring.
1637    pub syntax: SyntaxPalette,
1638    /// Semantic interaction styles for single-line inputs.
1639    pub input: InputPalette,
1640    /// Semantic interaction styles for multi-line text editors.
1641    pub text_area: TextAreaPalette,
1642    /// Semantic interaction styles for read-only document surfaces.
1643    pub document_view: DocumentViewPalette,
1644    /// Semantic interaction styles for hex editors/viewers.
1645    pub hex_area: HexAreaPalette,
1646    /// Semantic interaction styles for terminal surfaces.
1647    pub terminal: TerminalPalette,
1648    /// Colors for scrollbars.
1649    pub scrollbar: ScrollbarPalette,
1650    /// Colors for splitter interaction states.
1651    pub splitter: SplitterPalette,
1652    /// Typed app-specific theme data stored alongside the framework palettes.
1653    #[doc(hidden)]
1654    pub extensions: ThemeExtensions,
1655}
1656
1657impl Theme {
1658    /// Build a deterministic theme from probed host terminal colors.
1659    pub fn from_host_colors(colors: HostTerminalColors) -> Self {
1660        ThemePalette::new(colors.fg, colors.bg, colors.ansi[4])
1661            .success(colors.ansi[2])
1662            .warning(colors.ansi[3])
1663            .error(colors.ansi[1])
1664            .info(colors.ansi[4])
1665            .into_theme()
1666    }
1667
1668    /// Return the style associated with a semantic theme role.
1669    pub fn role(&self, role: ThemeRole) -> Style {
1670        match role {
1671            ThemeRole::Base => self.primary,
1672            ThemeRole::Accent => {
1673                let mut style = self.accent;
1674                if style.fg.is_none() {
1675                    style.fg = self.primary.fg;
1676                }
1677                if style.fg_transform.is_none() {
1678                    style.fg_transform = self.primary.fg_transform;
1679                }
1680                style
1681            }
1682            ThemeRole::Selection | ThemeRole::UnfocusedSelection => self.selection,
1683            ThemeRole::TextSelection => self.text_selection,
1684            ThemeRole::Hover
1685            | ThemeRole::DragSource
1686            | ThemeRole::DropTarget
1687            | ThemeRole::DropTargetActive
1688            | ThemeRole::ItemHover => self.hover,
1689            ThemeRole::Focus => self.focus,
1690            ThemeRole::Active => self.selection,
1691            ThemeRole::Border => self.primary.patch(self.border),
1692            ThemeRole::Disabled | ThemeRole::Muted => self.primary.patch(self.muted),
1693            ThemeRole::Error => Style::new().fg(self.status.error),
1694            ThemeRole::InputFocusContent => self.input.focus,
1695            ThemeRole::TextAreaFocusContent => self.text_area.focus,
1696            ThemeRole::DocumentViewFocusContent => self.document_view.focus,
1697            ThemeRole::HexAreaFocusContent => self.hex_area.focus,
1698            ThemeRole::HexAreaCursor => self.hex_area.cursor,
1699            ThemeRole::TerminalFocusContent => self.terminal.focus,
1700            ThemeRole::ScrollbarThumb => Style::new().bg(self.scrollbar.thumb),
1701            ThemeRole::ScrollbarThumbFocus => self
1702                .scrollbar
1703                .thumb_focus
1704                .map(|color| Style::new().bg(color))
1705                .unwrap_or_default(),
1706            ThemeRole::ScrollbarTrack => self
1707                .scrollbar
1708                .track
1709                .map(|color| Style::new().bg(color))
1710                .unwrap_or_default(),
1711            ThemeRole::SplitterHover => Style::new().fg(self.splitter.hover),
1712            ThemeRole::SplitterActive => Style::new().fg(self.splitter.active),
1713        }
1714    }
1715
1716    /// Create a theme from the three core colors most apps care about.
1717    ///
1718    /// - `primary_fg`: default text/foreground color
1719    /// - `primary_bg`: base surface/background color
1720    /// - `accent`: interactive accent used for control emphasis, selection,
1721    ///   text selection, splitters, and focused scrollbar thumbs
1722    pub fn custom(primary_fg: Color, primary_bg: Color, accent: Color) -> Self {
1723        let success = Color::Green;
1724        let warning = Color::Yellow;
1725        let error = Color::Red;
1726        let info = accent;
1727        let muted = primary_fg.blend_toward(primary_bg, 0.42);
1728        let border_active = accent.lighten_by(0.08);
1729        Self {
1730            primary: Style::new().fg(primary_fg).bg(primary_bg),
1731            accent: Style::new().fg(accent),
1732            selection: Style::new()
1733                .fg(accent)
1734                .bg(primary_bg.blend_toward(accent, 0.22)),
1735            text_selection: Style::new()
1736                .fg(accent)
1737                .bg(primary_bg.blend_toward(accent, 0.22)),
1738            focus: Style::new().fg(border_active),
1739            hover: Style::default(),
1740            border: Style::new().fg(primary_fg.blend_toward(primary_bg, 0.40)),
1741            muted: Style::new().fg(muted),
1742            surface: SurfacePalette {
1743                panel: primary_bg.elevate(0.07),
1744                element: primary_bg.elevate(0.04),
1745                menu: primary_bg.elevate(0.12),
1746                backdrop: primary_bg,
1747            },
1748            status: StatusPalette {
1749                success,
1750                warning,
1751                error,
1752                info,
1753            },
1754            border_active,
1755            file_icons: FileIconPalette::default(),
1756            git_status: GitStatusPalette::default(),
1757            diff: DiffPalette {
1758                context: Style::default(),
1759                added: Style::new().bg(primary_bg.blend_toward(success, 0.14)),
1760                removed: Style::new().bg(primary_bg.blend_toward(error, 0.16)),
1761                empty: Style::new().dim(),
1762                added_word: Style::new().bg(primary_bg.blend_toward(success, 0.24)),
1763                removed_word: Style::new().bg(primary_bg.blend_toward(error, 0.28)),
1764                added_marker: Style::new().fg(success),
1765                removed_marker: Style::new().fg(error),
1766                context_line_number: Style::new().fg(primary_fg.blend_toward(primary_bg, 0.50)),
1767                added_line_number: Style::default(),
1768                removed_line_number: Style::default(),
1769                context_separator_style: Style::new()
1770                    .fg(primary_fg.blend_toward(primary_bg, 0.40))
1771                    .dim(),
1772                patch_header: Style::new()
1773                    .fg(accent.blend_toward(primary_fg, 0.35))
1774                    .bold(),
1775            },
1776            document: DocumentPalette {
1777                heading_styles: [
1778                    Style::new().bold().fg(accent.lighten_by(0.20)),
1779                    Style::new().bold().fg(accent.lighten_by(0.12)),
1780                    Style::new().bold().fg(accent),
1781                    Style::new().bold().fg(primary_fg),
1782                    Style::new().bold().fg(primary_fg),
1783                    Style::new().bold().fg(primary_fg).dim(),
1784                ],
1785                code_inline: Style::new().fg(success),
1786                code_block: Style::default(),
1787                emphasis: Style::new().italic(),
1788                strong: Style::new().bold(),
1789                strikethrough: Style::new().strikethrough(),
1790                link: Style::new().fg(accent).underline(),
1791                blockquote_bar: Style::new().fg(muted).dim(),
1792                table_border: Style::new()
1793                    .fg(primary_fg.blend_toward(primary_bg, 0.40))
1794                    .dim(),
1795                table_header: Style::new().bold(),
1796                hr: Style::new()
1797                    .fg(primary_fg.blend_toward(primary_bg, 0.40))
1798                    .dim(),
1799                list_item: Style::new().fg(accent).bold(),
1800                list_enumeration: Style::new().fg(accent).bold(),
1801                diagram_node_fill_style: Style::new().bg(primary_bg.blend_toward(accent, 0.10)),
1802                diagram_node_border_style: Style::new().fg(accent.lighten_by(0.08)),
1803                diagram_node_label_style: Style::new().fg(primary_fg),
1804                diagram_edge_style: Style::new().fg(accent.blend_toward(primary_fg, 0.20)),
1805                diagram_muted_style: Style::new().fg(muted).dim(),
1806            },
1807            syntax: SyntaxPalette {
1808                comment: Style::new().fg(muted).italic().dim(),
1809                keyword: Style::new().fg(accent),
1810                string: Style::new().fg(accent.blend_toward(success, 0.55)),
1811                number: Style::new().fg(accent.blend_toward(Color::Yellow, 0.60)),
1812                constant: Style::new()
1813                    .fg(accent.blend_toward(Color::Yellow, 0.52).lighten_by(0.10)),
1814                function: Style::new().fg(info.blend_toward(accent, 0.12)),
1815                builtin: Style::new().fg(info.blend_toward(accent, 0.28)).italic(),
1816                type_name: Style::new().fg(accent.blend_toward(info, 0.32)),
1817                variable: Style::new().fg(primary_fg),
1818                parameter: Style::new()
1819                    .fg(primary_fg.blend_toward(accent, 0.12))
1820                    .italic(),
1821                operator: Style::new().fg(accent.blend_toward(error, 0.45)),
1822            },
1823            input: InputPalette::default(),
1824            text_area: TextAreaPalette::default(),
1825            document_view: DocumentViewPalette::default(),
1826            hex_area: HexAreaPalette {
1827                focus: Style::default(),
1828                cursor: Style::new().fg(accent),
1829            },
1830            terminal: TerminalPalette::default(),
1831            scrollbar: ScrollbarPalette {
1832                track: Some(primary_bg.elevate(0.05)),
1833                thumb: primary_bg.elevate(0.20),
1834                thumb_focus: Some(accent.lighten_by(0.08)),
1835            },
1836            splitter: SplitterPalette {
1837                hover: accent.lighten_by(0.08),
1838                active: accent.lighten_by(0.18),
1839            },
1840            extensions: ThemeExtensions::default(),
1841        }
1842    }
1843
1844    /// Set the primary text/foreground style.
1845    ///
1846    /// This is the base color for labels, text, and borders across all widgets.
1847    /// Start from a preset or `Theme::default()` and override only what you need:
1848    ///
1849    /// ```rust
1850    /// use tui_lipan::{Theme, Style, Color};
1851    ///
1852    /// let theme = Theme::default()
1853    ///     .primary(Style::new().fg(Color::hex_u24(0xE0E0E0)))
1854    ///     .selection(Style::new().fg(Color::hex_u24(0xFF8000)));
1855    /// ```
1856    pub fn primary(mut self, style: Style) -> Self {
1857        self.primary = style;
1858        self
1859    }
1860
1861    /// Set the interactive accent style.
1862    ///
1863    /// Used for button hover, cursors, matches, active glyphs, and other
1864    /// non-selection emphasis. Unlike [`Theme::selection`], this should usually
1865    /// avoid painting a selection background.
1866    pub fn accent(mut self, style: Style) -> Self {
1867        self.accent = style;
1868        self
1869    }
1870
1871    /// Set the focused-widget chrome style.
1872    ///
1873    /// `ThemeProvider` applies this to widget `focus_style` defaults. Keep it
1874    /// empty when you want the theme itself to stay visually quiet on focus
1875    /// while still allowing widgets to opt into explicit focus styles.
1876    pub fn focus(mut self, style: Style) -> Self {
1877        self.focus = style;
1878        self
1879    }
1880
1881    /// Attach typed app-specific theme data to this theme.
1882    ///
1883    /// This keeps app semantic tokens inside the framework theme tree so app
1884    /// code can read them through `Context::theme_extension::<T>()` while still
1885    /// relying on `ThemeProvider` as the single source of truth.
1886    pub fn with_extension<T>(mut self, extension: T) -> Self
1887    where
1888        T: ThemeExtension,
1889    {
1890        self.extensions.insert(extension);
1891        self
1892    }
1893
1894    /// Remove a previously attached typed theme extension.
1895    pub fn without_extension<T>(mut self) -> Self
1896    where
1897        T: ThemeExtension,
1898    {
1899        self.extensions.remove::<T>();
1900        self
1901    }
1902
1903    /// Return a typed app-specific theme extension if present.
1904    pub fn extension<T>(&self) -> Option<&T>
1905    where
1906        T: ThemeExtension,
1907    {
1908        self.extensions.get::<T>()
1909    }
1910
1911    /// Return a cloned typed app-specific theme extension if present.
1912    pub fn extension_cloned<T>(&self) -> Option<T>
1913    where
1914        T: ThemeExtension,
1915    {
1916        self.extension::<T>().cloned()
1917    }
1918
1919    /// Set the selected/current item style.
1920    ///
1921    /// Used for selected/current items in lists, tables, trees, and active tabs.
1922    /// Text/range selections use [`Theme::text_selection`] instead.
1923    pub fn selection(mut self, style: Style) -> Self {
1924        self.selection = style;
1925        self
1926    }
1927
1928    /// Set the text/range selection style.
1929    ///
1930    /// Used for selected ranges in `Input`, `TextArea`, `DocumentView`,
1931    /// `Terminal`, and `HexArea`.
1932    pub fn text_selection(mut self, style: Style) -> Self {
1933        self.text_selection = style;
1934        self
1935    }
1936
1937    /// Set the hover style.
1938    ///
1939    /// Hover is disabled by default for non-button widgets. Set this when you
1940    /// want row/surface hover feedback in lists, tables, trees, inputs, and
1941    /// similar widgets.
1942    pub fn hover(mut self, style: Style) -> Self {
1943        self.hover = style;
1944        self
1945    }
1946
1947    /// Set the border/frame style.
1948    ///
1949    /// Controls the foreground color used for frame borders and dividers.
1950    /// When set, borders are decoupled from the primary text color.
1951    pub fn border(mut self, style: Style) -> Self {
1952        self.border = style;
1953        self
1954    }
1955
1956    /// Set the muted/secondary style.
1957    ///
1958    /// Used for placeholders, disabled widgets, line numbers, scroll
1959    /// indicators, and empty-state text.
1960    pub fn muted(mut self, style: Style) -> Self {
1961        self.muted = style;
1962        self
1963    }
1964
1965    /// Set the scrollbar color palette.
1966    pub fn scrollbar(mut self, palette: ScrollbarPalette) -> Self {
1967        self.scrollbar = palette;
1968        self
1969    }
1970
1971    /// Set the splitter color palette.
1972    pub fn splitter(mut self, palette: SplitterPalette) -> Self {
1973        self.splitter = palette;
1974        self
1975    }
1976
1977    /// Set the file icon color palette.
1978    pub fn file_icons(mut self, palette: FileIconPalette) -> Self {
1979        self.file_icons = palette;
1980        self
1981    }
1982
1983    /// Set the git status color palette.
1984    pub fn git_status(mut self, palette: GitStatusPalette) -> Self {
1985        self.git_status = palette;
1986        self
1987    }
1988
1989    /// Set the semantic diff style palette.
1990    pub fn diff(mut self, palette: DiffPalette) -> Self {
1991        self.diff = palette;
1992        self
1993    }
1994
1995    /// Set the semantic document/markdown style palette.
1996    pub fn document(mut self, palette: DocumentPalette) -> Self {
1997        self.document = palette;
1998        self
1999    }
2000
2001    /// Set the semantic syntax highlighting palette.
2002    pub fn syntax(mut self, palette: SyntaxPalette) -> Self {
2003        self.syntax = palette;
2004        self
2005    }
2006
2007    /// Set the semantic interaction palette for single-line inputs.
2008    pub fn input(mut self, palette: InputPalette) -> Self {
2009        self.input = palette;
2010        self
2011    }
2012
2013    /// Set the semantic interaction palette for multi-line text editors.
2014    pub fn text_area(mut self, palette: TextAreaPalette) -> Self {
2015        self.text_area = palette;
2016        self
2017    }
2018
2019    /// Set the semantic interaction palette for read-only document surfaces.
2020    pub fn document_view(mut self, palette: DocumentViewPalette) -> Self {
2021        self.document_view = palette;
2022        self
2023    }
2024
2025    /// Set the semantic interaction palette for hex editors/viewers.
2026    pub fn hex_area(mut self, palette: HexAreaPalette) -> Self {
2027        self.hex_area = palette;
2028        self
2029    }
2030
2031    /// Set the semantic interaction palette for terminal surfaces.
2032    pub fn terminal(mut self, palette: TerminalPalette) -> Self {
2033        self.terminal = palette;
2034        self
2035    }
2036}
2037
2038/// A minimal color palette that derives a complete [`Theme`].
2039///
2040/// Set 3 required colors (text, background, accent) and optionally override
2041/// a few more. Everything else - accent, selection, text selection, border, muted, scrollbar,
2042/// splitter, toast, diff, document, syntax, text-surface interaction, file-icon,
2043/// and git-status palettes - is derived automatically so every widget in the
2044/// tree shares a coherent look. Focus chrome is derived separately from the
2045/// accent token so apps can mute or restyle focus without affecting hover,
2046/// cursors, or other accent-driven states. Generic hover is intentionally
2047/// disabled by default and can be opted into later with [`Theme::hover`].
2048///
2049/// # Quick start
2050///
2051/// ```rust
2052/// use tui_lipan::{ThemePalette, Color};
2053///
2054/// // Three colors → full theme
2055/// let theme = ThemePalette::new(
2056///     Color::hex_u24(0xCDD6F4),  // text
2057///     Color::hex_u24(0x1E1E2E),  // background
2058///     Color::hex_u24(0xCBA6F7),  // accent
2059/// ).into_theme();
2060///
2061/// // Override just what you need
2062/// let theme = ThemePalette::new(
2063///     Color::hex_u24(0xCDD6F4),
2064///     Color::hex_u24(0x1E1E2E),
2065///     Color::hex_u24(0xCBA6F7),
2066/// )
2067/// .border(Color::hex_u24(0x585B70))
2068/// .selection(Color::hex_u24(0xCBA6F7))
2069/// .text_selection(Color::hex_u24(0x89B4FA))
2070/// .success(Color::hex_u24(0xA6E3A1))
2071/// .error(Color::hex_u24(0xF38BA8))
2072/// .into_theme();
2073/// ```
2074#[derive(Clone, Debug)]
2075pub struct ThemePalette {
2076    /// Main text/foreground color. Applied to all text and labels.
2077    pub text: Color,
2078    /// Primary background color.
2079    pub background: Color,
2080    /// Accent color used to derive interactive emphasis and default selection styles.
2081    pub accent: Color,
2082    /// Color used to derive selected/current item styles. Default: accent.
2083    pub selection: Option<Color>,
2084    /// Color used to derive text/range selection styles. Default: accent.
2085    pub text_selection: Option<Color>,
2086    /// Border/frame color. Default: text blended 40 % toward background.
2087    pub border: Option<Color>,
2088    /// Muted/secondary text color (placeholders, disabled, line numbers).
2089    /// Default: text blended 50 % toward background.
2090    pub muted: Option<Color>,
2091    /// Scrollbar thumb color. Default: background lightened 16 %.
2092    pub scrollbar: Option<Color>,
2093    /// Success/green semantic color. Default: `#34D399`.
2094    pub success: Option<Color>,
2095    /// Warning/yellow semantic color. Default: `#FBBF24`.
2096    pub warning: Option<Color>,
2097    /// Error/red semantic color. Default: `#F43F5E`.
2098    pub error: Option<Color>,
2099    /// Info/blue semantic color. Default: accent.
2100    pub info: Option<Color>,
2101}
2102
2103impl ThemePalette {
2104    /// Create a palette from the three essential colors.
2105    pub fn new(text: Color, background: Color, accent: Color) -> Self {
2106        Self {
2107            text,
2108            background,
2109            accent,
2110            selection: None,
2111            text_selection: None,
2112            border: None,
2113            muted: None,
2114            scrollbar: None,
2115            success: None,
2116            warning: None,
2117            error: None,
2118            info: None,
2119        }
2120    }
2121
2122    /// Override the selected/current item color.
2123    pub fn selection(mut self, color: Color) -> Self {
2124        self.selection = Some(color);
2125        self
2126    }
2127
2128    /// Override the text/range selection color.
2129    pub fn text_selection(mut self, color: Color) -> Self {
2130        self.text_selection = Some(color);
2131        self
2132    }
2133
2134    /// Override the border/frame color.
2135    pub fn border(mut self, color: Color) -> Self {
2136        self.border = Some(color);
2137        self
2138    }
2139
2140    /// Override the muted/secondary text color.
2141    pub fn muted(mut self, color: Color) -> Self {
2142        self.muted = Some(color);
2143        self
2144    }
2145
2146    /// Override the scrollbar thumb color.
2147    pub fn scrollbar(mut self, color: Color) -> Self {
2148        self.scrollbar = Some(color);
2149        self
2150    }
2151
2152    /// Override the success semantic color.
2153    pub fn success(mut self, color: Color) -> Self {
2154        self.success = Some(color);
2155        self
2156    }
2157
2158    /// Override the warning semantic color.
2159    pub fn warning(mut self, color: Color) -> Self {
2160        self.warning = Some(color);
2161        self
2162    }
2163
2164    /// Override the error semantic color.
2165    pub fn error(mut self, color: Color) -> Self {
2166        self.error = Some(color);
2167        self
2168    }
2169
2170    /// Override the info semantic color.
2171    pub fn info(mut self, color: Color) -> Self {
2172        self.info = Some(color);
2173        self
2174    }
2175
2176    /// Convert this palette into a fully-derived [`Theme`].
2177    pub fn into_theme(self) -> Theme {
2178        Theme::from(self)
2179    }
2180}
2181
2182impl From<ThemePalette> for Theme {
2183    fn from(p: ThemePalette) -> Self {
2184        let border_color = p
2185            .border
2186            .unwrap_or_else(|| p.text.blend_toward(p.background, 0.40));
2187        let muted_color = p
2188            .muted
2189            .unwrap_or_else(|| p.text.blend_toward(p.background, 0.42));
2190        let scrollbar_thumb = p.scrollbar.unwrap_or_else(|| p.background.elevate(0.20));
2191
2192        let success = p.success.unwrap_or(Color::hex_u24(0x34D399));
2193        let warning = p.warning.unwrap_or(Color::hex_u24(0xFBBF24));
2194        let error = p.error.unwrap_or(Color::hex_u24(0xF43F5E));
2195        let info = p.info.unwrap_or(p.accent);
2196        let border_active = p.border.unwrap_or(p.accent).lighten_by(0.08);
2197        let selection = p.selection.unwrap_or(p.accent);
2198        let text_selection = p.text_selection.unwrap_or(p.accent);
2199
2200        Theme {
2201            primary: Style::new().fg(p.text).bg(p.background),
2202            accent: Style::new().fg(p.accent),
2203            selection: Style::new()
2204                .fg(selection)
2205                .bg(p.background.blend_toward(selection, 0.22)),
2206            text_selection: Style::new()
2207                .fg(text_selection)
2208                .bg(p.background.blend_toward(text_selection, 0.22)),
2209            focus: Style::new().fg(border_active),
2210            hover: Style::default(),
2211            border: Style::new().fg(border_color),
2212            muted: Style::new().fg(muted_color),
2213            surface: SurfacePalette {
2214                panel: p.background.elevate(0.07),
2215                element: p.background.elevate(0.04),
2216                menu: p.background.elevate(0.12),
2217                backdrop: p.background,
2218            },
2219            status: StatusPalette {
2220                success,
2221                warning,
2222                error,
2223                info,
2224            },
2225            border_active,
2226            file_icons: FileIconPalette {
2227                green: success,
2228                red: error,
2229                yellow: warning,
2230                azure: info,
2231                blue: p.accent,
2232                cyan: info.lighten_by(0.10),
2233                grey: muted_color,
2234                orange: warning.blend_toward(error, 0.40),
2235                purple: p.accent.blend_toward(error, 0.30),
2236            },
2237            git_status: GitStatusPalette {
2238                modified: warning,
2239                added: success,
2240                deleted: error,
2241                renamed: info,
2242                untracked: p.accent.blend_toward(error, 0.30),
2243                conflicted: error,
2244            },
2245            diff: DiffPalette {
2246                context: Style::default(),
2247                added: Style::new().bg(p.background.blend_toward(success, 0.14)),
2248                removed: Style::new().bg(p.background.blend_toward(error, 0.16)),
2249                empty: Style::new().dim(),
2250                added_word: Style::new().bg(p.background.blend_toward(success, 0.24)),
2251                removed_word: Style::new().bg(p.background.blend_toward(error, 0.28)),
2252                added_marker: Style::new().fg(success),
2253                removed_marker: Style::new().fg(error),
2254                context_line_number: Style::new().fg(p.text.blend_toward(p.background, 0.50)),
2255                added_line_number: Style::default(),
2256                removed_line_number: Style::default(),
2257                context_separator_style: Style::new()
2258                    .fg(p.text.blend_toward(p.background, 0.40))
2259                    .dim(),
2260                patch_header: Style::new().fg(p.accent.blend_toward(p.text, 0.25)).bold(),
2261            },
2262            document: DocumentPalette {
2263                heading_styles: [
2264                    Style::new().bold().fg(p.accent.lighten_by(0.20)),
2265                    Style::new().bold().fg(p.accent.lighten_by(0.12)),
2266                    Style::new().bold().fg(p.accent),
2267                    Style::new().bold().fg(p.text),
2268                    Style::new().bold().fg(p.text),
2269                    Style::new().bold().fg(p.text).dim(),
2270                ],
2271                code_inline: Style::new().fg(success),
2272                code_block: Style::default(),
2273                emphasis: Style::new().italic(),
2274                strong: Style::new().bold(),
2275                strikethrough: Style::new().strikethrough(),
2276                link: Style::new().fg(p.accent).underline(),
2277                blockquote_bar: Style::new().fg(muted_color).dim(),
2278                table_border: Style::new().fg(border_color).dim(),
2279                table_header: Style::new().bold(),
2280                hr: Style::new().fg(border_color).dim(),
2281                list_item: Style::new().fg(p.accent).bold(),
2282                list_enumeration: Style::new().fg(p.accent).bold(),
2283                diagram_node_fill_style: Style::new().bg(p.background.blend_toward(p.accent, 0.10)),
2284                diagram_node_border_style: Style::new().fg(p.accent.lighten_by(0.08)),
2285                diagram_node_label_style: Style::new().fg(p.text),
2286                diagram_edge_style: Style::new().fg(p.accent.blend_toward(p.text, 0.20)),
2287                diagram_muted_style: Style::new().fg(muted_color).dim(),
2288            },
2289            syntax: SyntaxPalette {
2290                comment: Style::new().fg(muted_color).italic().dim(),
2291                keyword: Style::new().fg(p.accent),
2292                string: Style::new().fg(success.blend_toward(p.accent, 0.15)),
2293                number: Style::new().fg(warning.blend_toward(p.accent, 0.20)),
2294                constant: Style::new().fg(warning.blend_toward(p.text, 0.18)),
2295                function: Style::new().fg(info.blend_toward(p.accent, 0.10)),
2296                builtin: Style::new()
2297                    .fg(info.blend_toward(muted_color, 0.22))
2298                    .italic(),
2299                type_name: Style::new().fg(p.accent.blend_toward(info, 0.35)),
2300                variable: Style::new().fg(p.text),
2301                parameter: Style::new().fg(p.text).italic(),
2302                operator: Style::new().fg(error.blend_toward(p.accent, 0.45)),
2303            },
2304            input: InputPalette::default(),
2305            text_area: TextAreaPalette::default(),
2306            document_view: DocumentViewPalette::default(),
2307            hex_area: HexAreaPalette {
2308                focus: Style::default(),
2309                cursor: Style::new().fg(p.accent),
2310            },
2311            terminal: TerminalPalette::default(),
2312            scrollbar: ScrollbarPalette {
2313                track: Some(p.background.elevate(0.05)),
2314                thumb: scrollbar_thumb,
2315                thumb_focus: Some(p.accent.lighten_by(0.08)),
2316            },
2317            splitter: SplitterPalette {
2318                hover: p.accent.lighten_by(0.08),
2319                active: p.accent.lighten_by(0.18),
2320            },
2321            extensions: ThemeExtensions::default(),
2322        }
2323    }
2324}
2325
2326impl Default for Theme {
2327    fn default() -> Self {
2328        let mut theme: Self = ThemePalette::new(
2329            Color::hex_u24(0xE2E8F0),
2330            Color::hex_u24(0x0B121F),
2331            Color::hex_u24(0x7DCFFF),
2332        )
2333        .success(Color::hex_u24(0x34D399))
2334        .warning(Color::hex_u24(0xFBBF24))
2335        .error(Color::hex_u24(0xF43F5E))
2336        .info(Color::hex_u24(0x38BDF8))
2337        .into();
2338
2339        theme.file_icons = FileIconPalette {
2340            azure: Color::hex_u24(0x7DCFFF),
2341            blue: Color::hex_u24(0x60A5FA),
2342            cyan: Color::hex_u24(0x2DD4BF),
2343            green: Color::hex_u24(0x4ADE80),
2344            grey: Color::hex_u24(0x94A3B8),
2345            orange: Color::hex_u24(0xFB923C),
2346            purple: Color::hex_u24(0xC4B5FD),
2347            red: Color::hex_u24(0xF87171),
2348            yellow: Color::hex_u24(0xFBBF24),
2349        };
2350        theme.git_status = GitStatusPalette {
2351            modified: Color::hex_u24(0xFBBF24),
2352            added: Color::hex_u24(0x34D399),
2353            deleted: Color::hex_u24(0xFB7171),
2354            renamed: Color::hex_u24(0x38BDF8),
2355            untracked: Color::hex_u24(0xA78BFA),
2356            conflicted: Color::hex_u24(0xF43F5E),
2357        };
2358
2359        theme
2360    }
2361}