Skip to main content

omp_tui/
context.rs

1//! Presentation context: glyph charset and color theme.
2//!
3//! Widgets never hardcode glyphs or colors — they consult the [`UiContext`]
4//! carried by [`crate::Ui`]. Agents author semantic tokens (`accent`,
5//! `warn`, …) and structural markup; the context decides what a border,
6//! cursor, or `warn` actually looks like on this terminal.
7
8use crate::{color::SystemColor, component::Elements, frame::Color, markup::Border};
9/// Terminal policy for Hangul Compatibility Jamo (`U+3131..=U+318E`).
10#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
11pub enum JamoWidth {
12	/// Follow the platform default: narrow on macOS, Unicode tables elsewhere.
13	#[default]
14	Platform,
15	/// Use the Unicode width table without a terminal-specific correction.
16	Unicode,
17	/// Force visible Compatibility Jamo to one cell.
18	Narrow,
19	/// Force visible Compatibility Jamo to two cells.
20	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/// Glyph capability tier, mirroring the `unicode | nerd | ascii` symbol
34/// presets in the coding agent.
35#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
36pub enum Charset {
37	/// Full Unicode box drawing, geometric shapes, half blocks.
38	#[default]
39	Unicode,
40	/// Unicode plus Nerd Font private-use glyphs where they read better.
41	NerdFont,
42	/// Pure 7-bit ASCII: every terminal, every font, every era.
43	Ascii,
44}
45
46/// Table-grid glyph set resolved by [`Charset::grid`]: border rows as
47/// `(left, junction, right)` triples plus the row-interior separators.
48#[derive(Clone, Copy)]
49pub struct Grid {
50	/// Horizontal fill between junctions.
51	pub fill:   char,
52	/// Left edge of a content row.
53	pub lead:   &'static str,
54	/// Between-cells separator.
55	pub mid:    &'static str,
56	/// Right edge of a content row.
57	pub tail:   &'static str,
58	/// Top border row glyphs.
59	pub top:    (char, char, char),
60	/// Separator row glyphs.
61	pub middle: (char, char, char),
62	/// Bottom border row glyphs.
63	pub bottom: (char, char, char),
64}
65
66impl Charset {
67	/// Resolves a semantic icon through this terminal's capability tier.
68	pub const fn icon(self, icon: crate::Icon) -> &'static str {
69		icon.glyph(self)
70	}
71
72	/// Resolves a short icon name or qualified compatibility alias.
73	pub fn icon_named(self, name: &str) -> Option<&'static str> {
74		crate::Icon::from_name(name).map(|icon| self.icon(icon))
75	}
76
77	/// Border glyph set for a box: `(tl, tr, bl, br, horizontal, vertical)`.
78	/// Public so raw-frame hosts painting their own chrome share the
79	/// widget tier policy instead of hardcoding box drawing.
80	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	/// Focus cursor prefix, two cells wide.
94	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	/// Radio mark for `(selected)`.
103	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	/// Checkbox mark for `(checked)`.
115	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	/// Tree expander for `(has_children, open)`.
127	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	/// Tree guide glyphs for a connector family: `(branch, last, continue)`.
137	///
138	/// Each is two cells wide; ASCII terminals collapse every family to the
139	/// same 7-bit set.
140	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	/// Horizontal rule / divider fill character.
154	pub(crate) const fn rule(self) -> char {
155		match self {
156			Self::Ascii => '-',
157			_ => '─',
158		}
159	}
160
161	/// A rule fill honoring this tier: non-ASCII requests (box-drawing,
162	/// em-dashes) degrade to the plain [`Charset::rule`] character on
163	/// ASCII terminals; ASCII requests pass through everywhere.
164	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	/// Blockquote rail prefix.
173	pub(crate) const fn quote_rail(self) -> &'static str {
174		match self {
175			Self::Ascii => "| ",
176			_ => "│ ",
177		}
178	}
179
180	/// Grid chrome for cell-bordered tables: the square border strokes
181	/// plus the tees and cross that [`Charset::border`] alone cannot
182	/// provide.
183	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	/// Scrollbar `(track, thumb)`.
207	pub const fn scrollbar(self) -> (&'static str, &'static str) {
208		match self {
209			Self::Ascii => ("|", "#"),
210			_ => ("│", "█"),
211		}
212	}
213
214	/// Progress bar `(filled, empty)`.
215	pub(crate) const fn progress(self) -> (&'static str, &'static str) {
216		match self {
217			Self::Ascii => ("#", "."),
218			_ => ("█", "░"),
219		}
220	}
221
222	/// Pill chip caps `(left, right)`; empty in ASCII (flat chips).
223	pub(crate) const fn pill_caps(self) -> (&'static str, &'static str) {
224		match self {
225			Self::Ascii => ("", ""),
226			_ => ("▐", "▌"),
227		}
228	}
229
230	/// Left rail glyph for editors and `<note>` callouts.
231	pub const fn rail(self) -> &'static str {
232		match self {
233			Self::Ascii => "| ",
234			_ => "▎ ",
235		}
236	}
237
238	/// Status-band chrome: `(left cap, segment separator, right cap)`.
239	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	/// Right-docked status-band chrome, [`Charset::status_band`] mirrored:
248	/// the opening cap points left into the surrounding background and the
249	/// closing edge ends flat, solid against the right margin.
250	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	/// Lift-shadow glyph under risen chrome; `None` skips the shadow —
259	/// ASCII has no half blocks worth faking with punctuation.
260	pub(crate) const fn shadow(self) -> Option<&'static str> {
261		match self {
262			Self::Ascii => None,
263			_ => Some("▀"),
264		}
265	}
266
267	/// Spinner animation frames for this tier.
268	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	/// Text cursor beam shown in inline edit modes.
276	pub(crate) const fn beam(self) -> &'static str {
277		match self {
278			Self::Ascii => "_",
279			_ => "▏",
280		}
281	}
282
283	/// Success / chosen mark.
284	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	/// `<note>` header icon.
293	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	/// Enum-cycle affordance `(left, right)` arrows.
302	pub(crate) const fn arrows(self) -> (&'static str, &'static str) {
303		match self {
304			Self::Ascii => ("<", ">"),
305			_ => ("◂", "▸"),
306		}
307	}
308
309	/// Dropdown-opens-here affordance.
310	pub(crate) const fn dropdown(self) -> &'static str {
311		match self {
312			Self::Ascii => " v",
313			_ => " ▾",
314		}
315	}
316}
317
318/// Terminal-reported background appearance.
319#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
320pub enum Appearance {
321	/// A background whose BT.601 luminance is below 0.5.
322	#[default]
323	Dark,
324	/// A background whose BT.601 luminance is at least 0.5.
325	Light,
326}
327
328impl Appearance {
329	/// Classifies 16-bit RGB components using BT.601 luminance.
330	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	/// Classifies 8-bit RGB components using BT.601 luminance.
340	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/// Semantic color palette. Agents pick meanings; the theme picks colors —
346/// no widget hardcodes an RGB value.
347#[derive(Clone, Copy, Debug, Eq, PartialEq)]
348pub struct Theme {
349	/// Default foreground.
350	pub fg:       Color,
351	/// Primary interactive accent (focus, selection, links).
352	pub accent:   Color,
353	/// Informational values.
354	pub info:     Color,
355	/// Success / enabled.
356	pub ok:       Color,
357	/// Caution / modified.
358	pub warn:     Color,
359	/// Errors / destructive.
360	pub err:      Color,
361	/// De-emphasized chrome and hints.
362	pub muted:    Color,
363	/// Container borders and rules; dimmer than `fg`, brighter than `surface`.
364	pub border:   Color,
365	/// Neutral chip / button fill.
366	pub surface:  Color,
367	/// Hover row tint.
368	pub hover:    Color,
369	/// Drop-shadow tint painted under lifted (elevated) surfaces.
370	pub shadow:   Color,
371	/// Text painted on top of accent/warn fills.
372	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	/// Returns the semantic palette for a terminal background appearance.
396	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	/// Resolves a semantic token name (`accent`, `warn`, …) or a CSS
430	/// system color keyword (`Canvas`, `LinkText`, …) to its color.
431	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	/// Whether `name` resolves via [`Self::token`] on every theme.
450	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/// Terminal image rendering capability.
468#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
469pub enum Graphics {
470	/// Render images as colored half-block text cells.
471	#[default]
472	Cells,
473	/// Render registered images with the DEC sixel protocol.
474	Sixel,
475	/// Render registered images with cursor-positioned Kitty placements.
476	KittyDirect,
477	/// Render registered images with Kitty Unicode placeholders.
478	KittyPlaceholders,
479	/// Render registered images with the iTerm2 inline-image protocol.
480	Iterm2,
481}
482
483/// Presentation context threaded through parse, layout, and paint.
484#[derive(Clone, Debug)]
485pub struct UiContext {
486	/// Terminal-reported dark or light background appearance.
487	pub appearance: Appearance,
488	/// Glyph capability tier.
489	pub charset:    Charset,
490	/// Terminal image rendering capability.
491	pub graphics:   Graphics,
492	/// Hangul Compatibility Jamo width policy.
493	///
494	/// Prefer [`UiContext::set_jamo_width`] over direct assignment: the method
495	/// also updates the process-wide hot-path setting and invalidates width
496	/// caches.
497	pub jamo_width: JamoWidth,
498	/// Semantic color palette.
499	pub theme:      Theme,
500	/// Custom element registry.
501	pub elements:   Elements,
502	/// Presentation clock of the pass in flight: [`crate::Ui::tick`] advances
503	/// it so size transitions can be sampled during layout, where no
504	/// [`crate::PaintCtx`] exists. Excluded from equality — a moving clock
505	/// must never read as a context change.
506	pub now:        std::time::Duration,
507	/// Cache-invalidation revision, advanced by [`crate::Ui::set_context`]
508	/// when a differing context is applied. Geometry and render memos fold
509	/// it into their keys so output derived from the previous context is
510	/// discarded. Excluded from equality, like the clock.
511	pub revision:   u64,
512	/// Off-thread image decoder. `None` decodes inline during layout for
513	/// deterministic tests and bare synchronous hosts. [`crate::App`] installs
514	/// one before building the [`crate::Ui`].
515	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	/// Applies a Hangul Compatibility Jamo policy process-wide.
536	///
537	/// Returns whether the effective configuration changed. Width-derived
538	/// caches observe that change through [`crate::rich::width_config_epoch`].
539	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	/// Applies the detected terminal's capabilities: graphics tier, glyph
545	/// charset, Compatibility Jamo policy, and background appearance.
546	///
547	/// Capability values are `0` for platform default, `1` for narrow, and `2`
548	/// for wide.
549	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	/// Returns this context configured for the detected terminal.
566	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}