Skip to main content

liora_core/
lib.rs

1use gpui::{
2    Animation, AnimationExt, App, Bounds, Context, Global, Hsla, Pixels, TextRun, Window,
3    WindowAppearance, WindowBounds, prelude::*, px,
4};
5
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::Duration;
8
9static NEXT_UNIQUE_ID: AtomicU64 = AtomicU64::new(1);
10
11/// Generate a process-wide unique, monotonically increasing numeric id.
12pub fn next_unique_id() -> u64 {
13    NEXT_UNIQUE_ID.fetch_add(1, Ordering::Relaxed)
14}
15
16/// Generate a process-wide unique id string with a stable component prefix.
17///
18/// Important: GPUI interactive state is keyed by `ElementId`, so call this only
19/// when constructing a persistent component/entity instance. Do not call it from
20/// a per-frame `render` path for a `RenderOnce` component, because that would
21/// assign a new ID every frame and break hover/click/portal state.
22pub fn unique_id(prefix: &str) -> gpui::SharedString {
23    format!("{}-{}", prefix, next_unique_id()).into()
24}
25
26/// Return a stable process-wide unique id for the current element path.
27///
28/// This is safe inside render paths because GPUI stores the generated value in
29/// keyed element state and reuses it for the same element across frames. The
30/// `key` must itself be stable for the visual element being rendered.
31pub fn stable_unique_id(
32    key: impl Into<gpui::ElementId>,
33    prefix: &str,
34    window: &mut Window,
35    cx: &mut App,
36) -> gpui::SharedString {
37    let prefix = prefix.to_string();
38    window
39        .use_keyed_state(key, cx, move |_, _| unique_id(&prefix))
40        .read(cx)
41        .clone()
42}
43
44pub mod popper;
45
46pub use popper::*;
47
48pub use liora_theme::Theme;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum ThemeMode {
52    #[default]
53    System,
54    Light,
55    Dark,
56}
57
58impl ThemeMode {
59    pub fn label(self) -> &'static str {
60        match self {
61            Self::System => "System",
62            Self::Light => "Light",
63            Self::Dark => "Dark",
64        }
65    }
66
67    pub fn value(self) -> &'static str {
68        match self {
69            Self::System => "system",
70            Self::Light => "light",
71            Self::Dark => "dark",
72        }
73    }
74
75    pub fn from_value(value: &str) -> Option<Self> {
76        match value {
77            "system" => Some(Self::System),
78            "light" => Some(Self::Light),
79            "dark" => Some(Self::Dark),
80            _ => None,
81        }
82    }
83
84    pub fn resolve(self, appearance: WindowAppearance) -> Theme {
85        match self {
86            Self::System => theme_for_window_appearance(appearance),
87            Self::Light => Theme::light(),
88            Self::Dark => Theme::dark(),
89        }
90    }
91
92    pub fn from_theme(theme: &Theme) -> Self {
93        match theme.name.as_str() {
94            "dark" => Self::Dark,
95            _ => Self::Light,
96        }
97    }
98}
99
100/// Return startup bounds for a window that should appear maximized immediately.
101///
102/// Liora uses a patched GPUI build that carries `WindowBounds::Maximized` into
103/// the Linux platform window creation path, so Wayland requests maximization
104/// before the first `surface.commit()` and X11 sets `_NET_WM_STATE` before
105/// `MapWindow`. The bounds below remain the restore/fallback geometry for that
106/// maximized request and intentionally use the stable GPUI display-bounds API so
107/// SDK crates remain compatible with crates.io `gpui`.
108pub fn startup_maximized_window_bounds(
109    cx: &App,
110    fallback_size: gpui::Size<Pixels>,
111) -> WindowBounds {
112    let bounds = cx
113        .primary_display()
114        .map(|display| display.bounds())
115        .unwrap_or(Bounds {
116            origin: gpui::Point::default(),
117            size: fallback_size,
118        });
119    WindowBounds::Maximized(bounds)
120}
121
122fn startup_system_appearance(cx: &App) -> WindowAppearance {
123    platform_system_appearance().unwrap_or_else(|| cx.window_appearance())
124}
125
126fn current_system_appearance(window: &Window, _cx: &App) -> WindowAppearance {
127    platform_system_appearance().unwrap_or_else(|| window.appearance())
128}
129
130#[cfg(any(target_os = "linux", target_os = "freebsd"))]
131fn platform_system_appearance() -> Option<WindowAppearance> {
132    gtk_theme_env_appearance()
133        .or_else(gtk_settings_appearance)
134        .or_else(gsettings_color_scheme_appearance)
135}
136
137#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
138fn platform_system_appearance() -> Option<WindowAppearance> {
139    None
140}
141
142#[cfg(any(target_os = "linux", target_os = "freebsd"))]
143fn gtk_theme_env_appearance() -> Option<WindowAppearance> {
144    std::env::var("GTK_THEME")
145        .ok()
146        .and_then(|theme| appearance_from_theme_name(&theme))
147}
148
149#[cfg(any(target_os = "linux", target_os = "freebsd"))]
150fn gtk_settings_appearance() -> Option<WindowAppearance> {
151    ["gtk-4.0", "gtk-3.0"]
152        .into_iter()
153        .filter_map(|version| {
154            std::env::var_os("HOME").map(|home| {
155                std::path::PathBuf::from(home)
156                    .join(".config")
157                    .join(version)
158                    .join("settings.ini")
159            })
160        })
161        .filter_map(|path| std::fs::read_to_string(path).ok())
162        .find_map(|settings| appearance_from_gtk_settings(&settings))
163}
164
165#[cfg(any(target_os = "linux", target_os = "freebsd"))]
166fn gsettings_color_scheme_appearance() -> Option<WindowAppearance> {
167    let output = std::process::Command::new("gsettings")
168        .args(["get", "org.gnome.desktop.interface", "color-scheme"])
169        .output()
170        .ok()?;
171    if !output.status.success() {
172        return None;
173    }
174    let value = String::from_utf8_lossy(&output.stdout);
175    appearance_from_color_scheme(&value)
176}
177
178#[cfg(any(target_os = "linux", target_os = "freebsd"))]
179fn appearance_from_color_scheme(value: &str) -> Option<WindowAppearance> {
180    let value = value
181        .trim()
182        .trim_matches('\'')
183        .trim_matches('"')
184        .to_ascii_lowercase();
185    if value.contains("prefer-dark") {
186        Some(WindowAppearance::Dark)
187    } else if value.contains("prefer-light") || value == "default" {
188        Some(WindowAppearance::Light)
189    } else {
190        None
191    }
192}
193
194#[cfg(any(target_os = "linux", target_os = "freebsd"))]
195fn appearance_from_gtk_settings(settings: &str) -> Option<WindowAppearance> {
196    for line in settings.lines() {
197        let line = line.trim();
198        if line.starts_with('#') || line.starts_with(';') || line.is_empty() {
199            continue;
200        }
201        let Some((key, value)) = line.split_once('=') else {
202            continue;
203        };
204        let key = key.trim();
205        let value = value.trim();
206        if key == "gtk-application-prefer-dark-theme" {
207            return match value.to_ascii_lowercase().as_str() {
208                "true" | "1" => Some(WindowAppearance::Dark),
209                "false" | "0" => Some(WindowAppearance::Light),
210                _ => None,
211            };
212        }
213        if key == "gtk-theme-name"
214            && let Some(appearance) = appearance_from_theme_name(value)
215        {
216            return Some(appearance);
217        }
218    }
219    None
220}
221
222#[cfg(any(target_os = "linux", target_os = "freebsd"))]
223fn appearance_from_theme_name(theme: &str) -> Option<WindowAppearance> {
224    let theme = theme.to_ascii_lowercase();
225    if theme.contains("dark") {
226        Some(WindowAppearance::Dark)
227    } else if theme.contains("light") {
228        Some(WindowAppearance::Light)
229    } else {
230        None
231    }
232}
233
234pub fn theme_for_window_appearance(appearance: WindowAppearance) -> Theme {
235    match appearance {
236        WindowAppearance::Light | WindowAppearance::VibrantLight => Theme::light(),
237        WindowAppearance::Dark | WindowAppearance::VibrantDark => Theme::dark(),
238    }
239}
240
241pub struct Config {
242    pub theme: Theme,
243    pub theme_mode: ThemeMode,
244    pub z_index_base: u32,
245}
246
247impl Global for Config {}
248
249impl Config {
250    pub fn set_theme_mode(&mut self, mode: ThemeMode, appearance: WindowAppearance) {
251        self.theme_mode = mode;
252        self.theme = mode.resolve(appearance);
253    }
254
255    pub fn sync_system_theme(&mut self, appearance: WindowAppearance) -> bool {
256        if self.theme_mode != ThemeMode::System {
257            return false;
258        }
259        let theme = ThemeMode::System.resolve(appearance);
260        let changed = self.theme.name != theme.name;
261        self.theme = theme;
262        changed
263    }
264}
265
266pub fn init_liora(cx: &mut App, theme: Theme) {
267    let theme_mode = ThemeMode::from_theme(&theme);
268    cx.set_global(Config {
269        theme,
270        theme_mode,
271        z_index_base: 1000,
272    });
273    cx.set_global(crate::popper::ZIndexStack::default());
274    cx.set_global(crate::popper::ActiveTooltip(Vec::new()));
275    cx.set_global(crate::popper::ActivePopover(Vec::new()));
276    cx.set_global(crate::popper::ActiveModal(Vec::new()));
277    cx.set_global(crate::popper::ActiveDrawer(Vec::new()));
278}
279
280pub fn init_liora_with_mode(cx: &mut App, mode: ThemeMode) {
281    let appearance = startup_system_appearance(cx);
282    cx.set_global(Config {
283        theme: mode.resolve(appearance),
284        theme_mode: mode,
285        z_index_base: 1000,
286    });
287    cx.set_global(crate::popper::ZIndexStack::default());
288    cx.set_global(crate::popper::ActiveTooltip(Vec::new()));
289    cx.set_global(crate::popper::ActivePopover(Vec::new()));
290    cx.set_global(crate::popper::ActiveModal(Vec::new()));
291    cx.set_global(crate::popper::ActiveDrawer(Vec::new()));
292}
293
294pub fn apply_theme_mode(window: &mut Window, cx: &mut App, mode: ThemeMode) {
295    let appearance = current_system_appearance(window, cx);
296    cx.global_mut::<Config>().set_theme_mode(mode, appearance);
297    window.refresh();
298}
299
300pub fn sync_system_theme(window: &mut Window, cx: &mut App) {
301    let appearance = current_system_appearance(window, cx);
302    if cx.global_mut::<Config>().sync_system_theme(appearance) {
303        window.refresh();
304    }
305}
306
307/// Attach System theme tracking to a concrete GPUI window.
308///
309/// `init_liora_with_mode(cx, ThemeMode::System)` runs before a window exists and
310/// can only use the app-level appearance snapshot. Following Zed's main-window
311/// pattern, create the window with `WindowOptions { show: false, .. }`, call this
312/// at the start of the `open_window` callback before constructing the root view,
313/// then activate the returned window handle after `open_window` completes.
314pub fn attach_system_theme_observer(window: &mut Window, cx: &mut App) {
315    sync_system_theme(window, cx);
316    window
317        .observe_window_appearance(|window, cx| sync_system_theme(window, cx))
318        .detach();
319}
320
321pub fn render_active_popover_in_window(_window: &mut gpui::Window, cx: &mut App) {
322    for entry in cx.global::<crate::popper::ActivePopover>().0.clone() {
323        push_portal(
324            move |_window, _cx| entry.view.clone().into_any_element(),
325            cx,
326        );
327    }
328}
329
330pub fn render_active_modal_in_window(_window: &mut gpui::Window, cx: &mut App) {
331    for entry in cx.global::<crate::popper::ActiveModal>().0.clone() {
332        push_portal(
333            move |_window, _cx| entry.view.clone().into_any_element(),
334            cx,
335        );
336    }
337}
338
339pub fn render_active_drawer_in_window(_window: &mut gpui::Window, cx: &mut App) {
340    for entry in cx.global::<crate::popper::ActiveDrawer>().0.clone() {
341        push_portal(
342            move |_window, _cx| entry.view.clone().into_any_element(),
343            cx,
344        );
345    }
346}
347
348pub fn render_active_tooltip_in_window(window: &mut gpui::Window, cx: &mut App) {
349    let mouse_pos = window.mouse_position();
350    cx.global_mut::<crate::popper::ActiveTooltip>()
351        .0
352        .retain(|data| data.anchor_bounds.contains(&mouse_pos));
353
354    let active = cx.global::<crate::popper::ActiveTooltip>().0.clone();
355    for (tooltip_index, data) in active.into_iter().enumerate() {
356        let theme = cx.global::<Config>().theme.clone();
357
358        // Measure text accurately
359        let font_size = px(theme.font_size.sm);
360        let text_style = window.text_style();
361        let run = TextRun {
362            len: data.content.len(),
363            font: text_style.font(),
364            color: theme.neutral.card,
365            background_color: None,
366            underline: None,
367            strikethrough: None,
368        };
369        let shaped_line =
370            window
371                .text_system()
372                .shape_line(data.content.clone(), font_size, &[run], None);
373
374        let padding_h = px(12.0);
375        let padding_v = px(4.0);
376        let line_height = window.line_height();
377        let content_size = gpui::Size {
378            width: shaped_line.width + padding_h * 2.0,
379            height: line_height + padding_v * 2.0,
380        };
381
382        push_passive_portal(
383            move |window, _cx| {
384                let viewport = Bounds {
385                    origin: gpui::Point::default(),
386                    size: window.viewport_size(),
387                };
388
389                let popper = Popper {
390                    anchor_bounds: data.anchor_bounds,
391                    placement: data.placement,
392                    offset: data.offset,
393                };
394
395                let (pos, _final_placement) =
396                    popper.calculate_position_with_flip(content_size, viewport);
397
398                gpui::div()
399                    .absolute()
400                    .cursor_default()
401                    .top(pos.y)
402                    .left(pos.x)
403                    .w(content_size.width)
404                    .h(content_size.height)
405                    .bg(theme.neutral.text_1)
406                    .text_color(theme.neutral.card)
407                    .px(padding_h)
408                    .flex()
409                    .items_center()
410                    .justify_center()
411                    .rounded(px(theme.radius.sm))
412                    .shadow_lg()
413                    .text_size(font_size)
414                    .child(data.content.clone())
415                    .with_animation(
416                        ("liora-tooltip-motion", tooltip_index),
417                        Animation::new(Duration::from_millis(220))
418                            .with_easing(gpui::ease_out_quint()),
419                        |tooltip, delta| tooltip.opacity(delta),
420                    )
421                    .into_any_element()
422            },
423            cx,
424        );
425    }
426}
427
428#[cfg(test)]
429mod theme_mode_tests {
430    use super::*;
431
432    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
433    #[test]
434    fn linux_startup_appearance_parses_synchronous_dark_preferences() {
435        assert_eq!(
436            appearance_from_color_scheme("'prefer-dark'"),
437            Some(WindowAppearance::Dark)
438        );
439        assert_eq!(
440            appearance_from_color_scheme("prefer-light"),
441            Some(WindowAppearance::Light)
442        );
443        assert_eq!(
444            appearance_from_gtk_settings(
445                "[Settings]\ngtk-application-prefer-dark-theme=true\ngtk-theme-name=Breeze\n"
446            ),
447            Some(WindowAppearance::Dark)
448        );
449        assert_eq!(
450            appearance_from_theme_name("Adwaita-dark"),
451            Some(WindowAppearance::Dark)
452        );
453    }
454
455    #[test]
456    fn theme_mode_values_and_labels_are_stable() {
457        assert_eq!(ThemeMode::System.value(), "system");
458        assert_eq!(ThemeMode::Light.label(), "Light");
459        assert_eq!(ThemeMode::from_value("dark"), Some(ThemeMode::Dark));
460        assert_eq!(ThemeMode::from_theme(&Theme::dark()), ThemeMode::Dark);
461        assert_eq!(ThemeMode::from_theme(&Theme::light()), ThemeMode::Light);
462        assert_eq!(ThemeMode::from_value("unknown"), None);
463    }
464
465    #[test]
466    fn system_theme_resolves_from_window_appearance() {
467        assert_eq!(
468            ThemeMode::System.resolve(WindowAppearance::Light).name,
469            Theme::light().name
470        );
471        assert_eq!(
472            ThemeMode::System
473                .resolve(WindowAppearance::VibrantDark)
474                .name,
475            Theme::dark().name
476        );
477    }
478
479    #[test]
480    fn config_syncs_only_in_system_mode() {
481        let mut config = Config {
482            theme: Theme::light(),
483            theme_mode: ThemeMode::Light,
484            z_index_base: 1000,
485        };
486        assert!(!config.sync_system_theme(WindowAppearance::Dark));
487        assert_eq!(config.theme.name, "light");
488
489        config.set_theme_mode(ThemeMode::System, WindowAppearance::Dark);
490        assert_eq!(config.theme.name, "dark");
491        assert!(!config.sync_system_theme(WindowAppearance::VibrantDark));
492        assert!(config.sync_system_theme(WindowAppearance::Light));
493        assert_eq!(config.theme.name, "light");
494    }
495
496    #[test]
497    fn system_theme_observer_syncs_immediately_and_stays_attached() {
498        let source = include_str!("lib.rs");
499        let start = source
500            .find("pub fn attach_system_theme_observer")
501            .expect("system theme observer helper should exist");
502        let body = &source[start
503            ..source[start..]
504                .find("pub fn render_active_popover_in_window")
505                .expect("next function should follow observer helper")
506                + start];
507
508        let sync_call = format!("{}(window, cx);", "sync_system_theme");
509        let observe_call = format!("{}", "observe_window_appearance");
510        let sync_index = body
511            .find(&sync_call)
512            .expect("observer helper should sync the current window appearance immediately");
513        let observe_index = body
514            .find(&observe_call)
515            .expect("observer helper should observe later appearance changes");
516        assert!(sync_index < observe_index);
517        assert!(body.contains(".detach();"));
518    }
519}
520
521#[cfg(test)]
522mod motion_tests {
523    #[test]
524    fn tooltip_rendering_uses_gpui_motion() {
525        let source = include_str!("lib.rs").split("#[cfg(test)]").next().unwrap();
526
527        assert!(source.contains("tooltip-motion"));
528        assert!(source.contains("with_animation("));
529    }
530}
531
532pub fn liora_theme<'a, V>(cx: &'a Context<'a, V>) -> &'a Theme {
533    &cx.global::<Config>().theme
534}
535
536pub trait ContextExt {
537    fn liora(&self) -> &Theme;
538}
539
540impl<'a, V> ContextExt for Context<'a, V> {
541    fn liora(&self) -> &Theme {
542        liora_theme(self)
543    }
544}
545
546pub trait ElementExt {
547    fn liora(self, cx: &mut App) -> Self;
548}
549
550impl ElementExt for gpui::Div {
551    fn liora(self, _cx: &mut App) -> Self {
552        self
553    }
554}
555
556pub fn z_index_popup<V>(cx: &Context<'_, V>) -> u32 {
557    cx.global::<Config>().z_index_base + 100
558}
559
560pub fn z_index_modal<V>(cx: &Context<'_, V>) -> u32 {
561    cx.global::<Config>().z_index_base + 200
562}
563
564pub fn z_index_notification<V>(cx: &Context<'_, V>) -> u32 {
565    cx.global::<Config>().z_index_base + 300
566}
567
568pub fn z_index_tooltip<V>(cx: &Context<'_, V>) -> u32 {
569    cx.global::<Config>().z_index_base + 400
570}
571
572pub fn hex_color(hex: u32) -> Hsla {
573    gpui::rgb(hex).into()
574}
575
576#[cfg(test)]
577mod unique_id_tests {
578    use super::*;
579
580    #[test]
581    fn generated_ids_are_prefixed_and_unique() {
582        let first = unique_id("component");
583        let second = unique_id("component");
584
585        assert!(first.as_ref().starts_with("component-"));
586        assert!(second.as_ref().starts_with("component-"));
587        assert_ne!(first, second);
588    }
589}