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