Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion rust/limux-host-linux/dev.limux.linux.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Name=Limux
Comment=GPU-accelerated terminal workspace manager
Exec=limux
TryExec=limux
Icon=limux
Icon=limux-os-dark
Terminal=false
Type=Application
Categories=Utility;TerminalEmulator;
Expand Down
Binary file modified rust/limux-host-linux/icons/app/128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified rust/limux-host-linux/icons/app/16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified rust/limux-host-linux/icons/app/256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified rust/limux-host-linux/icons/app/32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified rust/limux-host-linux/icons/app/512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rust/limux-host-linux/icons/app/os-dark/1024.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rust/limux-host-linux/icons/app/os-dark/128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rust/limux-host-linux/icons/app/os-dark/16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rust/limux-host-linux/icons/app/os-dark/256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rust/limux-host-linux/icons/app/os-dark/32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rust/limux-host-linux/icons/app/os-dark/512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rust/limux-host-linux/icons/app/os-light/128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rust/limux-host-linux/icons/app/os-light/16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rust/limux-host-linux/icons/app/os-light/256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rust/limux-host-linux/icons/app/os-light/32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rust/limux-host-linux/icons/app/os-light/512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
312 changes: 305 additions & 7 deletions rust/limux-host-linux/src/window.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command as ProcessCommand, Stdio};
use std::rc::Rc;

use adw::prelude::*;
Expand Down Expand Up @@ -28,6 +30,10 @@ use crate::split_tree::{self, SplitTreeContainer};

const PANE_CREATE_COMMAND_READY_INTERVAL_MS: u64 = 50;
const PANE_CREATE_COMMAND_READY_ATTEMPTS: u32 = 40;
const APP_ICON_FOR_DARK_OS: &str = "limux-os-dark";
const APP_ICON_FOR_LIGHT_OS: &str = "limux-os-light";
const APP_ICON_LAUNCHER_NAME: &str = "limux";
const APP_ICON_SIZES: &[u16] = &[16, 32, 128, 256, 512];

// ---------------------------------------------------------------------------
// State
Expand Down Expand Up @@ -1400,10 +1406,21 @@ pub fn build_window(app: &adw::Application) {
}
}

let initial_app_icon_name =
app_icon_name_for_system_theme(system_prefers_dark.get(), style_manager.is_dark());
gtk::Window::set_default_icon_name(initial_app_icon_name);
if let Err(err) = sync_launcher_icon_for_system_theme(initial_app_icon_name) {
eprintln!("limux: failed to sync launcher icon: {err}");
}
if let Err(err) = sync_desktop_entry_icon_for_system_theme(initial_app_icon_name) {
eprintln!("limux: failed to sync desktop entry icon: {err}");
}

let title = format!("Limux v{}", crate::VERSION);
let window = adw::ApplicationWindow::builder()
.application(app)
.title(&title)
.icon_name(initial_app_icon_name)
.default_width(1400)
.default_height(900)
.build();
Expand Down Expand Up @@ -1566,11 +1583,19 @@ pub fn build_window(app: &adw::Application) {
let state = state.clone();
let system_prefers_dark = system_prefers_dark.clone();
style_manager.connect_dark_notify(move |style_manager| {
let state = state.borrow();
sync_ghostty_color_scheme_for_config(
style_manager,
system_prefers_dark.get(),
&state.borrow().config.borrow().appearance,
&state.config.borrow().appearance,
);
if system_prefers_dark.get().is_none() {
sync_app_icon_for_system_theme(
&state.window,
system_prefers_dark.get(),
style_manager.is_dark(),
);
}
});
}

Expand Down Expand Up @@ -2361,11 +2386,13 @@ fn sync_system_prefers_dark_change(
}

