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