1use 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
20pub fn next_unique_id() -> u64 {
22 NEXT_UNIQUE_ID.fetch_add(1, Ordering::Relaxed)
23}
24
25pub fn unique_id(prefix: &str) -> gpui::SharedString {
32 format!("{}-{}", prefix, next_unique_id()).into()
33}
34
35pub 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
54pub mod popper;
56
57pub use popper::*;
58
59pub use liora_theme::Theme;
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum ThemeMode {
64 #[default]
65 System,
67 Light,
69 Dark,
71}
72
73impl ThemeMode {
74 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 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 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 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 pub fn from_theme(theme: &Theme) -> Self {
113 match theme.name.as_str() {
114 "dark" => Self::Dark,
115 _ => Self::Light,
116 }
117 }
118}
119
120pub 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#[derive(Clone, Debug)]
149pub struct LinuxDesktopIdentity<'a> {
150 pub app_id: &'a str,
152 pub desktop_entry: Cow<'a, str>,
154 pub png_icons: &'a [LinuxDesktopPngIcon<'a>],
156 pub svg_icon: &'a [u8],
158}
159
160#[derive(Clone, Copy, Debug)]
162pub struct LinuxDesktopPngIcon<'a> {
163 pub size: u16,
165 pub bytes: &'a [u8],
167}
168
169pub 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
190pub 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
227pub 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
457pub 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
465pub struct Config {
467 pub theme: Theme,
469 pub theme_mode: ThemeMode,
471 pub z_index_base: u32,
473}
474
475impl Global for Config {}
476
477impl Config {
478 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 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
496pub 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
511pub 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
526pub 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
533pub 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
541pub 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
555pub 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
565pub 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
575pub 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
585pub 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 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
770pub fn liora_theme<'a, V>(cx: &'a Context<'a, V>) -> &'a Theme {
772 &cx.global::<Config>().theme
773}
774
775pub trait ContextExt {
777 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
787pub trait ElementExt {
789 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
799pub fn z_index_popup<V>(cx: &Context<'_, V>) -> u32 {
801 cx.global::<Config>().z_index_base + 100
802}
803
804pub fn z_index_modal<V>(cx: &Context<'_, V>) -> u32 {
806 cx.global::<Config>().z_index_base + 200
807}
808
809pub fn z_index_notification<V>(cx: &Context<'_, V>) -> u32 {
811 cx.global::<Config>().z_index_base + 300
812}
813
814pub fn z_index_tooltip<V>(cx: &Context<'_, V>) -> u32 {
816 cx.global::<Config>().z_index_base + 400
817}
818
819pub 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}