system_prefers_dark.set(updated_preference);
let state = state.borrow();
sync_ghostty_color_scheme_for_config(
style_manager,
updated_preference,
&state.borrow().config.borrow().appearance,
&state.config.borrow().appearance,
);
sync_app_icon_for_system_theme(&state.window, updated_preference, style_manager.is_dark());
}

fn sync_portal_color_scheme_preference_change(
Expand Down Expand Up @@ -2768,6 +2795,196 @@ fn sync_ghostty_color_scheme_for_config(
crate::terminal::sync_color_scheme(dark);
}

fn app_icon_name_for_system_theme(
system_prefers_dark: Option<bool>,
fallback_dark: bool,
) -> &'static str {
if system_prefers_dark.unwrap_or(fallback_dark) {
APP_ICON_FOR_DARK_OS
} else {
APP_ICON_FOR_LIGHT_OS
}
}

fn sync_app_icon_for_system_theme(
window: &adw::ApplicationWindow,
system_prefers_dark: Option<bool>,
fallback_dark: bool,
) {
let icon_name = app_icon_name_for_system_theme(system_prefers_dark, fallback_dark);
gtk::Window::set_default_icon_name(icon_name);
window.set_icon_name(Some(icon_name));
if let Err(err) = sync_launcher_icon_for_system_theme(icon_name) {
eprintln!("limux: failed to sync launcher icon: {err}");
}
if let Err(err) = sync_desktop_entry_icon_for_system_theme(icon_name) {
eprintln!("limux: failed to sync desktop entry icon: {err}");
}
}

fn sync_launcher_icon_for_system_theme(icon_name: &str) -> Result<(), String> {
let user_data_dir =
dirs::data_dir().ok_or_else(|| "could not resolve user data directory".to_string())?;
let source_roots = launcher_icon_source_roots(&user_data_dir);
let mut changed = false;
let mut copied = 0usize;

for size in APP_ICON_SIZES {
let Some(source) = source_roots
.iter()
.map(|root| launcher_icon_path(root, *size, icon_name))
.find(|path| path.is_file())
else {
continue;
};
let dest = launcher_icon_path(&user_data_dir, *size, APP_ICON_LAUNCHER_NAME);
if copy_icon_if_changed(&source, &dest)? {
changed = true;
}
copied += 1;
}

if copied == 0 {
return Err(format!("no installed {icon_name} app icon variants found"));
}

if changed {
refresh_user_icon_cache(&user_data_dir);
}

Ok(())
}

fn launcher_icon_source_roots(user_data_dir: &Path) -> Vec<PathBuf> {
let mut roots = vec![user_data_dir.to_path_buf()];

let xdg_data_dirs = std::env::var_os("XDG_DATA_DIRS")
.unwrap_or_else(|| std::ffi::OsString::from("/usr/local/share:/usr/share"));
roots.extend(std::env::split_paths(&xdg_data_dirs));
roots.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")));

roots.into_iter().fold(Vec::new(), |mut unique, root| {
if !unique.contains(&root) {
unique.push(root);
}
unique
})
}

fn launcher_icon_path(root: &Path, size: u16, icon_name: &str) -> PathBuf {
root.join("icons")
.join("hicolor")
.join(format!("{size}x{size}"))
.join("apps")
.join(format!("{icon_name}.png"))
}

fn copy_icon_if_changed(source: &Path, dest: &Path) -> Result<bool, String> {
let source_bytes =
fs::read(source).map_err(|err| format!("failed to read {}: {err}", source.display()))?;
if fs::read(dest).is_ok_and(|dest_bytes| dest_bytes == source_bytes) {
return Ok(false);
}

if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
}
fs::write(dest, source_bytes)
.map_err(|err| format!("failed to write {}: {err}", dest.display()))?;
Ok(true)
}

