Skip to main content

liora_core/
lib.rs

1//! Core runtime primitives for Liora native GPUI applications.
2//!
3//! This crate owns application-wide theme configuration, system-theme
4//! synchronization, overlay/portal registries, z-index policy, and small
5//! helpers shared by every Liora component crate. Applications normally call
6//! `liora_components::init_liora` or `liora::init_liora`; use this crate
7//! directly when building lower-level integrations or custom component crates.
8
9use gpui::{
10    Animation, AnimationExt, App, Bounds, Context, Global, Hsla, Pixels, SharedString, TextRun,
11    Window, WindowAppearance, WindowBounds, prelude::*, px,
12};
13
14use std::borrow::Cow;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::Duration;
17
18pub mod fonts;
19
20pub use fonts::*;
21
22static NEXT_UNIQUE_ID: AtomicU64 = AtomicU64::new(1);
23
24/// Ordered font fallback lists used by Liora-rendered UI and code surfaces.
25///
26/// The default value intentionally leaves the UI list empty, so GPUI keeps using
27/// its platform/system default (`.SystemUIFont`) for normal text. The code list
28/// is also optional; when it is empty, Liora asks GPUI for the generic
29/// `Monospace` family for code-oriented surfaces. Applications that want
30/// branded typography can load font bytes with [`load_custom_fonts`] and then
31/// provide ordered fallback lists through [`FontConfig`].
32#[derive(Clone, Debug, Default, PartialEq, Eq)]
33pub struct FontConfig {
34    /// Ordered UI font candidates. The first currently available family wins;
35    /// if none are visible to GPUI, the final candidate is used as the declared
36    /// fallback.
37    pub ui_families: Vec<SharedString>,
38    /// Ordered code font candidates. The first currently available family wins;
39    /// if none are visible to GPUI, the final candidate is used as the declared
40    /// fallback. Empty lists resolve to GPUI's generic `Monospace`.
41    pub code_families: Vec<SharedString>,
42}
43
44impl FontConfig {
45    /// Returns the system-default typography policy.
46    pub fn system() -> Self {
47        Self::default()
48    }
49
50    /// Returns a config that uses `families` as the ordered UI fallback list.
51    pub fn with_ui_families(
52        mut self,
53        families: impl IntoIterator<Item = impl Into<SharedString>>,
54    ) -> Self {
55        self.ui_families = families.into_iter().map(Into::into).collect();
56        self
57    }
58
59    /// Returns a config that uses `families` as the ordered code fallback list.
60    pub fn with_code_families(
61        mut self,
62        families: impl IntoIterator<Item = impl Into<SharedString>>,
63    ) -> Self {
64        self.code_families = families.into_iter().map(Into::into).collect();
65        self
66    }
67
68    /// Returns the ordered UI fallback list.
69    pub fn ui_families(&self) -> &[SharedString] {
70        &self.ui_families
71    }
72
73    /// Returns the ordered code fallback list.
74    pub fn code_families(&self) -> &[SharedString] {
75        &self.code_families
76    }
77}
78
79/// Complete options for initializing Liora core state.
80#[derive(Clone, Debug, Default, PartialEq, Eq)]
81pub struct LioraOptions {
82    /// Startup theme mode. Defaults to following the operating system.
83    pub theme_mode: ThemeMode,
84    /// Optional app typography overrides. Defaults to system fonts.
85    pub fonts: FontConfig,
86}
87
88impl LioraOptions {
89    /// Creates options that follow the operating-system theme and font choices.
90    pub fn system() -> Self {
91        Self::default()
92    }
93
94    /// Returns options with an explicit theme mode.
95    pub fn with_theme_mode(mut self, mode: ThemeMode) -> Self {
96        self.theme_mode = mode;
97        self
98    }
99
100    /// Returns options with custom Liora font families.
101    pub fn with_fonts(mut self, fonts: FontConfig) -> Self {
102        self.fonts = fonts;
103        self
104    }
105}
106
107/// Registers application-provided font files with GPUI's text system.
108///
109/// This function only makes font faces available for later resolution; it does
110/// not change Liora's default typography. To actually use a family, pass a
111/// matching [`FontConfig`] through [`init_liora_with_options`] or update the
112/// running config with [`set_font_config`]. Font bytes normally come from
113/// `include_bytes!`, an asset source, or an application settings directory.
114pub fn load_custom_fonts(
115    cx: &mut App,
116    fonts: impl IntoIterator<Item = Cow<'static, [u8]>>,
117) -> gpui::Result<()> {
118    cx.text_system().add_fonts(fonts.into_iter().collect())
119}
120
121/// Generate a process-wide unique, monotonically increasing numeric id.
122pub fn next_unique_id() -> u64 {
123    NEXT_UNIQUE_ID.fetch_add(1, Ordering::Relaxed)
124}
125
126/// Generate a process-wide unique id string with a stable component prefix.
127///
128/// Important: GPUI interactive state is keyed by `ElementId`, so call this only
129/// when constructing a persistent component/entity instance. Do not call it from
130/// a per-frame `render` path for a `RenderOnce` component, because that would
131/// assign a new ID every frame and break hover/click/portal state.
132pub fn unique_id(prefix: &str) -> gpui::SharedString {
133    format!("{}-{}", prefix, next_unique_id()).into()
134}
135
136/// Return a stable process-wide unique id for the current element path.
137///
138/// This is safe inside render paths because GPUI stores the generated value in
139/// keyed element state and reuses it for the same element across frames. The
140/// `key` must itself be stable for the visual element being rendered.
141pub fn stable_unique_id(
142    key: impl Into<gpui::SharedString>,
143    prefix: &str,
144    window: &mut Window,
145    cx: &mut App,
146) -> gpui::SharedString {
147    let prefix = prefix.to_string();
148    let key = gpui::ElementId::from(key.into());
149    window
150        .use_keyed_state(key, cx, move |_, _| unique_id(&prefix))
151        .read(cx)
152        .clone()
153}
154
155/// Documents and exposes the popper module APIs.
156pub mod popper;
157
158pub use popper::*;
159
160pub use liora_theme::Theme;
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
163/// Options that control theme mode behavior.
164pub enum ThemeMode {
165    #[default]
166    /// Follows the operating system appearance when resolving the active theme.
167    System,
168    /// Forces the light Liora theme regardless of system appearance.
169    Light,
170    /// Forces the dark Liora theme regardless of system appearance.
171    Dark,
172}
173
174impl ThemeMode {
175    /// Returns the stable user-facing label for this value.
176    pub fn label(self) -> &'static str {
177        match self {
178            Self::System => "System",
179            Self::Light => "Light",
180            Self::Dark => "Dark",
181        }
182    }
183
184    /// Returns the serialized value used by forms, configuration, or persistence.
185    pub fn value(self) -> &'static str {
186        match self {
187            Self::System => "system",
188            Self::Light => "light",
189            Self::Dark => "dark",
190        }
191    }
192
193    /// Parses a serialized value into the corresponding strongly typed option.
194    pub fn from_value(value: &str) -> Option<Self> {
195        match value {
196            "system" => Some(Self::System),
197            "light" => Some(Self::Light),
198            "dark" => Some(Self::Dark),
199            _ => None,
200        }
201    }
202
203    /// Resolves this mode into a concrete light or dark theme.
204    pub fn resolve(self, appearance: WindowAppearance) -> Theme {
205        match self {
206            Self::System => theme_for_window_appearance(appearance),
207            Self::Light => Theme::light(),
208            Self::Dark => Theme::dark(),
209        }
210    }
211
212    /// Creates this value from theme.
213    pub fn from_theme(theme: &Theme) -> Self {
214        match theme.name.as_str() {
215            "dark" => Self::Dark,
216            _ => Self::Light,
217        }
218    }
219}
220
221/// Return startup bounds for a window that should request GPUI maximized state.
222///
223/// This helper uses the official pinned Zed GPUI git API surface. It preserves
224/// the caller's `WindowBounds::Maximized` intent and uses the primary display
225/// bounds as the restore/fallback geometry; exact first-frame behavior is
226/// decided by the upstream GPUI backend selected by the application root.
227pub fn startup_maximized_window_bounds(
228    cx: &App,
229    fallback_size: gpui::Size<Pixels>,
230) -> WindowBounds {
231    let bounds = cx
232        .primary_display()
233        .map(|display| display.bounds())
234        .unwrap_or(Bounds {
235            origin: gpui::Point::default(),
236            size: fallback_size,
237        });
238    WindowBounds::Maximized(bounds)
239}
240
241/// Metadata and icon bytes used to publish a Linux desktop identity.
242///
243/// Wayland does not let a client set a titlebar or taskbar icon directly.
244/// Compositors resolve the icon by matching the window `app_id` to a desktop
245/// entry and then loading that entry's `Icon=` name from the icon theme. Liora
246/// apps call [`ensure_linux_desktop_identity`] before opening their first GPUI
247/// window so `cargo run -p <app>` gets the same icon identity as an installed
248/// package without requiring root privileges.
249#[derive(Clone, Debug)]
250pub struct LinuxDesktopIdentity<'a> {
251    /// Stable GPUI/Wayland app id and desktop/icon filename stem.
252    pub app_id: &'a str,
253    /// Complete `.desktop` file contents for this app.
254    pub desktop_entry: Cow<'a, str>,
255    /// PNG icon bytes installed into hicolor size directories.
256    pub png_icons: &'a [LinuxDesktopPngIcon<'a>],
257    /// SVG icon bytes installed into hicolor scalable apps.
258    pub svg_icon: &'a [u8],
259}
260
261/// PNG icon bytes for one hicolor app-icon size bucket.
262#[derive(Clone, Copy, Debug)]
263pub struct LinuxDesktopPngIcon<'a> {
264    /// Square icon size in logical pixels.
265    pub size: u16,
266    /// PNG bytes for this size, normally 8-bit RGBA for maximum shell support.
267    pub bytes: &'a [u8],
268}
269
270/// Builds a user-level Linux desktop entry for a running Liora app.
271///
272/// Packaged installers use the static files under `packaging/linux`. Direct
273/// development runs should instead register the currently running executable,
274/// because `TryExec=<binary>` entries can be ignored when `cargo run` launches a
275/// target/debug binary that is not on `PATH`.
276pub fn linux_desktop_entry(
277    name: &str,
278    generic_name: &str,
279    comment: &str,
280    executable: &std::path::Path,
281    icon: &str,
282    categories: &str,
283    keywords: &str,
284) -> String {
285    format!(
286        "[Desktop Entry]\nVersion=1.0\nType=Application\nName={name}\nGenericName={generic_name}\nComment={comment}\nExec={}\nIcon={icon}\nCategories={categories}\nKeywords={keywords}\nStartupNotify=true\nTerminal=false\n",
287        desktop_exec_value(executable),
288    )
289}
290
291/// Returns the user-scoped hicolor app-icon path for an app id and icon size.
292///
293/// Use this when generating a development `.desktop` entry that should bypass
294/// desktop-environment icon-theme cache latency by pointing `Icon=` at the
295/// exact PNG file Liora registers.
296pub fn linux_desktop_png_icon_path(app_id: &str, size: u16) -> Option<std::path::PathBuf> {
297    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
298    {
299        linux_xdg_data_home().ok().map(|data_home| {
300            data_home
301                .join("icons")
302                .join("hicolor")
303                .join(format!("{size}x{size}"))
304                .join("apps")
305                .join(format!("{app_id}.png"))
306        })
307    }
308
309    #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
310    {
311        let _ = (app_id, size);
312        None
313    }
314}
315
316fn desktop_exec_value(executable: &std::path::Path) -> String {
317    let value = executable.to_string_lossy();
318    if value
319        .chars()
320        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '+'))
321    {
322        value.into_owned()
323    } else {
324        format!("\"{}\"", value.replace('\\', "\\\\").replace('\"', "\\\""))
325    }
326}
327
328/// Installs a user-scoped Linux desktop entry and hicolor icons for an app.
329///
330/// The operation is intentionally best-effort and idempotent: it writes only to
331/// `$XDG_DATA_HOME` or `~/.local/share`, skips non-Linux targets, and returns an
332/// error instead of panicking when the host environment has no usable home/data
333/// directory. Applications should call this before creating their first window
334/// and log failures, because the app can still run when icon registration is
335/// unavailable.
336pub fn ensure_linux_desktop_identity(identity: LinuxDesktopIdentity<'_>) -> std::io::Result<()> {
337    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
338    {
339        ensure_linux_desktop_identity_impl(linux_xdg_data_home()?, identity, true)
340    }
341
342    #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
343    {
344        let _ = identity;
345        Ok(())
346    }
347}
348
349#[cfg(any(target_os = "linux", target_os = "freebsd"))]
350fn ensure_linux_desktop_identity_impl(
351    data_home: std::path::PathBuf,
352    identity: LinuxDesktopIdentity<'_>,
353    refresh_cache: bool,
354) -> std::io::Result<()> {
355    let mut changed = write_if_changed(
356        &data_home
357            .join("applications")
358            .join(format!("{}.desktop", identity.app_id)),
359        identity.desktop_entry.as_bytes(),
360    )?;
361    for icon in identity.png_icons {
362        changed |= write_if_changed(
363            &data_home
364                .join("icons")
365                .join("hicolor")
366                .join(format!("{}x{}", icon.size, icon.size))
367                .join("apps")
368                .join(format!("{}.png", identity.app_id)),
369            icon.bytes,
370        )?;
371    }
372    changed |= write_if_changed(
373        &data_home
374            .join("icons")
375            .join("hicolor")
376            .join("scalable")
377            .join("apps")
378            .join(format!("{}.svg", identity.app_id)),
379        identity.svg_icon,
380    )?;
381    changed |= ensure_hicolor_index_theme(&data_home)?;
382    if changed && refresh_cache {
383        refresh_linux_desktop_identity_cache(&data_home);
384    }
385    Ok(())
386}
387
388#[cfg(any(target_os = "linux", target_os = "freebsd"))]
389fn refresh_linux_desktop_identity_cache(data_home: &std::path::Path) {
390    let apps_dir = data_home.join("applications");
391    let _ = std::fs::remove_file(
392        data_home
393            .join("icons")
394            .join("hicolor")
395            .join(".icon-theme.cache"),
396    );
397    let _ = std::process::Command::new("update-desktop-database")
398        .arg(&apps_dir)
399        .status();
400    for command in ["kbuildsycoca6", "kbuildsycoca5"] {
401        let _ = std::process::Command::new(command)
402            .arg("--noincremental")
403            .status();
404    }
405}
406
407#[cfg(any(target_os = "linux", target_os = "freebsd"))]
408fn ensure_hicolor_index_theme(data_home: &std::path::Path) -> std::io::Result<bool> {
409    write_if_changed(
410        &data_home.join("icons").join("hicolor").join("index.theme"),
411        HICOLOR_INDEX_THEME.as_bytes(),
412    )
413}
414
415#[cfg(any(target_os = "linux", target_os = "freebsd"))]
416const HICOLOR_INDEX_THEME: &str = "[Icon Theme]\nName=Hicolor\nComment=Fallback icon theme\nHidden=true\nDirectories=16x16/apps,24x24/apps,32x32/apps,48x48/apps,64x64/apps,128x128/apps,256x256/apps,512x512/apps,scalable/apps\n\n[16x16/apps]\nSize=16\nContext=Applications\nType=Threshold\n\n[24x24/apps]\nSize=24\nContext=Applications\nType=Threshold\n\n[32x32/apps]\nSize=32\nContext=Applications\nType=Threshold\n\n[48x48/apps]\nSize=48\nContext=Applications\nType=Threshold\n\n[64x64/apps]\nSize=64\nContext=Applications\nType=Threshold\n\n[128x128/apps]\nSize=128\nContext=Applications\nType=Threshold\n\n[256x256/apps]\nSize=256\nContext=Applications\nType=Threshold\n\n[512x512/apps]\nSize=512\nContext=Applications\nType=Threshold\n\n[scalable/apps]\nSize=512\nMinSize=16\nMaxSize=512\nContext=Applications\nType=Scalable\n";
417
418#[cfg(any(target_os = "linux", target_os = "freebsd"))]
419fn linux_xdg_data_home() -> std::io::Result<std::path::PathBuf> {
420    if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") {
421        return Ok(std::path::PathBuf::from(data_home));
422    }
423    std::env::var_os("HOME")
424        .map(std::path::PathBuf::from)
425        .map(|home| home.join(".local").join("share"))
426        .ok_or_else(|| {
427            std::io::Error::new(
428                std::io::ErrorKind::NotFound,
429                "neither XDG_DATA_HOME nor HOME is set",
430            )
431        })
432}
433
434#[cfg(any(target_os = "linux", target_os = "freebsd"))]
435fn write_if_changed(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<bool> {
436    if std::fs::read(path).is_ok_and(|existing| existing == bytes) {
437        return Ok(false);
438    }
439    if let Some(parent) = path.parent() {
440        std::fs::create_dir_all(parent)?;
441    }
442    std::fs::write(path, bytes)?;
443    Ok(true)
444}
445
446fn startup_system_appearance(cx: &App) -> WindowAppearance {
447    platform_system_appearance().unwrap_or_else(|| cx.window_appearance())
448}
449
450fn current_system_appearance(window: &Window, _cx: &App) -> WindowAppearance {
451    platform_system_appearance().unwrap_or_else(|| window.appearance())
452}
453
454#[cfg(any(target_os = "linux", target_os = "freebsd"))]
455fn platform_system_appearance() -> Option<WindowAppearance> {
456    gtk_theme_env_appearance()
457        .or_else(gtk_settings_appearance)
458        .or_else(gsettings_color_scheme_appearance)
459}
460
461#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
462fn platform_system_appearance() -> Option<WindowAppearance> {
463    None
464}
465
466#[cfg(any(target_os = "linux", target_os = "freebsd"))]
467fn gtk_theme_env_appearance() -> Option<WindowAppearance> {
468    std::env::var("GTK_THEME")
469        .ok()
470        .and_then(|theme| appearance_from_theme_name(&theme))
471}
472
473#[cfg(any(target_os = "linux", target_os = "freebsd"))]
474fn gtk_settings_appearance() -> Option<WindowAppearance> {
475    ["gtk-4.0", "gtk-3.0"]
476        .into_iter()
477        .filter_map(|version| {
478            std::env::var_os("HOME").map(|home| {
479                std::path::PathBuf::from(home)
480                    .join(".config")
481                    .join(version)
482                    .join("settings.ini")
483            })
484        })
485        .filter_map(|path| std::fs::read_to_string(path).ok())
486        .find_map(|settings| appearance_from_gtk_settings(&settings))
487}
488
489#[cfg(any(target_os = "linux", target_os = "freebsd"))]
490fn gsettings_color_scheme_appearance() -> Option<WindowAppearance> {
491    let output = std::process::Command::new("gsettings")
492        .args(["get", "org.gnome.desktop.interface", "color-scheme"])
493        .output()
494        .ok()?;
495    if !output.status.success() {
496        return None;
497    }
498    let value = String::from_utf8_lossy(&output.stdout);
499    appearance_from_color_scheme(&value)
500}
501
502#[cfg(any(target_os = "linux", target_os = "freebsd"))]
503fn appearance_from_color_scheme(value: &str) -> Option<WindowAppearance> {
504    let value = value
505        .trim()
506        .trim_matches('\'')
507        .trim_matches('"')
508        .to_ascii_lowercase();
509    if value.contains("prefer-dark") {
510        Some(WindowAppearance::Dark)
511    } else if value.contains("prefer-light") || value == "default" {
512        Some(WindowAppearance::Light)
513    } else {
514        None
515    }
516}
517
518#[cfg(any(target_os = "linux", target_os = "freebsd"))]
519fn appearance_from_gtk_settings(settings: &str) -> Option<WindowAppearance> {
520    for line in settings.lines() {
521        let line = line.trim();
522        if line.starts_with('#') || line.starts_with(';') || line.is_empty() {
523            continue;
524        }
525        let Some((key, value)) = line.split_once('=') else {
526            continue;
527        };
528        let key = key.trim();
529        let value = value.trim();
530        if key == "gtk-application-prefer-dark-theme" {
531            return match value.to_ascii_lowercase().as_str() {
532                "true" | "1" => Some(WindowAppearance::Dark),
533                "false" | "0" => Some(WindowAppearance::Light),
534                _ => None,
535            };
536        }
537        if key == "gtk-theme-name"
538            && let Some(appearance) = appearance_from_theme_name(value)
539        {
540            return Some(appearance);
541        }
542    }
543    None
544}
545
546#[cfg(any(target_os = "linux", target_os = "freebsd"))]
547fn appearance_from_theme_name(theme: &str) -> Option<WindowAppearance> {
548    let theme = theme.to_ascii_lowercase();
549    if theme.contains("dark") {
550        Some(WindowAppearance::Dark)
551    } else if theme.contains("light") {
552        Some(WindowAppearance::Light)
553    } else {
554        None
555    }
556}
557
558/// Maps a GPUI window appearance to the matching Liora theme.
559pub fn theme_for_window_appearance(appearance: WindowAppearance) -> Theme {
560    match appearance {
561        WindowAppearance::Light | WindowAppearance::VibrantLight => Theme::light(),
562        WindowAppearance::Dark | WindowAppearance::VibrantDark => Theme::dark(),
563    }
564}
565
566/// Runtime state used by Liora config behavior.
567pub struct Config {
568    /// Active Liora theme tokens stored in GPUI global state.
569    pub theme: Theme,
570    /// Configured theme mode used to resolve light or dark tokens.
571    pub theme_mode: ThemeMode,
572    /// Optional UI/code font family overrides.
573    pub fonts: FontConfig,
574    /// Base z-index offset for overlay layering.
575    pub z_index_base: u32,
576}
577
578impl Global for Config {}
579
580impl Config {
581    /// Updates the stored theme mode value and keeps the existing component identity.
582    pub fn set_theme_mode(&mut self, mode: ThemeMode, appearance: WindowAppearance) {
583        self.theme_mode = mode;
584        self.theme = mode.resolve(appearance);
585    }
586
587    /// Replaces the app typography policy while preserving the active theme.
588    pub fn set_font_config(&mut self, fonts: FontConfig) {
589        self.fonts = fonts;
590    }
591
592    /// Synchronizes the active theme from the current system/window appearance.
593    pub fn sync_system_theme(&mut self, appearance: WindowAppearance) -> bool {
594        if self.theme_mode != ThemeMode::System {
595            return false;
596        }
597        let theme = ThemeMode::System.resolve(appearance);
598        let changed = self.theme.name != theme.name;
599        self.theme = theme;
600        changed
601    }
602}
603
604/// Initializes Liora core state with an explicit concrete theme.
605pub fn init_liora(cx: &mut App, theme: Theme) {
606    let theme_mode = ThemeMode::from_theme(&theme);
607    set_core_config(
608        cx,
609        Config {
610            theme,
611            theme_mode,
612            fonts: FontConfig::default(),
613            z_index_base: 1000,
614        },
615    );
616}
617
618fn set_core_config(cx: &mut App, config: Config) {
619    cx.set_global(config);
620    cx.set_global(crate::popper::ZIndexStack::default());
621    cx.set_global(crate::popper::ActiveTooltip(Vec::new()));
622    cx.set_global(crate::popper::ActivePopover(Vec::new()));
623    cx.set_global(crate::popper::ActiveModal(Vec::new()));
624    cx.set_global(crate::popper::ActiveDrawer(Vec::new()));
625}
626
627/// Initializes Liora core state from a theme mode, including system mode resolution.
628pub fn init_liora_with_mode(cx: &mut App, mode: ThemeMode) {
629    init_liora_with_options(cx, LioraOptions::default().with_theme_mode(mode));
630}
631
632/// Initializes Liora core state from full startup options.
633pub fn init_liora_with_options(cx: &mut App, options: LioraOptions) {
634    let appearance = startup_system_appearance(cx);
635    set_core_config(
636        cx,
637        Config {
638            theme: options.theme_mode.resolve(appearance),
639            theme_mode: options.theme_mode,
640            fonts: options.fonts,
641            z_index_base: 1000,
642        },
643    );
644}
645
646/// Replaces Liora font-family overrides for subsequent renders.
647pub fn set_font_config(cx: &mut App, fonts: FontConfig) {
648    cx.global_mut::<Config>().set_font_config(fonts);
649}
650
651/// Resolves an ordered fallback list against families currently visible to GPUI.
652///
653/// GPUI's public text style currently accepts one `font_family` value, so Liora
654/// performs the ordered fallback selection before assigning that field. If no
655/// candidate is reported as available, Liora returns the last configured
656/// candidate because callers normally put the broadest platform fallback there.
657pub fn resolve_font_family_from_available(
658    candidates: &[SharedString],
659    available: &[String],
660    default: Option<SharedString>,
661) -> Option<SharedString> {
662    if candidates.is_empty() {
663        return default;
664    }
665
666    candidates
667        .iter()
668        .find(|candidate| available.iter().any(|family| family == candidate.as_ref()))
669        .cloned()
670        .or_else(|| candidates.last().cloned())
671}
672
673/// Returns the resolved UI font family, if the app overrides the system default.
674pub fn ui_font_family(cx: &App) -> Option<SharedString> {
675    let config = cx.global::<Config>();
676    let available = cx.text_system().all_font_names();
677    resolve_font_family_from_available(&config.fonts.ui_families, &available, None)
678}
679
680/// Returns the resolved code font family or GPUI's generic monospace family.
681pub fn code_font_family(cx: &App) -> SharedString {
682    let config = cx.global::<Config>();
683    let available = cx.text_system().all_font_names();
684    resolve_font_family_from_available(
685        &config.fonts.code_families,
686        &available,
687        Some("Monospace".into()),
688    )
689    .unwrap_or_else(|| "Monospace".into())
690}
691
692/// Applies a new theme mode and refreshes the active GPUI window.
693pub fn apply_theme_mode(window: &mut Window, cx: &mut App, mode: ThemeMode) {
694    let appearance = current_system_appearance(window, cx);
695    cx.global_mut::<Config>().set_theme_mode(mode, appearance);
696    window.refresh();
697}
698
699/// Synchronizes the active theme from the current system/window appearance.
700pub fn sync_system_theme(window: &mut Window, cx: &mut App) {
701    let appearance = current_system_appearance(window, cx);
702    if cx.global_mut::<Config>().sync_system_theme(appearance) {
703        window.refresh();
704    }
705}
706
707/// Attach System theme tracking to a concrete GPUI window.
708///
709/// `init_liora_with_mode(cx, ThemeMode::System)` runs before a window exists and
710/// can only use the app-level appearance snapshot. Following Zed's main-window
711/// pattern, create the window with `WindowOptions { show: false, .. }`, call this
712/// at the start of the `open_window` callback before constructing the root view,
713/// then activate the returned window handle after `open_window` completes.
714pub fn attach_system_theme_observer(window: &mut Window, cx: &mut App) {
715    sync_system_theme(window, cx);
716    window
717        .observe_window_appearance(|window, cx| sync_system_theme(window, cx))
718        .detach();
719}
720
721/// Renders the render active popover in window layer into native GPUI elements.
722pub fn render_active_popover_in_window(_window: &mut gpui::Window, cx: &mut App) {
723    for entry in cx.global::<crate::popper::ActivePopover>().0.clone() {
724        push_portal(
725            move |_window, _cx| entry.view.clone().into_any_element(),
726            cx,
727        );
728    }
729}
730
731/// Renders the render active modal in window layer into native GPUI elements.
732pub fn render_active_modal_in_window(_window: &mut gpui::Window, cx: &mut App) {
733    for entry in cx.global::<crate::popper::ActiveModal>().0.clone() {
734        push_portal(
735            move |_window, _cx| entry.view.clone().into_any_element(),
736            cx,
737        );
738    }
739}
740
741/// Renders the render active drawer in window layer into native GPUI elements.
742pub fn render_active_drawer_in_window(_window: &mut gpui::Window, cx: &mut App) {
743    for entry in cx.global::<crate::popper::ActiveDrawer>().0.clone() {
744        push_portal(
745            move |_window, _cx| entry.view.clone().into_any_element(),
746            cx,
747        );
748    }
749}
750
751/// Splits tooltip content into GPUI-safe display lines.
752///
753/// GPUI's low-level text shaper accepts a single line only and intentionally
754/// panics when a string contains newlines. Chart tooltips and downstream apps
755/// may provide multiline help text, so overlay rendering normalizes content at
756/// the Liora boundary before measuring each line independently.
757pub fn tooltip_content_lines(content: &SharedString) -> Vec<SharedString> {
758    let lines: Vec<SharedString> = content
759        .split('\n')
760        .map(|line| line.trim_end_matches('\r'))
761        .map(SharedString::from)
762        .collect();
763
764    if lines.is_empty() {
765        vec![SharedString::default()]
766    } else {
767        lines
768    }
769}
770
771/// Renders the render active tooltip in window layer into native GPUI elements.
772pub fn render_active_tooltip_in_window(window: &mut gpui::Window, cx: &mut App) {
773    let mouse_pos = window.mouse_position();
774    cx.global_mut::<crate::popper::ActiveTooltip>()
775        .0
776        .retain(|data| data.anchor_bounds.contains(&mouse_pos));
777
778    let active = cx.global::<crate::popper::ActiveTooltip>().0.clone();
779    for (tooltip_index, data) in active.into_iter().enumerate() {
780        let theme = cx.global::<Config>().theme.clone();
781
782        // Measure each visual line independently. GPUI shape_line is
783        // intentionally single-line only and will panic on newline input.
784        let font_size = px(theme.font_size.sm);
785        let text_style = window.text_style();
786        let lines = tooltip_content_lines(&data.content);
787        let max_line_width = lines
788            .iter()
789            .map(|line| {
790                let run = TextRun {
791                    len: line.len(),
792                    font: text_style.font(),
793                    color: theme.neutral.card,
794                    background_color: None,
795                    underline: None,
796                    strikethrough: None,
797                };
798                window
799                    .text_system()
800                    .shape_line(line.clone(), font_size, &[run], None)
801                    .width
802            })
803            .fold(px(0.0), |max_width, width| max_width.max(width));
804
805        let padding_h = px(12.0);
806        let padding_v = px(6.0);
807        let line_height = window.line_height();
808        let content_size = gpui::Size {
809            width: max_line_width + padding_h * 2.0,
810            height: line_height * lines.len() + padding_v * 2.0,
811        };
812
813        push_passive_portal(
814            move |window, _cx| {
815                let viewport = Bounds {
816                    origin: gpui::Point::default(),
817                    size: window.viewport_size(),
818                };
819
820                let popper = Popper {
821                    anchor_bounds: data.anchor_bounds,
822                    placement: data.placement,
823                    offset: data.offset,
824                };
825
826                let (pos, _final_placement) =
827                    popper.calculate_position_with_flip(content_size, viewport);
828
829                gpui::div()
830                    .absolute()
831                    .cursor_default()
832                    .top(pos.y)
833                    .left(pos.x)
834                    .w(content_size.width)
835                    .h(content_size.height)
836                    .bg(theme.neutral.text_1)
837                    .text_color(theme.neutral.card)
838                    .px(padding_h)
839                    .py(padding_v)
840                    .flex()
841                    .flex_col()
842                    .items_start()
843                    .justify_center()
844                    .gap(px(2.0))
845                    .rounded(px(theme.radius.sm))
846                    .shadow_lg()
847                    .text_size(font_size)
848                    .children(lines.iter().cloned())
849                    .with_animation(
850                        ("liora-tooltip-motion", tooltip_index),
851                        Animation::new(Duration::from_millis(220))
852                            .with_easing(gpui::ease_out_quint()),
853                        |tooltip, delta| tooltip.opacity(delta),
854                    )
855                    .into_any_element()
856            },
857            cx,
858        );
859    }
860}
861
862#[cfg(test)]
863mod tooltip_text_tests {
864    use super::*;
865
866    #[test]
867    fn tooltip_renderer_never_shapes_multiline_content_as_one_line() {
868        let source = include_str!("lib.rs").split("#[cfg(test)]").next().unwrap();
869
870        assert!(
871            !source.contains(".shape_line(data.content.clone()"),
872            "GPUI shape_line panics when the text argument contains newlines"
873        );
874        assert!(
875            source.contains("tooltip_content_lines(&data.content)")
876                || source.contains("tooltip_content_lines("),
877            "tooltip rendering should split multiline content before measuring and rendering"
878        );
879    }
880
881    #[test]
882    fn tooltip_content_lines_preserves_multiline_tooltips_without_newline_lines() {
883        let lines = tooltip_content_lines(&SharedString::from("Mon\nO 100  H 110\nL 96  C 108"));
884
885        assert_eq!(lines, vec!["Mon", "O 100  H 110", "L 96  C 108"]);
886        assert!(lines.iter().all(|line| !line.contains('\n')));
887    }
888}
889
890#[cfg(test)]
891mod theme_mode_tests {
892    use super::*;
893
894    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
895    #[test]
896    fn linux_startup_appearance_parses_synchronous_dark_preferences() {
897        assert_eq!(
898            appearance_from_color_scheme("'prefer-dark'"),
899            Some(WindowAppearance::Dark)
900        );
901        assert_eq!(
902            appearance_from_color_scheme("prefer-light"),
903            Some(WindowAppearance::Light)
904        );
905        assert_eq!(
906            appearance_from_gtk_settings(
907                "[Settings]\ngtk-application-prefer-dark-theme=true\ngtk-theme-name=Breeze\n"
908            ),
909            Some(WindowAppearance::Dark)
910        );
911        assert_eq!(
912            appearance_from_theme_name("Adwaita-dark"),
913            Some(WindowAppearance::Dark)
914        );
915    }
916
917    #[test]
918    fn theme_mode_values_and_labels_are_stable() {
919        assert_eq!(ThemeMode::System.value(), "system");
920        assert_eq!(ThemeMode::Light.label(), "Light");
921        assert_eq!(ThemeMode::from_value("dark"), Some(ThemeMode::Dark));
922        assert_eq!(ThemeMode::from_theme(&Theme::dark()), ThemeMode::Dark);
923        assert_eq!(ThemeMode::from_theme(&Theme::light()), ThemeMode::Light);
924        assert_eq!(ThemeMode::from_value("unknown"), None);
925    }
926
927    #[test]
928    fn system_theme_resolves_from_window_appearance() {
929        assert_eq!(
930            ThemeMode::System.resolve(WindowAppearance::Light).name,
931            Theme::light().name
932        );
933        assert_eq!(
934            ThemeMode::System
935                .resolve(WindowAppearance::VibrantDark)
936                .name,
937            Theme::dark().name
938        );
939    }
940
941    #[test]
942    fn config_syncs_only_in_system_mode() {
943        let mut config = Config {
944            theme: Theme::light(),
945            theme_mode: ThemeMode::Light,
946            fonts: FontConfig::default(),
947            z_index_base: 1000,
948        };
949        assert!(!config.sync_system_theme(WindowAppearance::Dark));
950        assert_eq!(config.theme.name, "light");
951
952        config.set_theme_mode(ThemeMode::System, WindowAppearance::Dark);
953        assert_eq!(config.theme.name, "dark");
954        assert!(!config.sync_system_theme(WindowAppearance::VibrantDark));
955        assert!(config.sync_system_theme(WindowAppearance::Light));
956        assert_eq!(config.theme.name, "light");
957    }
958
959    #[test]
960    fn font_config_defaults_to_system_fonts_and_keeps_custom_families_explicit() {
961        let default = FontConfig::default();
962        assert!(default.ui_families.is_empty());
963        assert!(default.code_families.is_empty());
964
965        let custom = FontConfig::system()
966            .with_ui_families(["Inter", "Segoe UI"])
967            .with_code_families(["JetBrains Mono", "Monospace"]);
968        assert_eq!(
969            custom.ui_families(),
970            &[SharedString::from("Inter"), SharedString::from("Segoe UI")]
971        );
972        assert_eq!(
973            custom.code_families(),
974            &[
975                SharedString::from("JetBrains Mono"),
976                SharedString::from("Monospace")
977            ]
978        );
979
980        let options = LioraOptions::system()
981            .with_theme_mode(ThemeMode::Dark)
982            .with_fonts(custom.clone());
983        assert_eq!(options.theme_mode, ThemeMode::Dark);
984        assert_eq!(options.fonts, custom);
985    }
986
987    #[test]
988    fn font_config_accepts_ordered_fallback_family_lists() {
989        let custom = FontConfig::system()
990            .with_ui_families(["PingFang SC", "Segoe UI", "Arial"])
991            .with_code_families(["JetBrains Mono", "SF Mono", "Monospace"]);
992
993        assert_eq!(
994            custom.ui_families(),
995            &[
996                SharedString::from("PingFang SC"),
997                SharedString::from("Segoe UI"),
998                SharedString::from("Arial")
999            ]
1000        );
1001        assert_eq!(
1002            custom.code_families(),
1003            &[
1004                SharedString::from("JetBrains Mono"),
1005                SharedString::from("SF Mono"),
1006                SharedString::from("Monospace")
1007            ]
1008        );
1009    }
1010
1011    #[test]
1012    fn font_family_resolution_uses_first_available_then_final_fallback() {
1013        let candidates = [
1014            SharedString::from("PingFang SC"),
1015            SharedString::from("Segoe UI"),
1016            SharedString::from("Arial"),
1017        ];
1018        let available = [String::from("Segoe UI")];
1019
1020        assert_eq!(
1021            resolve_font_family_from_available(&candidates, &available, None).as_deref(),
1022            Some("Segoe UI")
1023        );
1024        assert_eq!(
1025            resolve_font_family_from_available(&candidates, &[], None).as_deref(),
1026            Some("Arial")
1027        );
1028        assert_eq!(
1029            resolve_font_family_from_available(&[], &available, Some("Monospace".into()))
1030                .as_deref(),
1031            Some("Monospace")
1032        );
1033    }
1034
1035    #[test]
1036    fn system_theme_observer_syncs_immediately_and_stays_attached() {
1037        let source = include_str!("lib.rs");
1038        let start = source
1039            .find("pub fn attach_system_theme_observer")
1040            .expect("system theme observer helper should exist");
1041        let body = &source[start
1042            ..source[start..]
1043                .find("pub fn render_active_popover_in_window")
1044                .expect("next function should follow observer helper")
1045                + start];
1046
1047        let sync_call = format!("{}(window, cx);", "sync_system_theme");
1048        let observe_call = format!("{}", "observe_window_appearance");
1049        let sync_index = body
1050            .find(&sync_call)
1051            .expect("observer helper should sync the current window appearance immediately");
1052        let observe_index = body
1053            .find(&observe_call)
1054            .expect("observer helper should observe later appearance changes");
1055        assert!(sync_index < observe_index);
1056        assert!(body.contains(".detach();"));
1057    }
1058}
1059
1060#[cfg(test)]
1061mod motion_tests {
1062    #[test]
1063    fn tooltip_rendering_uses_gpui_motion() {
1064        let source = include_str!("lib.rs").split("#[cfg(test)]").next().unwrap();
1065
1066        assert!(source.contains("tooltip-motion"));
1067        assert!(source.contains("with_animation("));
1068    }
1069}
1070
1071/// Returns the active Liora theme stored in the GPUI application context.
1072pub fn liora_theme<'a, V>(cx: &'a Context<'a, V>) -> &'a Theme {
1073    &cx.global::<Config>().theme
1074}
1075
1076/// Convenience accessors for reading Liora theme data from GPUI contexts.
1077pub trait ContextExt {
1078    /// Returns the active Liora theme for the current context.
1079    fn liora(&self) -> &Theme;
1080}
1081
1082impl<'a, V> ContextExt for Context<'a, V> {
1083    fn liora(&self) -> &Theme {
1084        liora_theme(self)
1085    }
1086}
1087
1088/// Element extension points reserved for applying Liora-wide styling helpers.
1089pub trait ElementExt {
1090    /// Returns the active Liora theme for the current context.
1091    fn liora(self, cx: &mut App) -> Self;
1092}
1093
1094impl ElementExt for gpui::Div {
1095    fn liora(self, _cx: &mut App) -> Self {
1096        self
1097    }
1098}
1099
1100/// Returns the z-index reserved for popup overlays.
1101pub fn z_index_popup<V>(cx: &Context<'_, V>) -> u32 {
1102    cx.global::<Config>().z_index_base + 100
1103}
1104
1105/// Returns the z-index reserved for modal overlays.
1106pub fn z_index_modal<V>(cx: &Context<'_, V>) -> u32 {
1107    cx.global::<Config>().z_index_base + 200
1108}
1109
1110/// Returns the z-index reserved for notifications.
1111pub fn z_index_notification<V>(cx: &Context<'_, V>) -> u32 {
1112    cx.global::<Config>().z_index_base + 300
1113}
1114
1115/// Returns the z-index reserved for tooltip overlays.
1116pub fn z_index_tooltip<V>(cx: &Context<'_, V>) -> u32 {
1117    cx.global::<Config>().z_index_base + 400
1118}
1119
1120/// Converts a packed RGB integer into a GPUI HSLA color.
1121pub fn hex_color(hex: u32) -> Hsla {
1122    gpui::rgb(hex).into()
1123}
1124
1125#[cfg(test)]
1126mod unique_id_tests {
1127    use super::*;
1128
1129    #[test]
1130    fn generated_ids_are_prefixed_and_unique() {
1131        let first = unique_id("component");
1132        let second = unique_id("component");
1133
1134        assert!(first.as_ref().starts_with("component-"));
1135        assert!(second.as_ref().starts_with("component-"));
1136        assert_ne!(first, second);
1137    }
1138}
1139
1140#[cfg(test)]
1141mod desktop_identity_tests {
1142    use super::*;
1143
1144    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1145    #[test]
1146    fn linux_desktop_identity_installs_desktop_entry_and_hicolor_icons() {
1147        let temp_root = std::env::temp_dir().join(format!(
1148            "liora-desktop-identity-test-{}",
1149            std::process::id()
1150        ));
1151        let _ = std::fs::remove_dir_all(&temp_root);
1152        std::fs::create_dir_all(&temp_root).expect("test temp root should be creatable");
1153
1154        ensure_linux_desktop_identity_impl(
1155            temp_root.clone(),
1156            LinuxDesktopIdentity {
1157                app_id: "liora-test",
1158                desktop_entry: Cow::Borrowed(
1159                    "[Desktop Entry]\nType=Application\nName=Liora Test\nIcon=liora-test\n",
1160                ),
1161                png_icons: &[LinuxDesktopPngIcon {
1162                    size: 48,
1163                    bytes: b"png",
1164                }],
1165                svg_icon: b"svg",
1166            },
1167            false,
1168        )
1169        .expect("identity registration should write into the supplied XDG data home");
1170
1171        assert_eq!(
1172            std::fs::read_to_string(temp_root.join("applications/liora-test.desktop"))
1173                .expect("desktop entry should be installed"),
1174            "[Desktop Entry]\nType=Application\nName=Liora Test\nIcon=liora-test\n"
1175        );
1176        assert_eq!(
1177            std::fs::read(temp_root.join("icons/hicolor/48x48/apps/liora-test.png"))
1178                .expect("PNG hicolor icon should be installed"),
1179            b"png"
1180        );
1181        assert_eq!(
1182            std::fs::read(temp_root.join("icons/hicolor/scalable/apps/liora-test.svg"))
1183                .expect("SVG hicolor icon should be installed"),
1184            b"svg"
1185        );
1186        assert!(
1187            std::fs::read_to_string(temp_root.join("icons/hicolor/index.theme"))
1188                .expect("hicolor index theme should be installed")
1189                .contains("Directories=16x16/apps")
1190        );
1191
1192        let _ = std::fs::remove_dir_all(temp_root);
1193    }
1194}