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, TextRun, Window,
11    WindowAppearance, WindowBounds, prelude::*, px,
12};
13
14use std::borrow::Cow;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::Duration;
17
18static NEXT_UNIQUE_ID: AtomicU64 = AtomicU64::new(1);
19
20/// Generate a process-wide unique, monotonically increasing numeric id.
21pub fn next_unique_id() -> u64 {
22    NEXT_UNIQUE_ID.fetch_add(1, Ordering::Relaxed)
23}
24
25/// Generate a process-wide unique id string with a stable component prefix.
26///
27/// Important: GPUI interactive state is keyed by `ElementId`, so call this only
28/// when constructing a persistent component/entity instance. Do not call it from
29/// a per-frame `render` path for a `RenderOnce` component, because that would
30/// assign a new ID every frame and break hover/click/portal state.
31pub fn unique_id(prefix: &str) -> gpui::SharedString {
32    format!("{}-{}", prefix, next_unique_id()).into()
33}
34
35/// Return a stable process-wide unique id for the current element path.
36///
37/// This is safe inside render paths because GPUI stores the generated value in
38/// keyed element state and reuses it for the same element across frames. The
39/// `key` must itself be stable for the visual element being rendered.
40pub fn stable_unique_id(
41    key: impl Into<gpui::SharedString>,
42    prefix: &str,
43    window: &mut Window,
44    cx: &mut App,
45) -> gpui::SharedString {
46    let prefix = prefix.to_string();
47    let key = gpui::ElementId::from(key.into());
48    window
49        .use_keyed_state(key, cx, move |_, _| unique_id(&prefix))
50        .read(cx)
51        .clone()
52}
53
54/// Documents and exposes the popper module APIs.
55pub mod popper;
56
57pub use popper::*;
58
59pub use liora_theme::Theme;
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62/// Options that control theme mode behavior.
63pub enum ThemeMode {
64    #[default]
65    /// Follows the operating system appearance when resolving the active theme.
66    System,
67    /// Forces the light Liora theme regardless of system appearance.
68    Light,
69    /// Forces the dark Liora theme regardless of system appearance.
70    Dark,
71}
72
73impl ThemeMode {
74    /// Returns the stable user-facing label for this value.
75    pub fn label(self) -> &'static str {
76        match self {
77            Self::System => "System",
78            Self::Light => "Light",
79            Self::Dark => "Dark",
80        }
81    }
82
83    /// Returns the serialized value used by forms, configuration, or persistence.
84    pub fn value(self) -> &'static str {
85        match self {
86            Self::System => "system",
87            Self::Light => "light",
88            Self::Dark => "dark",
89        }
90    }
91
92    /// Parses a serialized value into the corresponding strongly typed option.
93    pub fn from_value(value: &str) -> Option<Self> {
94        match value {
95            "system" => Some(Self::System),
96            "light" => Some(Self::Light),
97            "dark" => Some(Self::Dark),
98            _ => None,
99        }
100    }
101
102    /// Resolves this mode into a concrete light or dark theme.
103    pub fn resolve(self, appearance: WindowAppearance) -> Theme {
104        match self {
105            Self::System => theme_for_window_appearance(appearance),
106            Self::Light => Theme::light(),
107            Self::Dark => Theme::dark(),
108        }
109    }
110
111    /// Creates this value from theme.
112    pub fn from_theme(theme: &Theme) -> Self {
113        match theme.name.as_str() {
114            "dark" => Self::Dark,
115            _ => Self::Light,
116        }
117    }
118}
119
120/// Return startup bounds for a window that should request GPUI maximized state.
121///
122/// This helper is intentionally limited to stable crates.io GPUI APIs. It
123/// preserves the caller's `WindowBounds::Maximized` intent and uses the primary
124/// display bounds as the restore/fallback geometry; exact first-frame behavior
125/// is decided by the GPUI backend selected by the application root.
126pub fn startup_maximized_window_bounds(
127    cx: &App,
128    fallback_size: gpui::Size<Pixels>,
129) -> WindowBounds {
130    let bounds = cx
131        .primary_display()
132        .map(|display| display.bounds())
133        .unwrap_or(Bounds {
134            origin: gpui::Point::default(),
135            size: fallback_size,
136        });
137    WindowBounds::Maximized(bounds)
138}
139
140/// Metadata and icon bytes used to publish a Linux desktop identity.
141///
142/// Wayland does not let a client set a titlebar or taskbar icon directly.
143/// Compositors resolve the icon by matching the window `app_id` to a desktop
144/// entry and then loading that entry's `Icon=` name from the icon theme. Liora
145/// apps call [`ensure_linux_desktop_identity`] before opening their first GPUI
146/// window so `cargo run -p <app>` gets the same icon identity as an installed
147/// package without requiring root privileges.
148#[derive(Clone, Debug)]
149pub struct LinuxDesktopIdentity<'a> {
150    /// Stable GPUI/Wayland app id and desktop/icon filename stem.
151    pub app_id: &'a str,
152    /// Complete `.desktop` file contents for this app.
153    pub desktop_entry: Cow<'a, str>,
154    /// PNG icon bytes installed into hicolor size directories.
155    pub png_icons: &'a [LinuxDesktopPngIcon<'a>],
156    /// SVG icon bytes installed into hicolor scalable apps.
157    pub svg_icon: &'a [u8],
158}
159
160/// PNG icon bytes for one hicolor app-icon size bucket.
161#[derive(Clone, Copy, Debug)]
162pub struct LinuxDesktopPngIcon<'a> {
163    /// Square icon size in logical pixels.
164    pub size: u16,
165    /// PNG bytes for this size, normally 8-bit RGBA for maximum shell support.
166    pub bytes: &'a [u8],
167}
168
169/// Builds a user-level Linux desktop entry for a running Liora app.
170///
171/// Packaged installers use the static files under `packaging/linux`. Direct
172/// development runs should instead register the currently running executable,
173/// because `TryExec=<binary>` entries can be ignored when `cargo run` launches a
174/// target/debug binary that is not on `PATH`.
175pub fn linux_desktop_entry(
176    name: &str,
177    generic_name: &str,
178    comment: &str,
179    executable: &std::path::Path,
180    icon: &str,
181    categories: &str,
182    keywords: &str,
183) -> String {
184    format!(
185        "[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",
186        desktop_exec_value(executable),
187    )
188}
189
190/// Returns the user-scoped hicolor app-icon path for an app id and icon size.
191///
192/// Use this when generating a development `.desktop` entry that should bypass
193/// desktop-environment icon-theme cache latency by pointing `Icon=` at the
194/// exact PNG file Liora registers.
195pub fn linux_desktop_png_icon_path(app_id: &str, size: u16) -> Option<std::path::PathBuf> {
196    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
197    {
198        linux_xdg_data_home().ok().map(|data_home| {
199            data_home
200                .join("icons")
201                .join("hicolor")
202                .join(format!("{size}x{size}"))
203                .join("apps")
204                .join(format!("{app_id}.png"))
205        })
206    }
207
208    #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
209    {
210        let _ = (app_id, size);
211        None
212    }
213}
214
215fn desktop_exec_value(executable: &std::path::Path) -> String {
216    let value = executable.to_string_lossy();
217    if value
218        .chars()
219        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '+'))
220    {
221        value.into_owned()
222    } else {
223        format!("\"{}\"", value.replace('\\', "\\\\").replace('\"', "\\\""))
224    }
225}
226
227/// Installs a user-scoped Linux desktop entry and hicolor icons for an app.
228///
229/// The operation is intentionally best-effort and idempotent: it writes only to
230/// `$XDG_DATA_HOME` or `~/.local/share`, skips non-Linux targets, and returns an
231/// error instead of panicking when the host environment has no usable home/data
232/// directory. Applications should call this before creating their first window
233/// and log failures, because the app can still run when icon registration is
234/// unavailable.
235pub fn ensure_linux_desktop_identity(identity: LinuxDesktopIdentity<'_>) -> std::io::Result<()> {
236    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
237    {
238        ensure_linux_desktop_identity_impl(linux_xdg_data_home()?, identity, true)
239    }
240
241    #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
242    {
243        let _ = identity;
244        Ok(())
245    }
246}
247
248#[cfg(any(target_os = "linux", target_os = "freebsd"))]
249fn ensure_linux_desktop_identity_impl(
250    data_home: std::path::PathBuf,
251    identity: LinuxDesktopIdentity<'_>,
252    refresh_cache: bool,
253) -> std::io::Result<()> {
254    let mut changed = write_if_changed(
255        &data_home
256            .join("applications")
257            .join(format!("{}.desktop", identity.app_id)),
258        identity.desktop_entry.as_bytes(),
259    )?;
260    for icon in identity.png_icons {
261        changed |= write_if_changed(
262            &data_home
263                .join("icons")
264                .join("hicolor")
265                .join(format!("{}x{}", icon.size, icon.size))
266                .join("apps")
267                .join(format!("{}.png", identity.app_id)),
268            icon.bytes,
269        )?;
270    }
271    changed |= write_if_changed(
272        &data_home
273            .join("icons")
274            .join("hicolor")
275            .join("scalable")
276            .join("apps")
277            .join(format!("{}.svg", identity.app_id)),
278        identity.svg_icon,
279    )?;
280    changed |= ensure_hicolor_index_theme(&data_home)?;
281    if changed && refresh_cache {
282        refresh_linux_desktop_identity_cache(&data_home);
283    }
284    Ok(())
285}
286
287#[cfg(any(target_os = "linux", target_os = "freebsd"))]
288fn refresh_linux_desktop_identity_cache(data_home: &std::path::Path) {
289    let apps_dir = data_home.join("applications");
290    let _ = std::fs::remove_file(
291        data_home
292            .join("icons")
293            .join("hicolor")
294            .join(".icon-theme.cache"),
295    );
296    let _ = std::process::Command::new("update-desktop-database")
297        .arg(&apps_dir)
298        .status();
299    for command in ["kbuildsycoca6", "kbuildsycoca5"] {
300        let _ = std::process::Command::new(command)
301            .arg("--noincremental")
302            .status();
303    }
304}
305
306#[cfg(any(target_os = "linux", target_os = "freebsd"))]
307fn ensure_hicolor_index_theme(data_home: &std::path::Path) -> std::io::Result<bool> {
308    write_if_changed(
309        &data_home.join("icons").join("hicolor").join("index.theme"),
310        HICOLOR_INDEX_THEME.as_bytes(),
311    )
312}
313
314#[cfg(any(target_os = "linux", target_os = "freebsd"))]
315const 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";
316
317#[cfg(any(target_os = "linux", target_os = "freebsd"))]
318fn linux_xdg_data_home() -> std::io::Result<std::path::PathBuf> {
319    if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") {
320        return Ok(std::path::PathBuf::from(data_home));
321    }
322    std::env::var_os("HOME")
323        .map(std::path::PathBuf::from)
324        .map(|home| home.join(".local").join("share"))
325        .ok_or_else(|| {
326            std::io::Error::new(
327                std::io::ErrorKind::NotFound,
328                "neither XDG_DATA_HOME nor HOME is set",
329            )
330        })
331}
332
333#[cfg(any(target_os = "linux", target_os = "freebsd"))]
334fn write_if_changed(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<bool> {
335    if std::fs::read(path).is_ok_and(|existing| existing == bytes) {
336        return Ok(false);
337    }
338    if let Some(parent) = path.parent() {
339        std::fs::create_dir_all(parent)?;
340    }
341    std::fs::write(path, bytes)?;
342    Ok(true)
343}
344
345fn startup_system_appearance(cx: &App) -> WindowAppearance {
346    platform_system_appearance().unwrap_or_else(|| cx.window_appearance())
347}
348
349fn current_system_appearance(window: &Window, _cx: &App) -> WindowAppearance {
350    platform_system_appearance().unwrap_or_else(|| window.appearance())
351}
352
353#[cfg(any(target_os = "linux", target_os = "freebsd"))]
354fn platform_system_appearance() -> Option<WindowAppearance> {
355    gtk_theme_env_appearance()
356        .or_else(gtk_settings_appearance)
357        .or_else(gsettings_color_scheme_appearance)
358}
359
360#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
361fn platform_system_appearance() -> Option<WindowAppearance> {
362    None
363}
364
365#[cfg(any(target_os = "linux", target_os = "freebsd"))]
366fn gtk_theme_env_appearance() -> Option<WindowAppearance> {
367    std::env::var("GTK_THEME")
368        .ok()
369        .and_then(|theme| appearance_from_theme_name(&theme))
370}
371
372#[cfg(any(target_os = "linux", target_os = "freebsd"))]
373fn gtk_settings_appearance() -> Option<WindowAppearance> {
374    ["gtk-4.0", "gtk-3.0"]
375        .into_iter()
376        .filter_map(|version| {
377            std::env::var_os("HOME").map(|home| {
378                std::path::PathBuf::from(home)
379                    .join(".config")
380                    .join(version)
381                    .join("settings.ini")
382            })
383        })
384        .filter_map(|path| std::fs::read_to_string(path).ok())
385        .find_map(|settings| appearance_from_gtk_settings(&settings))
386}
387
388#[cfg(any(target_os = "linux", target_os = "freebsd"))]
389fn gsettings_color_scheme_appearance() -> Option<WindowAppearance> {
390    let output = std::process::Command::new("gsettings")
391        .args(["get", "org.gnome.desktop.interface", "color-scheme"])
392        .output()
393        .ok()?;
394    if !output.status.success() {
395        return None;
396    }
397    let value = String::from_utf8_lossy(&output.stdout);
398    appearance_from_color_scheme(&value)
399}
400
401#[cfg(any(target_os = "linux", target_os = "freebsd"))]
402fn appearance_from_color_scheme(value: &str) -> Option<WindowAppearance> {
403    let value = value
404        .trim()
405        .trim_matches('\'')
406        .trim_matches('"')
407        .to_ascii_lowercase();
408    if value.contains("prefer-dark") {
409        Some(WindowAppearance::Dark)
410    } else if value.contains("prefer-light") || value == "default" {
411        Some(WindowAppearance::Light)
412    } else {
413        None
414    }
415}
416
417#[cfg(any(target_os = "linux", target_os = "freebsd"))]
418fn appearance_from_gtk_settings(settings: &str) -> Option<WindowAppearance> {
419    for line in settings.lines() {
420        let line = line.trim();
421        if line.starts_with('#') || line.starts_with(';') || line.is_empty() {
422            continue;
423        }
424        let Some((key, value)) = line.split_once('=') else {
425            continue;
426        };
427        let key = key.trim();
428        let value = value.trim();
429        if key == "gtk-application-prefer-dark-theme" {
430            return match value.to_ascii_lowercase().as_str() {
431                "true" | "1" => Some(WindowAppearance::Dark),
432                "false" | "0" => Some(WindowAppearance::Light),
433                _ => None,
434            };
435        }
436        if key == "gtk-theme-name"
437            && let Some(appearance) = appearance_from_theme_name(value)
438        {
439            return Some(appearance);
440        }
441    }
442    None
443}
444
445#[cfg(any(target_os = "linux", target_os = "freebsd"))]
446fn appearance_from_theme_name(theme: &str) -> Option<WindowAppearance> {
447    let theme = theme.to_ascii_lowercase();
448    if theme.contains("dark") {
449        Some(WindowAppearance::Dark)
450    } else if theme.contains("light") {
451        Some(WindowAppearance::Light)
452    } else {
453        None
454    }
455}
456
457/// Maps a GPUI window appearance to the matching Liora theme.
458pub fn theme_for_window_appearance(appearance: WindowAppearance) -> Theme {
459    match appearance {
460        WindowAppearance::Light | WindowAppearance::VibrantLight => Theme::light(),
461        WindowAppearance::Dark | WindowAppearance::VibrantDark => Theme::dark(),
462    }
463}
464
465/// Runtime state used by Liora config behavior.
466pub struct Config {
467    /// Active Liora theme tokens stored in GPUI global state.
468    pub theme: Theme,
469    /// Configured theme mode used to resolve light or dark tokens.
470    pub theme_mode: ThemeMode,
471    /// Base z-index offset for overlay layering.
472    pub z_index_base: u32,
473}
474
475impl Global for Config {}
476
477impl Config {
478    /// Updates the stored theme mode value and keeps the existing component identity.
479    pub fn set_theme_mode(&mut self, mode: ThemeMode, appearance: WindowAppearance) {
480        self.theme_mode = mode;
481        self.theme = mode.resolve(appearance);
482    }
483
484    /// Synchronizes the active theme from the current system/window appearance.
485    pub fn sync_system_theme(&mut self, appearance: WindowAppearance) -> bool {
486        if self.theme_mode != ThemeMode::System {
487            return false;
488        }
489        let theme = ThemeMode::System.resolve(appearance);
490        let changed = self.theme.name != theme.name;
491        self.theme = theme;
492        changed
493    }
494}
495
496/// Initializes Liora core state with an explicit concrete theme.
497pub fn init_liora(cx: &mut App, theme: Theme) {
498    let theme_mode = ThemeMode::from_theme(&theme);
499    cx.set_global(Config {
500        theme,
501        theme_mode,
502        z_index_base: 1000,
503    });
504    cx.set_global(crate::popper::ZIndexStack::default());
505    cx.set_global(crate::popper::ActiveTooltip(Vec::new()));
506    cx.set_global(crate::popper::ActivePopover(Vec::new()));
507    cx.set_global(crate::popper::ActiveModal(Vec::new()));
508    cx.set_global(crate::popper::ActiveDrawer(Vec::new()));
509}
510
511/// Initializes Liora core state from a theme mode, including system mode resolution.
512pub fn init_liora_with_mode(cx: &mut App, mode: ThemeMode) {
513    let appearance = startup_system_appearance(cx);
514    cx.set_global(Config {
515        theme: mode.resolve(appearance),
516        theme_mode: mode,
517        z_index_base: 1000,
518    });
519    cx.set_global(crate::popper::ZIndexStack::default());
520    cx.set_global(crate::popper::ActiveTooltip(Vec::new()));
521    cx.set_global(crate::popper::ActivePopover(Vec::new()));
522    cx.set_global(crate::popper::ActiveModal(Vec::new()));
523    cx.set_global(crate::popper::ActiveDrawer(Vec::new()));
524}
525
526/// Applies a new theme mode and refreshes the active GPUI window.
527pub fn apply_theme_mode(window: &mut Window, cx: &mut App, mode: ThemeMode) {
528    let appearance = current_system_appearance(window, cx);
529    cx.global_mut::<Config>().set_theme_mode(mode, appearance);
530    window.refresh();
531}
532
533/// Synchronizes the active theme from the current system/window appearance.
534pub fn sync_system_theme(window: &mut Window, cx: &mut App) {
535    let appearance = current_system_appearance(window, cx);
536    if cx.global_mut::<Config>().sync_system_theme(appearance) {
537        window.refresh();
538    }
539}
540
541/// Attach System theme tracking to a concrete GPUI window.
542///
543/// `init_liora_with_mode(cx, ThemeMode::System)` runs before a window exists and
544/// can only use the app-level appearance snapshot. Following Zed's main-window
545/// pattern, create the window with `WindowOptions { show: false, .. }`, call this
546/// at the start of the `open_window` callback before constructing the root view,
547/// then activate the returned window handle after `open_window` completes.
548pub fn attach_system_theme_observer(window: &mut Window, cx: &mut App) {
549    sync_system_theme(window, cx);
550    window
551        .observe_window_appearance(|window, cx| sync_system_theme(window, cx))
552        .detach();
553}
554
555/// Renders the render active popover in window layer into native GPUI elements.
556pub fn render_active_popover_in_window(_window: &mut gpui::Window, cx: &mut App) {
557    for entry in cx.global::<crate::popper::ActivePopover>().0.clone() {
558        push_portal(
559            move |_window, _cx| entry.view.clone().into_any_element(),
560            cx,
561        );
562    }
563}
564
565/// Renders the render active modal in window layer into native GPUI elements.
566pub fn render_active_modal_in_window(_window: &mut gpui::Window, cx: &mut App) {
567    for entry in cx.global::<crate::popper::ActiveModal>().0.clone() {
568        push_portal(
569            move |_window, _cx| entry.view.clone().into_any_element(),
570            cx,
571        );
572    }
573}
574
575/// Renders the render active drawer in window layer into native GPUI elements.
576pub fn render_active_drawer_in_window(_window: &mut gpui::Window, cx: &mut App) {
577    for entry in cx.global::<crate::popper::ActiveDrawer>().0.clone() {
578        push_portal(
579            move |_window, _cx| entry.view.clone().into_any_element(),
580            cx,
581        );
582    }
583}
584
585/// Renders the render active tooltip in window layer into native GPUI elements.
586pub fn render_active_tooltip_in_window(window: &mut gpui::Window, cx: &mut App) {
587    let mouse_pos = window.mouse_position();
588    cx.global_mut::<crate::popper::ActiveTooltip>()
589        .0
590        .retain(|data| data.anchor_bounds.contains(&mouse_pos));
591
592    let active = cx.global::<crate::popper::ActiveTooltip>().0.clone();
593    for (tooltip_index, data) in active.into_iter().enumerate() {
594        let theme = cx.global::<Config>().theme.clone();
595
596        // Measure text accurately
597        let font_size = px(theme.font_size.sm);
598        let text_style = window.text_style();
599        let run = TextRun {
600            len: data.content.len(),
601            font: text_style.font(),
602            color: theme.neutral.card,
603            background_color: None,
604            underline: None,
605            strikethrough: None,
606        };
607        let shaped_line =
608            window
609                .text_system()
610                .shape_line(data.content.clone(), font_size, &[run], None);
611
612        let padding_h = px(12.0);
613        let padding_v = px(4.0);
614        let line_height = window.line_height();
615        let content_size = gpui::Size {
616            width: shaped_line.width + padding_h * 2.0,
617            height: line_height + padding_v * 2.0,
618        };
619
620        push_passive_portal(
621            move |window, _cx| {
622                let viewport = Bounds {
623                    origin: gpui::Point::default(),
624                    size: window.viewport_size(),
625                };
626
627                let popper = Popper {
628                    anchor_bounds: data.anchor_bounds,
629                    placement: data.placement,
630                    offset: data.offset,
631                };
632
633                let (pos, _final_placement) =
634                    popper.calculate_position_with_flip(content_size, viewport);
635
636                gpui::div()
637                    .absolute()
638                    .cursor_default()
639                    .top(pos.y)
640                    .left(pos.x)
641                    .w(content_size.width)
642                    .h(content_size.height)
643                    .bg(theme.neutral.text_1)
644                    .text_color(theme.neutral.card)
645                    .px(padding_h)
646                    .flex()
647                    .items_center()
648                    .justify_center()
649                    .rounded(px(theme.radius.sm))
650                    .shadow_lg()
651                    .text_size(font_size)
652                    .child(data.content.clone())
653                    .with_animation(
654                        ("liora-tooltip-motion", tooltip_index),
655                        Animation::new(Duration::from_millis(220))
656                            .with_easing(gpui::ease_out_quint()),
657                        |tooltip, delta| tooltip.opacity(delta),
658                    )
659                    .into_any_element()
660            },
661            cx,
662        );
663    }
664}
665
666#[cfg(test)]
667mod theme_mode_tests {
668    use super::*;
669
670    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
671    #[test]
672    fn linux_startup_appearance_parses_synchronous_dark_preferences() {
673        assert_eq!(
674            appearance_from_color_scheme("'prefer-dark'"),
675            Some(WindowAppearance::Dark)
676        );
677        assert_eq!(
678            appearance_from_color_scheme("prefer-light"),
679            Some(WindowAppearance::Light)
680        );
681        assert_eq!(
682            appearance_from_gtk_settings(
683                "[Settings]\ngtk-application-prefer-dark-theme=true\ngtk-theme-name=Breeze\n"
684            ),
685            Some(WindowAppearance::Dark)
686        );
687        assert_eq!(
688            appearance_from_theme_name("Adwaita-dark"),
689            Some(WindowAppearance::Dark)
690        );
691    }
692
693    #[test]
694    fn theme_mode_values_and_labels_are_stable() {
695        assert_eq!(ThemeMode::System.value(), "system");
696        assert_eq!(ThemeMode::Light.label(), "Light");
697        assert_eq!(ThemeMode::from_value("dark"), Some(ThemeMode::Dark));
698        assert_eq!(ThemeMode::from_theme(&Theme::dark()), ThemeMode::Dark);
699        assert_eq!(ThemeMode::from_theme(&Theme::light()), ThemeMode::Light);
700        assert_eq!(ThemeMode::from_value("unknown"), None);
701    }
702
703    #[test]
704    fn system_theme_resolves_from_window_appearance() {
705        assert_eq!(
706            ThemeMode::System.resolve(WindowAppearance::Light).name,
707            Theme::light().name
708        );
709        assert_eq!(
710            ThemeMode::System
711                .resolve(WindowAppearance::VibrantDark)
712                .name,
713            Theme::dark().name
714        );
715    }
716
717    #[test]
718    fn config_syncs_only_in_system_mode() {
719        let mut config = Config {
720            theme: Theme::light(),
721            theme_mode: ThemeMode::Light,
722            z_index_base: 1000,
723        };
724        assert!(!config.sync_system_theme(WindowAppearance::Dark));
725        assert_eq!(config.theme.name, "light");
726
727        config.set_theme_mode(ThemeMode::System, WindowAppearance::Dark);
728        assert_eq!(config.theme.name, "dark");
729        assert!(!config.sync_system_theme(WindowAppearance::VibrantDark));
730        assert!(config.sync_system_theme(WindowAppearance::Light));
731        assert_eq!(config.theme.name, "light");
732    }
733
734    #[test]
735    fn system_theme_observer_syncs_immediately_and_stays_attached() {
736        let source = include_str!("lib.rs");
737        let start = source
738            .find("pub fn attach_system_theme_observer")
739            .expect("system theme observer helper should exist");
740        let body = &source[start
741            ..source[start..]
742                .find("pub fn render_active_popover_in_window")
743                .expect("next function should follow observer helper")
744                + start];
745
746        let sync_call = format!("{}(window, cx);", "sync_system_theme");
747        let observe_call = format!("{}", "observe_window_appearance");
748        let sync_index = body
749            .find(&sync_call)
750            .expect("observer helper should sync the current window appearance immediately");
751        let observe_index = body
752            .find(&observe_call)
753            .expect("observer helper should observe later appearance changes");
754        assert!(sync_index < observe_index);
755        assert!(body.contains(".detach();"));
756    }
757}
758
759#[cfg(test)]
760mod motion_tests {
761    #[test]
762    fn tooltip_rendering_uses_gpui_motion() {
763        let source = include_str!("lib.rs").split("#[cfg(test)]").next().unwrap();
764
765        assert!(source.contains("tooltip-motion"));
766        assert!(source.contains("with_animation("));
767    }
768}
769
770/// Returns the active Liora theme stored in the GPUI application context.
771pub fn liora_theme<'a, V>(cx: &'a Context<'a, V>) -> &'a Theme {
772    &cx.global::<Config>().theme
773}
774
775/// Convenience accessors for reading Liora theme data from GPUI contexts.
776pub trait ContextExt {
777    /// Returns the active Liora theme for the current context.
778    fn liora(&self) -> &Theme;
779}
780
781impl<'a, V> ContextExt for Context<'a, V> {
782    fn liora(&self) -> &Theme {
783        liora_theme(self)
784    }
785}
786
787/// Element extension points reserved for applying Liora-wide styling helpers.
788pub trait ElementExt {
789    /// Returns the active Liora theme for the current context.
790    fn liora(self, cx: &mut App) -> Self;
791}
792
793impl ElementExt for gpui::Div {
794    fn liora(self, _cx: &mut App) -> Self {
795        self
796    }
797}
798
799/// Returns the z-index reserved for popup overlays.
800pub fn z_index_popup<V>(cx: &Context<'_, V>) -> u32 {
801    cx.global::<Config>().z_index_base + 100
802}
803
804/// Returns the z-index reserved for modal overlays.
805pub fn z_index_modal<V>(cx: &Context<'_, V>) -> u32 {
806    cx.global::<Config>().z_index_base + 200
807}
808
809/// Returns the z-index reserved for notifications.
810pub fn z_index_notification<V>(cx: &Context<'_, V>) -> u32 {
811    cx.global::<Config>().z_index_base + 300
812}
813
814/// Returns the z-index reserved for tooltip overlays.
815pub fn z_index_tooltip<V>(cx: &Context<'_, V>) -> u32 {
816    cx.global::<Config>().z_index_base + 400
817}
818
819/// Converts a packed RGB integer into a GPUI HSLA color.
820pub fn hex_color(hex: u32) -> Hsla {
821    gpui::rgb(hex).into()
822}
823
824#[cfg(test)]
825mod unique_id_tests {
826    use super::*;
827
828    #[test]
829    fn generated_ids_are_prefixed_and_unique() {
830        let first = unique_id("component");
831        let second = unique_id("component");
832
833        assert!(first.as_ref().starts_with("component-"));
834        assert!(second.as_ref().starts_with("component-"));
835        assert_ne!(first, second);
836    }
837}
838
839#[cfg(test)]
840mod desktop_identity_tests {
841    use super::*;
842
843    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
844    #[test]
845    fn linux_desktop_identity_installs_desktop_entry_and_hicolor_icons() {
846        let temp_root = std::env::temp_dir().join(format!(
847            "liora-desktop-identity-test-{}",
848            std::process::id()
849        ));
850        let _ = std::fs::remove_dir_all(&temp_root);
851        std::fs::create_dir_all(&temp_root).expect("test temp root should be creatable");
852
853        ensure_linux_desktop_identity_impl(
854            temp_root.clone(),
855            LinuxDesktopIdentity {
856                app_id: "liora-test",
857                desktop_entry: Cow::Borrowed(
858                    "[Desktop Entry]\nType=Application\nName=Liora Test\nIcon=liora-test\n",
859                ),
860                png_icons: &[LinuxDesktopPngIcon {
861                    size: 48,
862                    bytes: b"png",
863                }],
864                svg_icon: b"svg",
865            },
866            false,
867        )
868        .expect("identity registration should write into the supplied XDG data home");
869
870        assert_eq!(
871            std::fs::read_to_string(temp_root.join("applications/liora-test.desktop"))
872                .expect("desktop entry should be installed"),
873            "[Desktop Entry]\nType=Application\nName=Liora Test\nIcon=liora-test\n"
874        );
875        assert_eq!(
876            std::fs::read(temp_root.join("icons/hicolor/48x48/apps/liora-test.png"))
877                .expect("PNG hicolor icon should be installed"),
878            b"png"
879        );
880        assert_eq!(
881            std::fs::read(temp_root.join("icons/hicolor/scalable/apps/liora-test.svg"))
882                .expect("SVG hicolor icon should be installed"),
883            b"svg"
884        );
885        assert!(
886            std::fs::read_to_string(temp_root.join("icons/hicolor/index.theme"))
887                .expect("hicolor index theme should be installed")
888                .contains("Directories=16x16/apps")
889        );
890
891        let _ = std::fs::remove_dir_all(temp_root);
892    }
893}