fn refresh_user_icon_cache(user_data_dir: &Path) {
let hicolor_dir = user_data_dir.join("icons/hicolor");
let _ = ProcessCommand::new("gtk-update-icon-cache")
.args(["-f", "-t"])
.arg(hicolor_dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}

fn sync_desktop_entry_icon_for_system_theme(icon_name: &str) -> Result<(), String> {
let user_data_dir =
dirs::data_dir().ok_or_else(|| "could not resolve user data directory".to_string())?;
let desktop_file_name = format!("{}.desktop", crate::APP_ID);
let dest = user_data_dir.join("applications").join(&desktop_file_name);
let source = desktop_entry_source_candidates(&user_data_dir, &desktop_file_name)
.into_iter()
.find(|path| path.is_file())
.ok_or_else(|| format!("{desktop_file_name} was not found"))?;
let contents = fs::read_to_string(&source)
.map_err(|err| format!("failed to read {}: {err}", source.display()))?;
let updated = desktop_entry_with_icon(&contents, icon_name);

if fs::read_to_string(&dest).is_ok_and(|existing| existing == updated) {
return Ok(());
}

if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
}
fs::write(&dest, updated)
.map_err(|err| format!("failed to write {}: {err}", dest.display()))?;
refresh_user_desktop_database(&user_data_dir);
Ok(())
}

fn desktop_entry_source_candidates(user_data_dir: &Path, desktop_file_name: &str) -> Vec<PathBuf> {
let mut roots = vec![user_data_dir.to_path_buf()];

let xdg_data_dirs = std::env::var_os("XDG_DATA_DIRS")
.unwrap_or_else(|| std::ffi::OsString::from("/usr/local/share:/usr/share"));
roots.extend(std::env::split_paths(&xdg_data_dirs));

let mut candidates = roots
.into_iter()
.map(|root| root.join("applications").join(desktop_file_name))
.collect::<Vec<_>>();
candidates.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(desktop_file_name));

candidates.into_iter().fold(Vec::new(), |mut unique, path| {
if !unique.contains(&path) {
unique.push(path);
}
unique
})
}

fn desktop_entry_with_icon(contents: &str, icon_name: &str) -> String {
let mut replaced = false;
let mut updated = contents
.lines()
.map(|line| {
if !replaced && line.starts_with("Icon=") {
replaced = true;
format!("Icon={icon_name}")
} else {
line.to_string()
}
})
.collect::<Vec<_>>();

if !replaced {
updated.push(format!("Icon={icon_name}"));
}

let mut output = updated.join("\n");
output.push('\n');
output
}

