Skip to main content

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}