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
11pub fn next_unique_id() -> u64 {
13 NEXT_UNIQUE_ID.fetch_add(1, Ordering::Relaxed)
14}
15
16pub fn unique_id(prefix: &str) -> gpui::SharedString {
23 format!("{}-{}", prefix, next_unique_id()).into()
24}
25
26pub 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
101pub 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
306pub 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 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}