fn refresh_user_desktop_database(user_data_dir: &Path) {
let applications_dir = user_data_dir.join("applications");
let _ = ProcessCommand::new("update-desktop-database")
.arg(applications_dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}

fn apply_appearance(
style_manager: &adw::StyleManager,
system_prefers_dark: Option<bool>,
Expand Down Expand Up @@ -5872,22 +6089,24 @@ mod tests {
use super::gtk::gdk;
use super::ToVariant;
use super::{
build_window_css, clamp_workspace_insert_index_for_pinning,
app_icon_name_for_system_theme, build_window_css, clamp_workspace_insert_index_for_pinning,
copy_icon_if_changed, desktop_entry_source_candidates, desktop_entry_with_icon,
desktop_notification_action_from_signal, desktop_notification_actions,
desktop_notification_activation_token_from_signal,
desktop_notification_closed_id_from_signal, desktop_notification_id_from_response,
directional_neighbor_score, favorites_prefix_len, font_size_after_delta,
ghostty_prefers_dark, gtk_system_prefers_dark_from_raw, next_active_workspace_index,
pane_create_split_placement, queue_session_save_request, resolve_pane_create_source_id,
resolved_system_prefers_dark, sanitize_background_opacity,
ghostty_prefers_dark, gtk_system_prefers_dark_from_raw, launcher_icon_path,
next_active_workspace_index, pane_create_split_placement, queue_session_save_request,
resolve_pane_create_source_id, resolved_system_prefers_dark, sanitize_background_opacity,
shortcut_allowed_while_browser_find_active, shortcut_blocked_by_editable,
shortcut_command_from_key_event, shortcut_dispatch_propagation,
should_emit_desktop_notification, tab_drag_workspace_seed, use_opaque_window_background,
validate_workspace_folder_input_with_dirs, workspace_drop_layout_path,
workspace_folder_path_from_input, workspace_notification_message, Direction,
EditableCaptureContext, NeighborScore, PaneBounds, PaneCreateDirection,
PaneCreateTargetError, PortalColorSchemePreference, SessionSaveAccess, SessionSaveRequest,
WorkspaceSeedSource, BASE_CSS, HOST_ENTRY_CSS_CLASS, WORKSPACE_RENAME_ENTRY_CSS_CLASS,
WorkspaceSeedSource, APP_ICON_FOR_DARK_OS, APP_ICON_FOR_LIGHT_OS, APP_ICON_LAUNCHER_NAME,
BASE_CSS, HOST_ENTRY_CSS_CLASS, WORKSPACE_RENAME_ENTRY_CSS_CLASS,
WORKSPACE_RENAME_ENTRY_CSS_CLASSES,
};
use crate::layout_state::{LayoutNodeState, PaneState, SplitOrientation, SplitState};
Expand Down Expand Up @@ -6273,6 +6492,85 @@ mod tests {
);
}

#[test]
fn app_icon_name_uses_os_theme_mapping() {
assert_eq!(
app_icon_name_for_system_theme(Some(true), false),
APP_ICON_FOR_DARK_OS
);
assert_eq!(
app_icon_name_for_system_theme(Some(false), true),
APP_ICON_FOR_LIGHT_OS
);
}

#[test]
fn app_icon_name_uses_fallback_when_os_theme_is_unknown() {
assert_eq!(
app_icon_name_for_system_theme(None, true),
APP_ICON_FOR_DARK_OS
);
assert_eq!(
app_icon_name_for_system_theme(None, false),
APP_ICON_FOR_LIGHT_OS
);
}

#[test]
fn launcher_icon_path_targets_hicolor_app_icon() {
assert_eq!(
launcher_icon_path(
std::path::Path::new("/home/tester/.local/share"),
512,
APP_ICON_LAUNCHER_NAME
),
std::path::PathBuf::from(
"/home/tester/.local/share/icons/hicolor/512x512/apps/limux.png"
)
);
}

#[test]
fn copy_icon_if_changed_replaces_stale_launcher_icon() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("source.png");
let dest = dir.path().join("nested/dest.png");
std::fs::write(&source, b"white-icon").unwrap();
std::fs::write(&dest, b"black-icon").unwrap_err();

assert!(copy_icon_if_changed(&source, &dest).unwrap());
assert_eq!(std::fs::read(&dest).unwrap(), b"white-icon");
assert!(!copy_icon_if_changed(&source, &dest).unwrap());
}

#[test]
fn desktop_entry_with_icon_replaces_first_icon_line() {
let updated = desktop_entry_with_icon(
"[Desktop Entry]\nName=Limux\nIcon=limux\nType=Application\n",
APP_ICON_FOR_DARK_OS,
);

assert_eq!(
updated,
"[Desktop Entry]\nName=Limux\nIcon=limux-os-dark\nType=Application\n"
);
}

#[test]
fn desktop_entry_source_candidates_include_user_and_dev_paths() {
let candidates = desktop_entry_source_candidates(
std::path::Path::new("/home/tester/.local/share"),
"dev.limux.linux.desktop",
);

assert!(candidates.contains(&std::path::PathBuf::from(
"/home/tester/.local/share/applications/dev.limux.linux.desktop"
)));
assert!(candidates.contains(
&std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("dev.limux.linux.desktop")
));
}

#[test]
fn ghostty_prefers_dark_uses_system_preference_when_requested() {
assert!(ghostty_prefers_dark(
Expand Down
Loading