tui_lipan/app/context.rs
1#[cfg(not(target_arch = "wasm32"))]
2use crate::app::runner::AppRunner;
3use crate::clipboard::{ClipboardConfig, ClipboardError, ClipboardProvider, ClipboardReporter};
4#[cfg(not(target_arch = "wasm32"))]
5use crate::core::component::Component;
6use crate::overlay::ToastPlacement;
7use crate::style::Padding;
8use crate::style::{Color, Paint, Style, Theme};
9use std::path::PathBuf;
10
11/// How the app occupies terminal space.
12#[derive(Clone, Debug, Default, PartialEq, Eq)]
13pub(crate) enum ViewportMode {
14 /// Take over the full terminal using the alternate screen.
15 #[default]
16 Fullscreen,
17 /// Render inline at the current cursor position with a fixed viewport height.
18 Inline { height: u16 },
19}
20
21/// Startup behavior for transcript inline mode.
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23pub enum InlineStartupPolicy {
24 /// Preserve host terminal content above the inline viewport.
25 #[default]
26 PreserveHost,
27 /// Clear the host terminal before the first inline render.
28 ClearHost,
29}
30
31/// Public surface mode taxonomy for app rendering.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub enum SurfaceMode {
34 /// Take over the full terminal using the alternate screen.
35 #[default]
36 Fullscreen,
37 /// Inline viewport intended for ephemeral UI sessions.
38 InlineEphemeral {
39 /// Requested inline viewport height.
40 height: u16,
41 },
42 /// Inline viewport intended for transcript-friendly sessions.
43 InlineTranscript {
44 /// Requested inline viewport height.
45 height: u16,
46 /// Startup behavior for the host terminal.
47 startup: InlineStartupPolicy,
48 },
49}
50
51impl SurfaceMode {
52 pub(crate) fn is_inline(&self) -> bool {
53 !matches!(self, Self::Fullscreen)
54 }
55
56 pub(crate) fn normalized(self) -> Self {
57 match self {
58 Self::Fullscreen => Self::Fullscreen,
59 Self::InlineEphemeral { height } => Self::InlineEphemeral {
60 height: height.max(1),
61 },
62 Self::InlineTranscript { height, startup } => Self::InlineTranscript {
63 height: height.max(1),
64 startup,
65 },
66 }
67 }
68
69 pub(crate) fn viewport_mode(self) -> ViewportMode {
70 match self.normalized() {
71 Self::Fullscreen => ViewportMode::Fullscreen,
72 Self::InlineEphemeral { height } | Self::InlineTranscript { height, .. } => {
73 ViewportMode::Inline { height }
74 }
75 }
76 }
77
78 pub(crate) fn clear_on_start(self) -> bool {
79 matches!(
80 self,
81 Self::InlineTranscript {
82 startup: InlineStartupPolicy::ClearHost,
83 ..
84 }
85 )
86 }
87}
88
89/// Controls which Enter key combinations insert new lines in `TextArea`.
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
91pub enum TextAreaNewlineBinding {
92 /// Use plain Enter.
93 #[default]
94 Enter,
95 /// Use Shift+Enter only.
96 ShiftEnter,
97 /// Accept both Enter and Shift+Enter.
98 EnterOrShiftEnter,
99}
100
101/// Controls automatic foreground contrast adjustments for widget text.
102#[cfg_attr(
103 feature = "terminal-serde",
104 derive(serde::Serialize, serde::Deserialize)
105)]
106#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
107pub enum ContrastPolicy {
108 /// Keep user-provided foreground colors unchanged.
109 Off,
110 /// Auto-adjust foreground on colored backgrounds when contrast is too low
111 /// using WCAG 2.1 contrast ratio (AA normal text: >= 4.5:1).
112 #[default]
113 Wcag,
114 /// Keep the current foreground when it is readable under WCAG 2.1;
115 /// otherwise snap to black or white, whichever has higher contrast.
116 BlackOrWhite,
117 /// Auto-adjust using APCA perceptual contrast (WCAG 3.0 draft).
118 ///
119 /// Better for dark themes and polarity-aware readability. Uses `|Lc|` >= 60
120 /// as the minimum body-text threshold.
121 Apca,
122}
123
124#[cfg(all(test, feature = "terminal-serde"))]
125mod terminal_serde_tests {
126 use super::*;
127
128 #[test]
129 fn contrast_policy_round_trips() {
130 let policy = ContrastPolicy::BlackOrWhite;
131 let json = serde_json::to_string(&policy).unwrap();
132 assert_eq!(
133 serde_json::from_str::<ContrastPolicy>(&json).unwrap(),
134 policy
135 );
136 }
137}
138
139/// Runtime devtools subsystem configuration.
140#[cfg(feature = "devtools")]
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub struct DevToolsConfig {
143 /// Enable devtools log ingestion and sink wiring.
144 pub logs: bool,
145 /// Enable runtime frame metrics collection.
146 pub metrics: bool,
147 /// Show tui-lipan's own framework-internal logs in the log view.
148 ///
149 /// Set to `false` so the devtools log view starts with framework noise
150 /// (key events, dirty tracking, etc.) hidden, showing only the host
151 /// application's own `debug_log!` lines. Can still be toggled at runtime
152 /// from the "tui-lipan" button in the Logs tab.
153 pub show_framework_logs: bool,
154}
155
156#[cfg(feature = "devtools")]
157impl Default for DevToolsConfig {
158 fn default() -> Self {
159 Self {
160 logs: true,
161 metrics: true,
162 show_framework_logs: true,
163 }
164 }
165}
166
167/// How the root viewport background is painted before the UI tree renders.
168///
169/// By default the framework paints nothing behind the tree, so the host
170/// terminal background shows through ([`Transparent`](Self::Transparent)). Opt
171/// into a filled background when you want a fully "designed" surface rather than
172/// text floating on the terminal color — useful for kiosk-style apps, themes
173/// with a strong identity, or matching a brand backdrop.
174///
175/// ```no_run
176/// use tui_lipan::prelude::*;
177///
178/// // Fill with the active theme's backdrop surface:
179/// let app = App::new().theme(Theme::lipan()).fill_background();
180///
181/// // Or an explicit color:
182/// let app = App::new().screen_background(Color::hex_u24(0x04090D));
183/// ```
184#[derive(Clone, Copy, Debug, Default, PartialEq)]
185pub enum ScreenBackground {
186 /// Leave the host terminal background untouched. (default)
187 #[default]
188 Transparent,
189 /// Fill the viewport with the active root theme's backdrop surface
190 /// ([`Theme::surface`]`.backdrop`).
191 ///
192 /// This tracks the theme of the realized root node, so apps that swap themes
193 /// at runtime via a root `ThemeProvider` (rather than rebuilding the `App`)
194 /// keep the backdrop in sync without any extra wiring.
195 Theme,
196 /// Fill the viewport with an explicit style (typically just a background color).
197 Custom(Style),
198}
199
200impl ScreenBackground {
201 /// Resolve to the concrete fill style for `theme`, or `None` when nothing
202 /// should be painted.
203 pub(crate) fn resolve(self, theme: &Theme) -> Option<Style> {
204 match self {
205 Self::Transparent => None,
206 Self::Theme => Some(Style::new().bg(theme.surface.backdrop)),
207 Self::Custom(style) => (!style.is_empty()).then_some(style),
208 }
209 }
210}
211
212impl From<Style> for ScreenBackground {
213 fn from(style: Style) -> Self {
214 Self::Custom(style)
215 }
216}
217
218impl From<Color> for ScreenBackground {
219 fn from(color: Color) -> Self {
220 Self::Custom(Style::new().bg(color))
221 }
222}
223
224impl From<Paint> for ScreenBackground {
225 fn from(paint: Paint) -> Self {
226 Self::Custom(Style::new().bg(paint))
227 }
228}
229
230/// Application builder.
231pub struct App {
232 pub(crate) title: Option<String>,
233 pub(crate) surface_mode: SurfaceMode,
234 pub(crate) mouse_enabled: Option<bool>,
235 pub(crate) scroll_wheel_multiplier: u16,
236 pub(crate) theme: Theme,
237 pub(crate) toast_placement: ToastPlacement,
238 pub(crate) toast_gap: u16,
239 pub(crate) toast_margin: Padding,
240 pub(crate) clipboard_config: ClipboardConfig,
241 pub(crate) keymap_path: Option<PathBuf>,
242 pub(crate) text_area_newline_binding: TextAreaNewlineBinding,
243 pub(crate) contrast_policy: ContrastPolicy,
244 pub(crate) clipboard_provider: Option<Box<dyn ClipboardProvider>>,
245 pub(crate) clipboard_reporter: ClipboardReporter,
246 pub(crate) terminal_bg: Option<Color>,
247 pub(crate) live_host_terminal_colors: bool,
248 pub(crate) system_theme: bool,
249 pub(crate) screen_background: ScreenBackground,
250 #[cfg(feature = "devtools")]
251 pub(crate) devtools_config: DevToolsConfig,
252}
253
254impl Default for App {
255 fn default() -> Self {
256 let theme = Theme::default();
257 Self {
258 title: None,
259 surface_mode: SurfaceMode::default(),
260 mouse_enabled: None,
261 scroll_wheel_multiplier: 1,
262 theme,
263 toast_placement: ToastPlacement::default(),
264 toast_gap: 1,
265 toast_margin: Padding::BORDER,
266 clipboard_config: ClipboardConfig::default(),
267 keymap_path: None,
268 text_area_newline_binding: TextAreaNewlineBinding::default(),
269 contrast_policy: ContrastPolicy::default(),
270 clipboard_provider: None,
271 clipboard_reporter: crate::clipboard::default_clipboard_reporter(),
272 terminal_bg: None,
273 live_host_terminal_colors: false,
274 system_theme: false,
275 screen_background: ScreenBackground::default(),
276 #[cfg(feature = "devtools")]
277 devtools_config: DevToolsConfig::default(),
278 }
279 }
280}
281
282impl App {
283 /// Create a new app.
284 pub fn new() -> Self {
285 Self::default()
286 }
287
288 /// Set the terminal window title (via OSC 2 escape sequence).
289 pub fn title(mut self, title: impl Into<String>) -> Self {
290 self.title = Some(title.into());
291 self
292 }
293
294 /// Set the app surface mode explicitly.
295 pub fn surface(mut self, mode: SurfaceMode) -> Self {
296 self.surface_mode = mode.normalized();
297 self
298 }
299
300 /// Use fullscreen alternate-screen rendering.
301 pub fn fullscreen(self) -> Self {
302 self.surface(SurfaceMode::Fullscreen)
303 }
304
305 /// Render the app inline for ephemeral (non-transcript) sessions.
306 ///
307 /// Height is clamped to at least one row.
308 pub fn inline_ephemeral(self, height: u16) -> Self {
309 self.surface(SurfaceMode::InlineEphemeral { height })
310 }
311
312 /// Render the app inline for transcript sessions.
313 ///
314 /// Defaults to preserving host terminal content on startup.
315 pub fn inline_transcript(self, height: u16) -> Self {
316 self.surface(SurfaceMode::InlineTranscript {
317 height,
318 startup: InlineStartupPolicy::PreserveHost,
319 })
320 }
321
322 /// Render the app inline for transcript sessions with explicit startup behavior.
323 ///
324 /// Height is clamped to at least one row.
325 pub fn inline_transcript_with_startup(self, height: u16, startup: InlineStartupPolicy) -> Self {
326 self.surface(SurfaceMode::InlineTranscript { height, startup })
327 }
328
329 /// Configure mouse capture behavior.
330 ///
331 /// This sets the initial runtime state. Components can later change it with
332 /// `Context::set_mouse_capture(...)` or `Context::toggle_mouse_capture()`.
333 ///
334 /// Defaults:
335 /// - fullscreen mode: enabled
336 /// - inline mode: disabled
337 pub fn mouse(mut self, enabled: bool) -> Self {
338 self.mouse_enabled = Some(enabled);
339 self
340 }
341
342 /// Set the app-wide mouse wheel step multiplier.
343 ///
344 /// Each wheel tick scrolls `multiplier` lines instead of the default single
345 /// line. Coalesced wheel bursts multiply by this value too, so two ticks with
346 /// `multiplier = 3` scroll six lines total.
347 pub fn scroll_wheel_multiplier(mut self, multiplier: u16) -> Self {
348 self.scroll_wheel_multiplier = multiplier.max(1);
349 self
350 }
351
352 /// Set the app-wide default theme.
353 ///
354 /// This theme is applied to the root tree every render.
355 /// Use `ThemeProvider` to override a specific subtree.
356 pub fn theme(mut self, theme: Theme) -> Self {
357 self.theme = theme;
358 self
359 }
360
361 /// Paint the root viewport background before rendering the UI tree.
362 ///
363 /// By default the framework paints nothing behind the tree (the host
364 /// terminal background shows through). This opts into a filled background so
365 /// the UI reads as a designed surface. Accepts a [`Color`], [`Paint`],
366 /// [`Style`], or a [`ScreenBackground`] directly:
367 ///
368 /// ```no_run
369 /// use tui_lipan::prelude::*;
370 ///
371 /// let app = App::new().screen_background(Color::hex_u24(0x04090D));
372 /// ```
373 ///
374 /// Use [`fill_background`](Self::fill_background) to track the active theme's
375 /// backdrop automatically.
376 pub fn screen_background(mut self, background: impl Into<ScreenBackground>) -> Self {
377 self.screen_background = background.into();
378 self
379 }
380
381 /// Fill the root viewport with the active theme's backdrop surface.
382 ///
383 /// Shorthand for `screen_background(ScreenBackground::Theme)`. The fill tracks
384 /// the app theme, so swapping themes keeps the backdrop in sync.
385 pub fn fill_background(mut self) -> Self {
386 self.screen_background = ScreenBackground::Theme;
387 self
388 }
389
390 /// Set where toasts appear on screen.
391 pub fn toast_placement(mut self, placement: ToastPlacement) -> Self {
392 self.toast_placement = placement;
393 self
394 }
395
396 /// Set vertical gap between stacked toasts.
397 pub fn toast_gap(mut self, gap: u16) -> Self {
398 self.toast_gap = gap;
399 self
400 }
401
402 /// Set outside margin between toasts and the viewport edge.
403 pub fn toast_margin(mut self, margin: impl Into<Padding>) -> Self {
404 self.toast_margin = margin.into();
405 self
406 }
407
408 /// Configure clipboard behavior.
409 pub fn clipboard_config(mut self, config: ClipboardConfig) -> Self {
410 self.clipboard_config = config;
411 self
412 }
413
414 /// Use a specific keymap file path for this app instance.
415 ///
416 /// This path has higher priority than `TUI_LIPAN_KEYMAP` and the default
417 /// `$XDG_CONFIG_HOME/tui-lipan/keymap.conf` fallback.
418 pub fn keymap_path(mut self, path: impl Into<PathBuf>) -> Self {
419 self.keymap_path = Some(path.into());
420 self
421 }
422
423 /// Configure which Enter key combination inserts new lines in `TextArea`.
424 ///
425 /// This policy is scoped to `TextArea` and does not change single-line
426 /// `Input` behavior.
427 pub fn text_area_newline_binding(mut self, binding: TextAreaNewlineBinding) -> Self {
428 self.text_area_newline_binding = binding;
429 self
430 }
431
432 /// Configure app-wide text contrast behavior for interactive widget states.
433 ///
434 /// Individual styles can override this per-state by setting
435 /// `Style::contrast_policy(...)` on the relevant style (base, hover,
436 /// selection, focus, theme role, etc.).
437 pub fn contrast_policy(mut self, policy: ContrastPolicy) -> Self {
438 self.contrast_policy = policy;
439 self
440 }
441
442 /// Provide a custom clipboard provider implementation.
443 pub fn clipboard_provider(mut self, provider: impl ClipboardProvider + 'static) -> Self {
444 self.clipboard_provider = Some(Box::new(provider));
445 self
446 }
447
448 /// Provide a custom clipboard error reporter.
449 pub fn clipboard_reporter(mut self, reporter: impl Fn(ClipboardError) + 'static) -> Self {
450 self.clipboard_reporter = std::rc::Rc::new(reporter);
451 self
452 }
453
454 /// Set the resolved terminal background color.
455 ///
456 /// When set, [`crate::style::ColorTransform::Opacity`] can blend foreground colors
457 /// toward the real terminal background even when a cell's background is
458 /// [`Color::Reset`]. Obtain this value from [`crate::style::query_host_colors()`]
459 /// before starting the app:
460 ///
461 /// ```ignore
462 /// let bg = query_host_colors().map(|c| c.bg);
463 /// App::new().terminal_bg(bg).run(MyComponent);
464 /// ```
465 pub fn terminal_bg(mut self, color: Option<Color>) -> Self {
466 self.terminal_bg = color;
467 self
468 }
469
470 /// Enable runner-managed host terminal color refreshes.
471 ///
472 /// When enabled, the runner queries the host terminal palette before component
473 /// init, refreshes on terminal focus gained, and services
474 /// `Context::request_host_terminal_color_refresh()` on the UI thread while
475 /// coordinating with tui-lipan's input reader. Refreshed colors are exposed
476 /// through `Context::host_terminal_colors()` and the resolved terminal
477 /// background is kept in sync for opacity blending.
478 ///
479 /// Disabled by default so static apps do not poll the terminal.
480 pub fn live_host_terminal_colors(mut self, enabled: bool) -> Self {
481 self.live_host_terminal_colors = enabled;
482 self
483 }
484
485 /// Use the host terminal palette as the app theme once colors are probed.
486 ///
487 /// The current app theme remains the fallback until the runner successfully
488 /// receives host colors. Later failed refreshes keep the last applied theme.
489 pub fn system_theme(mut self) -> Self {
490 self.system_theme = true;
491 self
492 }
493
494 /// Configure runtime devtools subsystem behavior.
495 #[cfg(feature = "devtools")]
496 pub fn devtools_config(mut self, config: DevToolsConfig) -> Self {
497 self.devtools_config = config;
498 self
499 }
500
501 /// Mount the root component with default properties.
502 #[cfg(not(target_arch = "wasm32"))]
503 pub fn mount<C>(self, component: C) -> AppRunner<C>
504 where
505 C: Component,
506 C::Properties: Default,
507 {
508 self.mount_with_props(component, C::Properties::default())
509 }
510
511 /// Mount the root component with explicit properties.
512 #[cfg(not(target_arch = "wasm32"))]
513 pub fn mount_with_props<C>(self, component: C, props: C::Properties) -> AppRunner<C>
514 where
515 C: Component,
516 {
517 AppRunner::new(self, component, props)
518 }
519}