1use crate::{color::SystemColor, component::Elements, frame::Color, markup::Border};
9#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
11pub enum JamoWidth {
12 #[default]
14 Platform,
15 Unicode,
17 Narrow,
19 Wide,
21}
22
23impl JamoWidth {
24 const fn from_caps(value: u8) -> Self {
25 match value {
26 1 => Self::Narrow,
27 2 => Self::Wide,
28 _ => Self::Platform,
29 }
30 }
31}
32
33#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
36pub enum Charset {
37 #[default]
39 Unicode,
40 NerdFont,
42 Ascii,
44}
45
46#[derive(Clone, Copy)]
49pub struct Grid {
50 pub fill: char,
52 pub lead: &'static str,
54 pub mid: &'static str,
56 pub tail: &'static str,
58 pub top: (char, char, char),
60 pub middle: (char, char, char),
62 pub bottom: (char, char, char),
64}
65
66impl Charset {
67 pub const fn icon(self, icon: crate::Icon) -> &'static str {
69 icon.glyph(self)
70 }
71
72 pub fn icon_named(self, name: &str) -> Option<&'static str> {
74 crate::Icon::from_name(name).map(|icon| self.icon(icon))
75 }
76
77 pub const fn border(self, border: Border) -> (char, char, char, char, char, char) {
81 match self {
82 Self::Ascii => ('+', '+', '+', '+', '-', '|'),
83 _ => match border {
84 Border::Square => ('┌', '┐', '└', '┘', '─', '│'),
85 Border::Dash => ('┌', '┐', '└', '┘', '╌', '┆'),
86 Border::Round => ('╭', '╮', '╰', '╯', '─', '│'),
87 Border::Heavy => ('┏', '┓', '┗', '┛', '━', '┃'),
88 Border::Double => ('╔', '╗', '╚', '╝', '═', '║'),
89 },
90 }
91 }
92
93 pub const fn cursor(self) -> &'static str {
95 match self {
96 Self::Unicode => "❯ ",
97 Self::NerdFont => "\u{f054} ",
98 Self::Ascii => "> ",
99 }
100 }
101
102 pub const fn radio(self, selected: bool) -> &'static str {
104 match (self, selected) {
105 (Self::Ascii, true) => "(o)",
106 (Self::Ascii, false) => "( )",
107 (Self::NerdFont, true) => "\u{f192}",
108 (Self::NerdFont, false) => "\u{f10c}",
109 (_, true) => "◉",
110 (_, false) => "○",
111 }
112 }
113
114 pub(crate) const fn checkbox(self, checked: bool) -> &'static str {
116 match (self, checked) {
117 (Self::Ascii, true) => "[x]",
118 (Self::Ascii, false) => "[ ]",
119 (Self::NerdFont, true) => "\u{f14a}",
120 (Self::NerdFont, false) => "\u{f096}",
121 (_, true) => "☑",
122 (_, false) => "☐",
123 }
124 }
125
126 pub(crate) const fn expander(self, open: bool) -> &'static str {
128 match (self, open) {
129 (Self::Ascii, true) => "v ",
130 (Self::Ascii, false) => "> ",
131 (_, true) => "▾ ",
132 (_, false) => "▸ ",
133 }
134 }
135
136 pub(crate) const fn guides(self, family: Border) -> (&'static str, &'static str, &'static str) {
141 match self {
142 Self::Ascii => ("|-", "`-", "| "),
143 _ => match family {
144 Border::Square => ("├─", "└─", "│ "),
145 Border::Dash => ("├╌", "└╌", "┆ "),
146 Border::Round => ("├─", "╰─", "│ "),
147 Border::Heavy => ("┣━", "┗━", "┃ "),
148 Border::Double => ("╠═", "╚═", "║ "),
149 },
150 }
151 }
152
153 pub(crate) const fn rule(self) -> char {
155 match self {
156 Self::Ascii => '-',
157 _ => '─',
158 }
159 }
160
161 pub(crate) const fn rule_fill(self, requested: char) -> char {
165 if matches!(self, Self::Ascii) && !requested.is_ascii() {
166 self.rule()
167 } else {
168 requested
169 }
170 }
171
172 pub(crate) const fn quote_rail(self) -> &'static str {
174 match self {
175 Self::Ascii => "| ",
176 _ => "│ ",
177 }
178 }
179
180 pub const fn grid(self) -> Grid {
184 match self {
185 Self::Ascii => Grid {
186 fill: '-',
187 lead: "| ",
188 mid: " | ",
189 tail: " |",
190 top: ('+', '+', '+'),
191 middle: ('+', '+', '+'),
192 bottom: ('+', '+', '+'),
193 },
194 _ => Grid {
195 fill: '─',
196 lead: "│ ",
197 mid: " │ ",
198 tail: " │",
199 top: ('┌', '┬', '┐'),
200 middle: ('├', '┼', '┤'),
201 bottom: ('└', '┴', '┘'),
202 },
203 }
204 }
205
206 pub const fn scrollbar(self) -> (&'static str, &'static str) {
208 match self {
209 Self::Ascii => ("|", "#"),
210 _ => ("│", "█"),
211 }
212 }
213
214 pub(crate) const fn progress(self) -> (&'static str, &'static str) {
216 match self {
217 Self::Ascii => ("#", "."),
218 _ => ("█", "░"),
219 }
220 }
221
222 pub(crate) const fn pill_caps(self) -> (&'static str, &'static str) {
224 match self {
225 Self::Ascii => ("", ""),
226 _ => ("▐", "▌"),
227 }
228 }
229
230 pub const fn rail(self) -> &'static str {
232 match self {
233 Self::Ascii => "| ",
234 _ => "▎ ",
235 }
236 }
237
238 pub(crate) const fn status_band(self) -> (&'static str, &'static str, &'static str) {
240 match self {
241 Self::Ascii => ("", ">", ">"),
242 Self::Unicode => ("", "›", "›"),
243 Self::NerdFont => ("\u{e0b6}", "\u{e0b1}", "\u{e0b0}"),
244 }
245 }
246
247 pub(crate) const fn status_band_end(self) -> (&'static str, &'static str, &'static str) {
251 match self {
252 Self::Ascii => ("<", ">", ""),
253 Self::Unicode => ("‹", "›", ""),
254 Self::NerdFont => ("\u{e0b2}", "\u{e0b1}", ""),
255 }
256 }
257
258 pub(crate) const fn shadow(self) -> Option<&'static str> {
261 match self {
262 Self::Ascii => None,
263 _ => Some("▀"),
264 }
265 }
266
267 pub const fn spinner(self) -> crate::anim::Frames {
269 match self {
270 Self::Ascii => crate::anim::Frames::SPINNER_ASCII,
271 _ => crate::anim::Frames::SPINNER,
272 }
273 }
274
275 pub(crate) const fn beam(self) -> &'static str {
277 match self {
278 Self::Ascii => "_",
279 _ => "▏",
280 }
281 }
282
283 pub const fn check(self) -> &'static str {
285 match self {
286 Self::Ascii => "*",
287 Self::NerdFont => "\u{f00c}",
288 Self::Unicode => "✓",
289 }
290 }
291
292 pub(crate) const fn note_icon(self) -> &'static str {
294 match self {
295 Self::Ascii => "[i]",
296 Self::NerdFont => "\u{f05a}",
297 Self::Unicode => "ℹ",
298 }
299 }
300
301 pub(crate) const fn arrows(self) -> (&'static str, &'static str) {
303 match self {
304 Self::Ascii => ("<", ">"),
305 _ => ("◂", "▸"),
306 }
307 }
308
309 pub(crate) const fn dropdown(self) -> &'static str {
311 match self {
312 Self::Ascii => " v",
313 _ => " ▾",
314 }
315 }
316}
317
318#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
320pub enum Appearance {
321 #[default]
323 Dark,
324 Light,
326}
327
328impl Appearance {
329 pub const fn from_rgb16(red: u16, green: u16, blue: u16) -> Self {
331 let weighted = 299 * red as u64 + 587 * green as u64 + 114 * blue as u64;
332 if weighted < 500 * u16::MAX as u64 {
333 Self::Dark
334 } else {
335 Self::Light
336 }
337 }
338
339 pub const fn from_rgb8(red: u8, green: u8, blue: u8) -> Self {
341 Self::from_rgb16((red as u16) * 0x101, (green as u16) * 0x101, (blue as u16) * 0x101)
342 }
343}
344
345#[derive(Clone, Copy, Debug, Eq, PartialEq)]
348pub struct Theme {
349 pub fg: Color,
351 pub accent: Color,
353 pub info: Color,
355 pub ok: Color,
357 pub warn: Color,
359 pub err: Color,
361 pub muted: Color,
363 pub border: Color,
365 pub surface: Color,
367 pub hover: Color,
369 pub shadow: Color,
371 pub contrast: Color,
373}
374
375impl Default for Theme {
376 fn default() -> Self {
377 Self {
378 fg: Color::Rgb(0xc8, 0xcc, 0xd4),
379 accent: Color::Rgb(0x61, 0xaf, 0xef),
380 info: Color::Rgb(0x56, 0xb6, 0xc2),
381 ok: Color::Rgb(0x98, 0xc3, 0x79),
382 warn: Color::Rgb(0xe5, 0xc0, 0x7b),
383 err: Color::Rgb(0xe0, 0x6c, 0x75),
384 muted: Color::Rgb(0x5c, 0x63, 0x70),
385 border: Color::Rgb(0x45, 0x4b, 0x58),
386 surface: Color::Rgb(0x3a, 0x3f, 0x4b),
387 hover: Color::Rgb(0x2c, 0x31, 0x3a),
388 shadow: Color::Rgb(0x05, 0x07, 0x0c),
389 contrast: Color::Rgb(0x10, 0x12, 0x16),
390 }
391 }
392}
393
394impl Theme {
395 pub const fn for_appearance(appearance: Appearance) -> Self {
397 match appearance {
398 Appearance::Dark => Self {
399 fg: Color::Rgb(0xc8, 0xcc, 0xd4),
400 accent: Color::Rgb(0x61, 0xaf, 0xef),
401 info: Color::Rgb(0x56, 0xb6, 0xc2),
402 ok: Color::Rgb(0x98, 0xc3, 0x79),
403 warn: Color::Rgb(0xe5, 0xc0, 0x7b),
404 err: Color::Rgb(0xe0, 0x6c, 0x75),
405 muted: Color::Rgb(0x5c, 0x63, 0x70),
406 border: Color::Rgb(0x45, 0x4b, 0x58),
407 surface: Color::Rgb(0x3a, 0x3f, 0x4b),
408 hover: Color::Rgb(0x2c, 0x31, 0x3a),
409 shadow: Color::Rgb(0x05, 0x07, 0x0c),
410 contrast: Color::Rgb(0x10, 0x12, 0x16),
411 },
412 Appearance::Light => Self {
413 fg: Color::Rgb(0x24, 0x28, 0x30),
414 accent: Color::Rgb(0x00, 0x5f, 0xaf),
415 info: Color::Rgb(0x00, 0x72, 0x7d),
416 ok: Color::Rgb(0x3f, 0x70, 0x19),
417 warn: Color::Rgb(0x8a, 0x5a, 0x00),
418 err: Color::Rgb(0xb0, 0x24, 0x32),
419 muted: Color::Rgb(0x6b, 0x70, 0x78),
420 border: Color::Rgb(0xd0, 0xd7, 0xde),
421 surface: Color::Rgb(0xe2, 0xe5, 0xea),
422 hover: Color::Rgb(0xed, 0xef, 0xf2),
423 shadow: Color::Rgb(0xb8, 0xbd, 0xc7),
424 contrast: Color::Rgb(0xff, 0xff, 0xff),
425 },
426 }
427 }
428
429 pub(crate) fn token(&self, name: &str) -> Option<Color> {
432 Some(match name {
433 "fg" => self.fg,
434 "accent" => self.accent,
435 "info" => self.info,
436 "ok" => self.ok,
437 "warn" => self.warn,
438 "err" => self.err,
439 "muted" => self.muted,
440 "border" => self.border,
441 "surface" => self.surface,
442 "hover" => self.hover,
443 "shadow" => self.shadow,
444 "contrast" => self.contrast,
445 _ => return SystemColor::parse(name).map(|system| system.resolve(self)),
446 })
447 }
448
449 pub(crate) fn is_token(name: &str) -> bool {
451 matches!(
452 name,
453 "fg"
454 | "accent"
455 | "info" | "ok"
456 | "warn" | "err"
457 | "muted"
458 | "border"
459 | "surface"
460 | "hover"
461 | "shadow"
462 | "contrast"
463 ) || SystemColor::parse(name).is_some()
464 }
465}
466
467#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
469pub enum Graphics {
470 #[default]
472 Cells,
473 Sixel,
475 KittyDirect,
477 KittyPlaceholders,
479 Iterm2,
481}
482
483#[derive(Clone, Debug)]
485pub struct UiContext {
486 pub appearance: Appearance,
488 pub charset: Charset,
490 pub graphics: Graphics,
492 pub jamo_width: JamoWidth,
498 pub theme: Theme,
500 pub elements: Elements,
502 pub now: std::time::Duration,
507 pub revision: u64,
512 pub loader: Option<crate::ImageLoader>,
516}
517
518impl Default for UiContext {
519 fn default() -> Self {
520 Self {
521 appearance: Appearance::default(),
522 charset: Charset::default(),
523 graphics: Graphics::default(),
524 jamo_width: crate::rich::jamo_width(),
525 theme: Theme::default(),
526 elements: Elements::default(),
527 now: std::time::Duration::default(),
528 revision: 0,
529 loader: None,
530 }
531 }
532}
533
534impl UiContext {
535 pub fn set_jamo_width(&mut self, width: JamoWidth) -> bool {
540 self.jamo_width = width;
541 crate::rich::set_jamo_width(width)
542 }
543
544 pub fn apply_terminal_caps(&mut self, caps: &crate::TerminalCaps) -> bool {
550 self.graphics = caps.graphics;
551 let mut changed = self.charset != caps.charset;
552 self.charset = caps.charset;
553 changed |= self.set_jamo_width(JamoWidth::from_caps(caps.jamo_width));
554 if let Some((red, green, blue)) = caps.background {
555 let appearance = Appearance::from_rgb16(red, green, blue);
556 if appearance != self.appearance {
557 self.appearance = appearance;
558 self.theme = Theme::for_appearance(appearance);
559 changed = true;
560 }
561 }
562 changed
563 }
564
565 pub fn with_terminal_caps(mut self, caps: &crate::TerminalCaps) -> Self {
567 self.apply_terminal_caps(caps);
568 self
569 }
570}
571
572impl PartialEq for UiContext {
573 fn eq(&self, other: &Self) -> bool {
574 self.charset == other.charset
575 && self.appearance == other.appearance
576 && self.graphics == other.graphics
577 && self.jamo_width == other.jamo_width
578 && self.theme == other.theme
579 && self.elements.ptr_eq(&other.elements)
580 }
581}
582
583impl Eq for UiContext {}
584
585#[cfg(test)]
586mod tests {
587 use super::{Appearance, Theme};
588
589 #[test]
590 fn bt601_classifies_boundary_colors_at_both_component_depths() {
591 assert_eq!(Appearance::from_rgb8(0, 0, 0), Appearance::Dark);
592 assert_eq!(Appearance::from_rgb8(255, 255, 255), Appearance::Light);
593 assert_eq!(Appearance::from_rgb8(127, 127, 127), Appearance::Dark);
594 assert_eq!(Appearance::from_rgb8(128, 128, 128), Appearance::Light);
595 assert_eq!(Appearance::from_rgb16(0, 0, 0), Appearance::Dark);
596 assert_eq!(Appearance::from_rgb16(u16::MAX, u16::MAX, u16::MAX), Appearance::Light);
597 assert_eq!(Appearance::from_rgb16(0x7fff, 0x7fff, 0x7fff), Appearance::Dark);
598 assert_eq!(Appearance::from_rgb16(0x8000, 0x8000, 0x8000), Appearance::Light);
599 }
600
601 #[test]
602 fn appearance_palettes_are_distinct_and_cover_every_token() {
603 let dark = Theme::for_appearance(Appearance::Dark);
604 let light = Theme::for_appearance(Appearance::Light);
605 assert_ne!(dark, light);
606 for token in [
607 "fg", "accent", "info", "ok", "warn", "err", "muted", "surface", "hover", "shadow",
608 "contrast",
609 ] {
610 assert!(dark.token(token).is_some(), "dark palette misses {token}");
611 assert!(light.token(token).is_some(), "light palette misses {token}");
612 }
613 }
614}