use gtk::prelude::*;
use gtk::{gdk, gdk_pixbuf, gio, glib, pango};
use std::cell::{Cell, OnceCell, RefCell};
use std::collections::VecDeque;
use std::env;
use std::ffi::{OsStr, OsString};
use std::fs::{self, File, OpenOptions};
use std::io::{BufReader, Read, Seek, SeekFrom, Write};
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt};
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use vte::prelude::*;
use crate::cover_image::CoverImage;
use crate::theme::TerminalTheme;
const SPAWN_TIMEOUT_MS: i32 = 30_000;
const BACKGROUND_IMAGE_CACHE_CAPACITY: usize = 4;
const BACKGROUND_IMAGE_CACHE_BYTES: usize = 128 * 1024 * 1024;
const BACKGROUND_IMAGE_MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
const BACKGROUND_IMAGE_MAX_SOURCE_EDGE: i32 = 65_535;
const BACKGROUND_IMAGE_MAX_SOURCE_PIXELS: u64 = 128 * 1024 * 1024;
const BACKGROUND_IMAGE_MAX_JPEG_SOURCE_PIXELS: u64 = 40_000_000;
const BACKGROUND_IMAGE_MAX_PNG_DECODE_WORK_BYTES: u64 = 512 * 1024 * 1024;
const BACKGROUND_IMAGE_MAX_DECODED_EDGE: i32 = 4_096;
const BACKGROUND_IMAGE_MAX_DECODED_PIXELS: u64 = 3_840 * 2_160;
const DECODED_TEXTURE_BYTES_PER_PIXEL: usize = 4;
const PNG_SIGNATURE: [u8; 8] = [137, 80, 78, 71, 13, 10, 26, 10];
const VISUAL_BELL_DURATION: Duration = Duration::from_millis(500);
const LIOS_BRIDGE_CONTAINED: &str = "LIOS_BRIDGE_CONTAINED";
const URL_MATCH_PATTERN: &str = r#"(?i)\b(?:https?://|file:(?://)?)[^\s<>\"\x{201c}\x{201d}\x{3002}\x{ff0c}\x{ff1b}\x{ff01}\x{ff1f}]+"#;
pub(crate) const PCRE2_MULTILINE: u32 = 0x0000_0400;
pub(crate) const PCRE2_UCP: u32 = 0x0002_0000;
pub(crate) const VTE_REGEX_FLAGS: u32 =
vte::ffi::VTE_REGEX_FLAGS_DEFAULT as u32 | PCRE2_MULTILINE | PCRE2_UCP;
static PROMPT_SCRIPT_COUNTER: AtomicUsize = AtomicUsize::new(0);
thread_local! {
static URL_MATCH_REGEX: OnceCell<Result<vte::Regex, String>> = const { OnceCell::new() };
static BACKGROUND_IMAGE_CACHE: RefCell<Vec<CachedBackgroundTexture>> = const { RefCell::new(Vec::new()) };
static BACKGROUND_IMAGE_LOADER: RefCell<BackgroundImageLoader> = const {
RefCell::new(BackgroundImageLoader {
active: None,
pending: VecDeque::new(),
})
};
}
pub const CHILD_TERM: &str = "xterm-256color";
pub const CHILD_COLORTERM: &str = "truecolor";
pub const CHILD_TERM_PROGRAM: &str = "lios";
pub const MAX_SCROLLBACK_LINES: i64 = 100_000;
pub const DEFAULT_IMAGE_OPACITY: f64 = 1.0;
pub const DEFAULT_TERMINAL_OPACITY: f64 = 1.0;
pub const DEFAULT_IMAGE_TERMINAL_OPACITY: f64 = 0.50;
pub const DEFAULT_OVERLAY_OPACITY: f64 = 0.18;
pub const SHELL_DEFAULT_PROMPT_PROFILE: &str = "shell-default";
pub const PROMPT_PROFILE_NAMES: &[&str] = &[
SHELL_DEFAULT_PROMPT_PROFILE,
"lightbar",
"timebar",
"fibonnaci",
"fano-plane",
"alice-bob",
"akira",
];
const LIGHTBAR_PS1: &str = r"\n\n \[\e[0;46;30m\]\w\[\]\[\e[0;1;33m\] --> \[\e[0m\]";
const TIMEBAR_PS1: &str = r"\n \[$(tput sgr0)\]\[\033[38;5;230m\]\[\033[48;5;17m\]\t\[$(tput bold)\]\[$(tput sgr0)\]\[\033[38;5;15m\]\[\033[48;5;-1m\].\[$(tput sgr0)\]\[$(tput sgr0)\]\[\033[38;5;69m\]\u\[$(tput sgr0)\]\[\033[38;5;198m\]@\[$(tput sgr0)\]\[\033[38;5;74m\]\h\[$(tput sgr0)\]\[\033[38;5;15m\] \W]\[$(tput sgr0)\] --> ";
const TIMEBAR_PS2: &str =
r"\n\W\[\033[1;96m\]($(git branch 2>/dev/null | grep '^*' | colrm 1 2))\n︻デ═一 \e[m";
const TIMEBAR_PS3: &str = r"\n\[\033[1;97m\] ($(git branch 2>/dev/null | grep '^*' | colrm 1 2))\n \[\033[1;96m\]\W \[\033[38;5;74m\]-> \[\033[38;5;15m\]";
const FIBONNACI_PS1: &str = r"\n \[\033[38;5;220m\]phi\[\033[38;5;178m\]:\[\033[38;5;229m\]\t \[\033[38;5;214m\]\u@\h \[\033[38;5;226m\]\W\[\033[38;5;178m\] --> \[\033[0m\]";
const FANO_PLANE_PS1: &str = TIMEBAR_PS2;
const ALICE_BOB_PS1: &str = TIMEBAR_PS3;
const AKIRA_PS1: &str = r"\n \[\033[38;5;196m\]AKIRA\[\033[38;5;231m\]::\[\033[38;5;160m\]\t \[\033[38;5;15m\]\u@\h \[\033[38;5;196m\]\W\[\033[38;5;88m\] //\[\033[38;5;196m\]> \[\033[0m\]";
const MATCHED_CURSOR_FOREGROUND: &str = "#020303";
#[derive(Debug, Clone)]
pub struct LaunchConfig {
pub command: LaunchCommand,
pub working_directory: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub enum LaunchCommand {
DefaultShell,
Shell(String),
Argv(Vec<String>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct TerminalConfig {
pub theme_name: String,
pub cursor_background: Option<String>,
pub cursor_foreground: Option<String>,
pub cursor_match_overlay: bool,
pub unified_accent: bool,
pub font: String,
pub scrollback_lines: i64,
pub prompt: PromptConfig,
pub theme: TerminalTheme,
pub background: BackgroundConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptConfig {
pub profile_name: String,
ps1: Option<String>,
ps2: Option<String>,
ps3: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BackgroundConfig {
pub image: Option<PathBuf>,
pub image_opacity: f64,
pub terminal_opacity: f64,
pub overlay_color: Option<gdk::RGBA>,
pub overlay_opacity: f64,
pub random_overlay: bool,
}
impl Default for TerminalConfig {
fn default() -> Self {
Self {
theme_name: "default".to_string(),
cursor_background: None,
cursor_foreground: None,
cursor_match_overlay: false,
unified_accent: false,
font: "Monospace 12".to_string(),
scrollback_lines: 1_000,
prompt: PromptConfig::default(),
theme: TerminalTheme::named("default").expect("built-in theme exists"),
background: BackgroundConfig::default(),
}
}
}
impl TerminalConfig {
pub fn set_unified_accent(&mut self, enabled: bool) {
self.unified_accent = enabled;
self.cursor_match_overlay = enabled;
if !enabled {
return;
}
if self.background.overlay_opacity <= 0.0 {
self.background.overlay_opacity = DEFAULT_OVERLAY_OPACITY;
}
if self.background.overlay_color.is_none() {
self.background.random_overlay = true;
}
}
pub fn reconcile_unified_accent(&mut self) {
let has_overlay = self.background.overlay_opacity > 0.0
&& (self.background.random_overlay || self.background.overlay_color.is_some());
if self.unified_accent && !has_overlay {
self.unified_accent = false;
}
}
}
impl Default for PromptConfig {
fn default() -> Self {
Self {
profile_name: SHELL_DEFAULT_PROMPT_PROFILE.to_string(),
ps1: None,
ps2: None,
ps3: None,
}
}
}
impl PromptConfig {
pub fn canonical_name(name: &str) -> Result<&'static str, String> {
let normalized = name.trim().to_ascii_lowercase().replace('_', "-");
match normalized.as_str() {
"" | "default" | "shell" | "shell-default" | "original" | "original-shell" => {
Ok(SHELL_DEFAULT_PROMPT_PROFILE)
}
"lightbar" | "light-bar" => Ok("lightbar"),
"timebar" | "time-bar" | "time-travel" | "timetravel" => Ok("timebar"),
"fibonnaci" | "fibonacci" | "fib" | "phi" => Ok("fibonnaci"),
"fano-plane" | "fanoplane" | "fano" | "556" | "5.56" | "5-56" | "gun" | "rifle" => {
Ok("fano-plane")
}
"alice-bob" | "alice-and-bob" | "alice & bob" | "alice&bob" | "1337" | "leet"
| "elite" => Ok("alice-bob"),
"akira" | "kaneda" | "neo-tokyo" | "neotokyo" => Ok("akira"),
_ => Err(format!(
"unknown prompt profile '{name}'. Available profiles: {}",
PROMPT_PROFILE_NAMES.join(", ")
)),
}
}
pub fn named(name: &str) -> Result<Self, String> {
match Self::canonical_name(name)? {
SHELL_DEFAULT_PROMPT_PROFILE => Ok(Self::default()),
"lightbar" => Ok(Self {
profile_name: "lightbar".to_string(),
ps1: Some(LIGHTBAR_PS1.to_string()),
ps2: None,
ps3: None,
}),
"timebar" => Ok(Self {
profile_name: "timebar".to_string(),
ps1: Some(TIMEBAR_PS1.to_string()),
ps2: Some(TIMEBAR_PS2.to_string()),
ps3: Some(TIMEBAR_PS3.to_string()),
}),
"fibonnaci" => Ok(Self {
profile_name: "fibonnaci".to_string(),
ps1: Some(FIBONNACI_PS1.to_string()),
ps2: Some(TIMEBAR_PS2.to_string()),
ps3: Some(TIMEBAR_PS3.to_string()),
}),
"fano-plane" => Ok(Self {
profile_name: "fano-plane".to_string(),
ps1: Some(FANO_PLANE_PS1.to_string()),
ps2: Some(TIMEBAR_PS2.to_string()),
ps3: Some(TIMEBAR_PS3.to_string()),
}),
"alice-bob" => Ok(Self {
profile_name: "alice-bob".to_string(),
ps1: Some(ALICE_BOB_PS1.to_string()),
ps2: Some(TIMEBAR_PS2.to_string()),
ps3: Some(TIMEBAR_PS3.to_string()),
}),
"akira" => Ok(Self {
profile_name: "akira".to_string(),
ps1: Some(AKIRA_PS1.to_string()),
ps2: Some(TIMEBAR_PS2.to_string()),
ps3: Some(TIMEBAR_PS3.to_string()),
}),
_ => unreachable!("canonical prompt profile names are exhaustive"),
}
}
pub fn ps1(&self) -> Option<&str> {
self.ps1.as_deref()
}
fn activation_script(&self) -> Option<String> {
let ps1 = self.ps1()?;
let mut script = format!(
"export LIOS_ORIGINAL_PS1=\"${{LIOS_ORIGINAL_PS1-${{PS1-}}}}\"; export LIOS_ORIGINAL_PS2=\"${{LIOS_ORIGINAL_PS2-${{PS2-}}}}\"; export LIOS_ORIGINAL_PS3=\"${{LIOS_ORIGINAL_PS3-${{PS3-}}}}\"; export LIOS_PS1_PROFILE={}; export PS1={}",
shell_quote(&self.profile_name),
shell_quote(ps1)
);
apply_prompt_assignment(&mut script, "PS2", "LIOS_ORIGINAL_PS2", self.ps2.as_deref());
apply_prompt_assignment(&mut script, "PS3", "LIOS_ORIGINAL_PS3", self.ps3.as_deref());
Some(script)
}
}
fn apply_prompt_assignment(
script: &mut String,
prompt_name: &str,
original_name: &str,
value: Option<&str>,
) {
if let Some(value) = value {
script.push_str(&format!("; export {prompt_name}={}", shell_quote(value)));
} else {
script.push_str(&format!(
"; if [ \"${{{original_name}+x}}\" = x ]; then export {prompt_name}=\"${original_name}\"; fi"
));
}
}
pub(crate) fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
impl Default for BackgroundConfig {
fn default() -> Self {
Self {
image: None,
image_opacity: DEFAULT_IMAGE_OPACITY,
terminal_opacity: DEFAULT_TERMINAL_OPACITY,
overlay_color: None,
overlay_opacity: DEFAULT_OVERLAY_OPACITY,
random_overlay: true,
}
}
}
pub struct TerminalPane {
root: gtk::Overlay,
terminal: vte::Terminal,
prompt: RefCell<PromptConfig>,
applied_config: RefCell<TerminalConfig>,
background: RefCell<BackgroundView>,
background_image_key: RefCell<Option<BackgroundImageKey>>,
tint: gtk::DrawingArea,
tint_state: Rc<RefCell<TintState>>,
effective_overlay: RefCell<Option<gdk::RGBA>>,
child_process: Rc<Cell<ChildProcessState>>,
spawn_cancellable: gio::Cancellable,
spawn_cancelled: Rc<Cell<bool>>,
pending_startup_script: Rc<RefCell<Option<PathBuf>>>,
default_shell: Cell<bool>,
expected_child_executable: Rc<RefCell<Option<PathBuf>>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChildProcessState {
NotStarted,
Spawning,
Running(libc::pid_t),
Exited,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct TintState {
color: Option<gdk::RGBA>,
opacity: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ConfigChanges {
font: bool,
scrollback: bool,
terminal_appearance: bool,
background: bool,
background_style: bool,
image_opacity: bool,
tint: bool,
}
fn classify_config_changes(current: &TerminalConfig, next: &TerminalConfig) -> ConfigChanges {
let background = current.background.image != next.background.image;
let tint = current.background.overlay_color != next.background.overlay_color
|| current.background.overlay_opacity != next.background.overlay_opacity
|| current.background.random_overlay != next.background.random_overlay;
ConfigChanges {
font: current.font != next.font,
scrollback: current.scrollback_lines != next.scrollback_lines,
terminal_appearance: current.theme_name != next.theme_name
|| current.cursor_background != next.cursor_background
|| current.cursor_foreground != next.cursor_foreground
|| current.cursor_match_overlay != next.cursor_match_overlay
|| current.theme != next.theme
|| (tint && next.cursor_match_overlay),
background,
background_style: current.theme.background_color() != next.theme.background_color()
|| current.background.terminal_opacity != next.background.terminal_opacity,
image_opacity: current.background.image_opacity != next.background.image_opacity,
tint,
}
}
impl TerminalPane {
pub fn new(config: &TerminalConfig) -> Self {
let root = gtk::Overlay::new();
root.set_hexpand(true);
root.set_vexpand(true);
root.add_css_class("lios-terminal-root");
root.set_cursor_from_name(Some("default"));
let background_image_key = configured_background_image_key(&config.background);
let background = background_widget(
&config.theme,
&config.background,
background_image_key.clone(),
);
root.set_child(Some(&background.widget));
let terminal = vte::Terminal::new();
terminal.set_hexpand(true);
terminal.set_vexpand(true);
terminal.set_font(Some(&pango::FontDescription::from_string(&config.font)));
terminal.set_scrollback_lines(config.scrollback_lines as _);
terminal.set_scroll_on_keystroke(true);
terminal.set_scroll_on_output(false);
terminal.set_audible_bell(false);
terminal.set_cursor_blink_mode(vte::CursorBlinkMode::Off);
terminal.set_cursor_shape(vte::CursorShape::Block);
terminal.set_mouse_autohide(false);
terminal.set_bold_is_bright(true);
terminal.set_enable_sixel(true);
terminal.set_allow_hyperlink(true);
terminal.add_css_class("lios-terminal");
terminal.set_cursor_from_name(Some("default"));
install_visual_bell(&terminal);
keep_terminal_pointer_visible(&terminal);
install_link_opening(&terminal);
let overlay = overlay_color(&config.background);
terminal_theme(config, overlay.as_ref()).apply_to(&terminal);
let (tint, tint_state) = color_overlay_widget(overlay, config.background.overlay_opacity);
tint.set_visible(overlay.is_some());
root.add_overlay(&tint);
root.set_clip_overlay(&tint, true);
root.add_overlay(&terminal);
root.set_clip_overlay(&terminal, true);
root.set_measure_overlay(&terminal, true);
Self {
root,
terminal,
prompt: RefCell::new(config.prompt.clone()),
applied_config: RefCell::new(config.clone()),
background: RefCell::new(background),
background_image_key: RefCell::new(background_image_key),
tint,
tint_state,
effective_overlay: RefCell::new(overlay),
child_process: Rc::new(Cell::new(ChildProcessState::NotStarted)),
spawn_cancellable: gio::Cancellable::new(),
spawn_cancelled: Rc::new(Cell::new(false)),
pending_startup_script: Rc::new(RefCell::new(None)),
default_shell: Cell::new(true),
expected_child_executable: Rc::new(RefCell::new(None)),
}
}
pub fn widget(&self) -> >k::Overlay {
&self.root
}
pub fn terminal(&self) -> &vte::Terminal {
&self.terminal
}
pub fn has_resolved_background_image(&self) -> bool {
self.background_image_key.borrow().is_some()
}
pub fn focus(&self) {
self.terminal.grab_focus();
}
pub fn feed_text(&self, text: &str) {
self.terminal.feed(text.as_bytes());
}
pub fn has_running_process(&self) -> bool {
let child_pid = match self.child_process.get() {
ChildProcessState::Spawning => return true,
ChildProcessState::Running(child_pid) => child_pid,
ChildProcessState::NotStarted | ChildProcessState::Exited => return false,
};
let child_process_group = unsafe { libc::getpgid(child_pid) };
if child_process_group < 0 {
return std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH);
}
if !self.default_shell.get() {
return true;
}
if let Some(expected) = self.expected_child_executable.borrow().as_deref() {
match fs::read_link(format!("/proc/{child_pid}/exe")) {
Ok(actual) if actual == expected => {}
Ok(_) | Err(_) => return true,
}
}
let foreground_process_group = self
.terminal
.pty()
.map(|pty| unsafe { libc::tcgetpgrp(pty.fd().as_raw_fd()) })
.filter(|process_group| *process_group >= 0);
running_process_needs_confirmation(
true,
Some(child_process_group),
foreground_process_group,
shell_has_child_processes(child_pid).unwrap_or(true),
)
}
pub fn mark_child_exited(&self) {
self.child_process.set(ChildProcessState::Exited);
}
pub fn cancel_pending_spawn(&self) {
if self.child_process.get() == ChildProcessState::Spawning {
self.spawn_cancelled.set(true);
self.spawn_cancellable.cancel();
remove_pending_startup_script(&self.pending_startup_script);
self.child_process.set(ChildProcessState::Exited);
}
}
pub fn apply_prompt_profile(&self, prompt: &PromptConfig) -> Result<(), String> {
if *self.prompt.borrow() == *prompt {
return Ok(());
}
*self.prompt.borrow_mut() = prompt.clone();
self.applied_config.borrow_mut().prompt = prompt.clone();
Ok(())
}
pub fn apply_config(&self, config: &TerminalConfig) -> Result<(), String> {
if *self.prompt.borrow() != config.prompt {
self.apply_prompt_profile(&config.prompt)?;
}
let current = self.applied_config.borrow();
let changes = classify_config_changes(¤t, config);
drop(current);
let next_background_image_key = configured_background_image_key(&config.background);
let background_source_changed =
changes.background || *self.background_image_key.borrow() != next_background_image_key;
let overlay = if changes.tint {
overlay_color(&config.background)
} else {
*self.effective_overlay.borrow()
};
if changes.font {
self.terminal
.set_font(Some(&pango::FontDescription::from_string(&config.font)));
}
if changes.scrollback {
self.terminal
.set_scrollback_lines(config.scrollback_lines as _);
}
if changes.terminal_appearance {
self.terminal.set_cursor_from_name(Some("default"));
terminal_theme(config, overlay.as_ref()).apply_to(&self.terminal);
}
if background_source_changed {
let background = background_widget(
&config.theme,
&config.background,
next_background_image_key.clone(),
);
self.root.set_child(Some(&background.widget));
*self.background.borrow_mut() = background;
*self.background_image_key.borrow_mut() = next_background_image_key;
} else {
let background = self.background.borrow();
if changes.background_style {
background.update_style(&config.theme, &config.background);
}
if changes.image_opacity {
background.update_image_opacity(config.background.image_opacity);
}
}
if changes.tint {
*self.effective_overlay.borrow_mut() = overlay;
*self.tint_state.borrow_mut() = TintState {
color: overlay,
opacity: config.background.overlay_opacity,
};
self.tint.set_visible(overlay.is_some());
self.tint.queue_draw();
}
*self.applied_config.borrow_mut() = config.clone();
Ok(())
}
pub fn spawn(&self, launch: LaunchConfig) {
self.default_shell
.set(matches!(&launch.command, LaunchCommand::DefaultShell));
let prompt = self.prompt.borrow().clone();
let argv = match launch.command.to_argv(&prompt) {
Ok(argv) => argv,
Err(message) => {
self.child_process.set(ChildProcessState::Exited);
self.report_spawn_error(&message);
return;
}
};
self.pending_startup_script.replace(
self.default_shell
.get()
.then(|| startup_prompt_script_from_argv(&argv))
.flatten(),
);
self.expected_child_executable.replace(
self.default_shell
.get()
.then(|| Path::new(&argv[0]).canonicalize().ok())
.flatten(),
);
self.child_process.set(ChildProcessState::Spawning);
self.spawn_cancelled.set(false);
let working_directory = working_directory(launch.working_directory.as_deref());
let spawn_flags = terminal_spawn_flags(env::var_os(LIOS_BRIDGE_CONTAINED).as_deref());
let envv = child_environment(working_directory.as_deref());
let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let env_refs: Vec<&str> = envv.iter().map(String::as_str).collect();
let terminal_for_error = self.terminal.clone();
let child_process = self.child_process.clone();
let spawn_cancelled = self.spawn_cancelled.clone();
let pending_startup_script = self.pending_startup_script.clone();
let expected_child_executable = self.expected_child_executable.clone();
let default_shell = self.default_shell.get();
self.terminal.spawn_async(
vte::PtyFlags::DEFAULT,
working_directory.as_deref(),
&argv_refs,
&env_refs,
spawn_flags,
|| {},
SPAWN_TIMEOUT_MS,
Some(&self.spawn_cancellable),
move |result| match result {
Ok(pid) => {
if spawn_cancelled.get() {
unsafe {
libc::kill(pid.0, libc::SIGHUP);
}
remove_pending_startup_script(&pending_startup_script);
child_process.set(ChildProcessState::Exited);
return;
}
pending_startup_script.borrow_mut().take();
if default_shell {
if let Ok(executable) = fs::read_link(format!("/proc/{}/exe", pid.0)) {
expected_child_executable.replace(Some(executable));
}
}
child_process.set(ChildProcessState::Running(pid.0));
}
Err(error) => {
remove_pending_startup_script(&pending_startup_script);
child_process.set(ChildProcessState::Exited);
terminal_for_error.feed(
format!(
"\r\nFailed to execute child: {}\r\n",
terminal_safe_status_text(&error.to_string())
)
.as_bytes(),
);
}
},
);
}
fn report_spawn_error(&self, message: &str) {
self.terminal.feed(
format!(
"Failed to start terminal shell: {}\r\n",
terminal_safe_status_text(message)
)
.as_bytes(),
);
}
}
fn startup_prompt_script_from_argv(argv: &[String]) -> Option<PathBuf> {
(argv.len() == 4 && argv.get(1).is_some_and(|arg| arg == "--rcfile"))
.then(|| PathBuf::from(&argv[2]))
}
fn remove_pending_startup_script(script: &Rc<RefCell<Option<PathBuf>>>) {
if let Some(path) = script.borrow_mut().take() {
let _ = fs::remove_file(path);
}
}
impl Drop for TerminalPane {
fn drop(&mut self) {
self.cancel_pending_spawn();
}
}
fn terminal_safe_status_text(message: &str) -> String {
let mut safe = String::new();
for character in message.chars().take(4_096) {
if character.is_control() {
safe.extend(character.escape_default());
} else {
safe.push(character);
}
}
safe
}
fn running_process_needs_confirmation(
default_shell: bool,
child_process_group: Option<libc::pid_t>,
foreground_process_group: Option<libc::pid_t>,
shell_has_children: bool,
) -> bool {
let Some(child_process_group) = child_process_group else {
return false;
};
if !default_shell {
return true;
}
shell_has_children
|| foreground_process_group.is_none_or(|foreground| foreground != child_process_group)
}
fn shell_has_child_processes(shell_pid: libc::pid_t) -> Option<bool> {
fs::read_to_string(format!("/proc/{shell_pid}/task/{shell_pid}/children"))
.ok()
.map(|children| !children.trim().is_empty())
}
fn terminal_spawn_flags(bridge_contained: Option<&OsStr>) -> glib::SpawnFlags {
let mut bits = glib::SpawnFlags::SEARCH_PATH.bits();
if bridge_contained == Some(OsStr::new("1")) {
bits |= vte::ffi::VTE_SPAWN_NO_SYSTEMD_SCOPE as u32;
}
glib::SpawnFlags::from_bits_retain(bits)
}
fn prompt_script_path(kind: &str) -> PathBuf {
prompt_script_path_in(&prompt_runtime_directory(), kind)
}
fn prompt_script_path_in(directory: &Path, kind: &str) -> PathBuf {
let counter = PROMPT_SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
directory.join(format!(
"lp{}-{kind}-{counter:x}-{nanos:x}.sh",
std::process::id()
))
}
fn with_prompt_script_path<T>(
kind: &str,
write: impl FnMut(&Path) -> Result<T, std::io::Error>,
) -> Result<T, std::io::Error> {
let primary = prompt_script_path(kind);
let fallback_directory = prompt_fallback_directory();
let fallback = (primary.parent() != Some(fallback_directory.as_path()))
.then(|| prompt_script_path_in(&fallback_directory, kind));
with_prompt_script_paths(&primary, fallback.as_deref(), write)
}
fn with_prompt_script_paths<T>(
primary: &Path,
fallback: Option<&Path>,
mut write: impl FnMut(&Path) -> Result<T, std::io::Error>,
) -> Result<T, std::io::Error> {
match write(primary) {
Ok(value) => Ok(value),
Err(primary_error) => match fallback {
Some(fallback) => write(fallback),
None => Err(primary_error),
},
}
}
fn prompt_runtime_directory() -> PathBuf {
env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.and_then(|path| ensure_private_prompt_directory(&path))
.unwrap_or_else(prompt_fallback_directory)
}
fn prompt_fallback_directory() -> PathBuf {
for base in user_cache_directories() {
if let Some(base) = ensure_user_cache_directory(&base) {
if let Some(directory) = ensure_private_prompt_directory(&base) {
return directory;
}
}
}
for base in [env::temp_dir(), PathBuf::from("/tmp")] {
if let Some(directory) = ensure_private_prompt_directory(&base) {
return directory;
}
}
PathBuf::from("/__lios_private_runtime_unavailable__")
}
fn user_cache_directories() -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(path) = env::var_os("XDG_CACHE_HOME").filter(|path| !path.is_empty()) {
candidates.push(PathBuf::from(path));
}
if let Some(home) = env::var_os("HOME").filter(|path| !path.is_empty()) {
let fallback = PathBuf::from(home).join(".cache");
if !candidates.contains(&fallback) {
candidates.push(fallback);
}
}
candidates
}
fn ensure_user_cache_directory(path: &Path) -> Option<PathBuf> {
if !path.is_absolute() || path.to_str().is_none() {
return None;
}
if !path.exists() {
let parent = path.parent()?;
if !runtime_base_is_safe(parent) {
return None;
}
let mut builder = fs::DirBuilder::new();
builder.mode(0o700);
if builder.create(path).is_err() {
return None;
}
}
runtime_base_is_safe(path).then(|| path.to_path_buf())
}
fn ensure_private_prompt_directory(base: &Path) -> Option<PathBuf> {
if base.to_str().is_none() || !runtime_base_is_safe(base) {
return None;
}
let effective_uid = unsafe { libc::geteuid() };
let directory = base.join(format!("lios-runtime-{effective_uid}"));
let mut builder = fs::DirBuilder::new();
builder.mode(0o700);
if let Err(error) = builder.create(&directory) {
if error.kind() != std::io::ErrorKind::AlreadyExists {
return None;
}
}
if !private_prompt_directory_is_safe(&directory, effective_uid) {
return None;
}
prune_stale_prompt_scripts(&directory, effective_uid);
Some(directory)
}
fn prune_stale_prompt_scripts(directory: &Path, effective_uid: libc::uid_t) {
const MAX_ENTRIES_TO_INSPECT: usize = 256;
const STALE_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
let Ok(entries) = fs::read_dir(directory) else {
return;
};
for entry in entries.flatten().take(MAX_ENTRIES_TO_INSPECT) {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if !name.starts_with("lp") || !name.ends_with(".sh") {
continue;
}
let path = entry.path();
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
let stale = metadata
.modified()
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age >= STALE_AFTER);
if metadata.file_type().is_file() && metadata.uid() == effective_uid && stale {
let _ = fs::remove_file(path);
}
}
}
fn runtime_base_is_safe(path: &Path) -> bool {
let Ok(metadata) = fs::symlink_metadata(path) else {
return false;
};
if !metadata.file_type().is_dir() {
return false;
}
let effective_uid = unsafe { libc::geteuid() };
let mode = metadata.mode();
let private_user_directory = metadata.uid() == effective_uid && mode & 0o022 == 0;
let root_owned_sticky_directory = metadata.uid() == 0 && mode & 0o1000 != 0;
private_user_directory || root_owned_sticky_directory
}
fn private_prompt_directory_is_safe(path: &Path, effective_uid: libc::uid_t) -> bool {
fs::symlink_metadata(path).is_ok_and(|metadata| {
metadata.file_type().is_dir()
&& metadata.uid() == effective_uid
&& metadata.mode() & 0o077 == 0
})
}
impl LaunchCommand {
fn to_argv(&self, prompt: &PromptConfig) -> Result<Vec<String>, String> {
match self {
Self::DefaultShell => default_shell_argv(prompt),
Self::Shell(command) => Ok(vec![
"/bin/sh".to_string(),
"-lc".to_string(),
command.clone(),
]),
Self::Argv(argv) if argv.is_empty() => Err("empty command".to_string()),
Self::Argv(argv) => Ok(argv.clone()),
}
}
}
fn default_shell_argv(prompt: &PromptConfig) -> Result<Vec<String>, String> {
let shell = default_shell()?;
if prompt.ps1().is_some() && is_bash_shell(&shell) {
let rcfile = write_bash_prompt_rcfile(prompt)
.map_err(|error| format!("failed to prepare Bash prompt profile: {error}"))?;
return Ok(vec![
shell,
"--rcfile".to_string(),
rcfile.to_string_lossy().into_owned(),
"-i".to_string(),
]);
}
Ok(vec![shell])
}
fn is_bash_shell(shell: &str) -> bool {
matches!(
Path::new(shell).file_name().and_then(|name| name.to_str()),
Some("bash" | "rbash")
)
}
fn write_bash_prompt_rcfile(prompt: &PromptConfig) -> Result<PathBuf, std::io::Error> {
with_prompt_script_path("startup", |path| {
write_bash_prompt_rcfile_at(path, prompt)?;
Ok(path.to_path_buf())
})
}
fn write_bash_prompt_rcfile_at(path: &Path, prompt: &PromptConfig) -> Result<(), std::io::Error> {
let result = (|| {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
writeln!(file, "rm -f -- {}", shell_quote(&path.to_string_lossy()))?;
writeln!(
file,
"if [ -r \"$HOME/.bashrc\" ]; then . \"$HOME/.bashrc\"; fi"
)?;
if let Some(script) = prompt.activation_script() {
writeln!(file, "{script}")?;
}
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(path);
}
result
}
fn default_shell() -> Result<String, String> {
if let Ok(shell) = env::var("SHELL") {
if is_executable(Path::new(&shell)) {
return Ok(shell);
}
}
for shell in [
"/bin/sh",
"/bin/bash",
"/usr/bin/bash",
"/bin/dash",
"/usr/bin/dash",
"/bin/zsh",
"/usr/bin/zsh",
"/bin/fish",
"/usr/bin/fish",
"/bin/tcsh",
"/usr/bin/tcsh",
"/bin/csh",
"/usr/bin/csh",
"/bin/ksh",
"/usr/bin/ksh",
] {
if is_executable(Path::new(shell)) {
return Ok(shell.to_string());
}
}
Err("unable to determine a usable shell".to_string())
}
fn is_executable(path: &Path) -> bool {
let Ok(metadata) = fs::metadata(path) else {
return false;
};
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
}
fn working_directory(requested: Option<&Path>) -> Option<String> {
requested
.map(Path::to_path_buf)
.or_else(|| env::current_dir().ok())
.and_then(|path| path.to_str().map(ToOwned::to_owned))
}
fn child_environment(working_directory: Option<&str>) -> Vec<String> {
child_environment_from(env::vars_os(), working_directory)
}
fn child_environment_from(
variables: impl IntoIterator<Item = (OsString, OsString)>,
working_directory: Option<&str>,
) -> Vec<String> {
let mut envv = Vec::new();
let mut has_pwd = false;
for (key, value) in variables {
let (Ok(key), Ok(value)) = (key.into_string(), value.into_string()) else {
continue;
};
if should_strip_child_env(&key) {
continue;
}
if key == "PWD" {
if let Some(cwd) = working_directory {
envv.push(format!("PWD={cwd}"));
has_pwd = true;
}
continue;
}
envv.push(format!("{key}={value}"));
}
if !has_pwd {
if let Some(cwd) = working_directory {
envv.push(format!("PWD={cwd}"));
}
}
envv.push(format!("TERM={CHILD_TERM}"));
envv.push(format!("COLORTERM={CHILD_COLORTERM}"));
envv.push(format!("TERM_PROGRAM={CHILD_TERM_PROGRAM}"));
envv.push(format!(
"TERM_PROGRAM_VERSION={}",
env!("CARGO_PKG_VERSION")
));
envv
}
fn should_strip_child_env(key: &str) -> bool {
matches!(
key,
"COLUMNS"
| "LINES"
| "WINDOWID"
| "COLORTERM"
| "TERM"
| "TERM_PROGRAM"
| "TERM_PROGRAM_VERSION"
| "VTE_VERSION"
| "GSK_RENDERER"
| "LIOS_IMAGE_PROTOCOL"
| "LIOS_ORIGINAL_PS1"
| "LIOS_ORIGINAL_PS2"
| "LIOS_ORIGINAL_PS3"
| "LIOS_PS1_PROFILE"
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct BackgroundImageKey {
canonical_path: PathBuf,
device: u64,
inode: u64,
size: u64,
modified_seconds: i64,
modified_nanoseconds: i64,
changed_seconds: i64,
changed_nanoseconds: i64,
}
struct CachedBackgroundTexture {
key: BackgroundImageKey,
texture: gdk::Texture,
decoded_bytes: usize,
}
struct BackgroundImageLoad {
key: BackgroundImageKey,
targets: Vec<glib::WeakRef<CoverImage>>,
}
#[derive(Debug)]
struct DecodedPremultipliedRgbaImage {
width: i32,
height: i32,
pixels: Vec<u8>,
}
impl DecodedPremultipliedRgbaImage {
fn into_texture(self) -> Result<gdk::Texture, String> {
let stride = usize::try_from(self.width)
.ok()
.and_then(|width| width.checked_mul(DECODED_TEXTURE_BYTES_PER_PIXEL))
.ok_or_else(|| "decoded image stride overflowed".to_string())?;
let expected_bytes = usize::try_from(self.height)
.ok()
.and_then(|height| stride.checked_mul(height))
.ok_or_else(|| "decoded image size overflowed".to_string())?;
if self.pixels.len() != expected_bytes {
return Err("decoded image returned an invalid pixel buffer".to_string());
}
let bytes = glib::Bytes::from_owned(self.pixels);
Ok(gdk::MemoryTexture::new(
self.width,
self.height,
gdk::MemoryFormat::R8g8b8a8Premultiplied,
&bytes,
stride,
)
.upcast())
}
}
struct BackgroundImageLoader {
active: Option<BackgroundImageLoad>,
pending: VecDeque<BackgroundImageLoad>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct BackgroundBaseState {
color: gdk::RGBA,
opaque: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct ShadeState {
color: gdk::RGBA,
opacity: f64,
}
struct BackgroundView {
widget: gtk::Overlay,
base: gtk::DrawingArea,
base_state: Rc<RefCell<BackgroundBaseState>>,
shade: gtk::DrawingArea,
shade_state: Rc<RefCell<ShadeState>>,
image: Option<CoverImage>,
has_image: bool,
}
impl BackgroundView {
fn update_style(&self, theme: &TerminalTheme, config: &BackgroundConfig) {
let color = theme.background_color();
*self.base_state.borrow_mut() = BackgroundBaseState {
color,
opaque: self.has_image || config.terminal_opacity >= 0.999,
};
*self.shade_state.borrow_mut() = ShadeState {
color,
opacity: background_terminal_opacity(config, self.has_image),
};
self.base.queue_draw();
self.shade.queue_draw();
}
fn update_image_opacity(&self, opacity: f64) {
if let Some(image) = &self.image {
image.set_opacity(opacity);
}
}
}
fn background_widget(
theme: &TerminalTheme,
config: &BackgroundConfig,
image_key: Option<BackgroundImageKey>,
) -> BackgroundView {
let background = gtk::Overlay::new();
background.set_hexpand(true);
background.set_vexpand(true);
background.set_can_target(false);
background.add_css_class("lios-terminal-root");
let has_image = image_key.is_some();
let (base, base_state) = background_base_widget(
theme.background_color(),
has_image || config.terminal_opacity >= 0.999,
);
background.set_child(Some(&base));
let image = image_key.map(|key| {
let image = CoverImage::pending(config.image_opacity);
background.add_overlay(&image);
background.set_clip_overlay(&image, true);
request_background_texture(key, &image);
image
});
let (shade, shade_state) = terminal_shade_widget(
theme.background_color(),
background_terminal_opacity(config, has_image),
);
background.add_overlay(&shade);
background.set_clip_overlay(&shade, true);
BackgroundView {
widget: background,
base,
base_state,
shade,
shade_state,
image,
has_image,
}
}
fn background_image_key(path: &Path) -> Result<BackgroundImageKey, std::io::Error> {
let canonical_path = path.canonicalize()?;
let metadata = fs::metadata(&canonical_path)?;
if !metadata.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"background image is not a regular file",
));
}
Ok(BackgroundImageKey {
canonical_path,
device: metadata.dev(),
inode: metadata.ino(),
size: metadata.size(),
modified_seconds: metadata.mtime(),
modified_nanoseconds: metadata.mtime_nsec(),
changed_seconds: metadata.ctime(),
changed_nanoseconds: metadata.ctime_nsec(),
})
}
fn configured_background_image_key(config: &BackgroundConfig) -> Option<BackgroundImageKey> {
let path = config.image.as_deref()?;
match background_image_key(path) {
Ok(key) => Some(key),
Err(error) => {
eprintln!(
"failed to inspect background image '{}': {error}",
terminal_safe_status_text(&path.display().to_string())
);
None
}
}
}
fn cached_background_texture(key: &BackgroundImageKey) -> Option<gdk::Texture> {
BACKGROUND_IMAGE_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let index = cache.iter().position(|entry| entry.key == *key)?;
let entry = cache.remove(index);
let result = entry.texture.clone();
cache.push(entry);
Some(result)
})
}
fn cache_background_texture(key: BackgroundImageKey, texture: &gdk::Texture) {
if let Some(decoded_bytes) = decoded_texture_bytes(texture.width(), texture.height()) {
BACKGROUND_IMAGE_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let existing_sizes = cache
.iter()
.map(|entry| entry.decoded_bytes)
.collect::<Vec<_>>();
if let Some(eviction_count) = background_cache_eviction_count(
&existing_sizes,
decoded_bytes,
BACKGROUND_IMAGE_CACHE_CAPACITY,
BACKGROUND_IMAGE_CACHE_BYTES,
) {
cache.drain(..eviction_count);
cache.push(CachedBackgroundTexture {
key,
texture: texture.clone(),
decoded_bytes,
});
}
});
}
}
fn request_background_texture(key: BackgroundImageKey, target: &CoverImage) {
if let Some(texture) = cached_background_texture(&key) {
target.set_texture(&texture);
return;
}
let target = target.downgrade();
let next = BACKGROUND_IMAGE_LOADER.with(|loader| {
let mut loader = loader.borrow_mut();
enqueue_background_image_load(&mut loader, key, target)
});
if let Some(key) = next {
launch_background_image_load(key);
}
}
fn enqueue_background_image_load(
loader: &mut BackgroundImageLoader,
key: BackgroundImageKey,
target: glib::WeakRef<CoverImage>,
) -> Option<BackgroundImageKey> {
loader
.pending
.retain(|load| load.targets.iter().any(|target| target.upgrade().is_some()));
if let Some(active) = loader.active.as_mut().filter(|load| load.key == key) {
active.targets.retain(|target| target.upgrade().is_some());
active.targets.push(target);
return None;
}
if let Some(pending) = loader.pending.iter_mut().find(|load| load.key == key) {
pending.targets.retain(|target| target.upgrade().is_some());
pending.targets.push(target);
return None;
}
let load = BackgroundImageLoad {
key: key.clone(),
targets: vec![target],
};
if loader.active.is_none() {
loader.active = Some(load);
Some(key)
} else {
loader.pending.push_back(load);
None
}
}
fn launch_background_image_load(key: BackgroundImageKey) {
glib::spawn_future_local(async move {
let result = decode_background_image(&key).await;
complete_background_image_load(key, result);
});
}
async fn decode_background_image(
key: &BackgroundImageKey,
) -> Result<DecodedPremultipliedRgbaImage, String> {
let key = key.clone();
gio::spawn_blocking(move || decode_background_image_blocking(&key))
.await
.map_err(|_| "image decoder worker stopped unexpectedly".to_string())?
}
fn decode_background_image_blocking(
key: &BackgroundImageKey,
) -> Result<DecodedPremultipliedRgbaImage, String> {
if key.size > BACKGROUND_IMAGE_MAX_FILE_BYTES {
return Err(format!(
"encoded file is larger than {} MiB",
BACKGROUND_IMAGE_MAX_FILE_BYTES / (1024 * 1024)
));
}
let source = OpenOptions::new()
.read(true)
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
.open(&key.canonical_path)
.map_err(|error| error.to_string())?;
let decoded = decode_open_background_image(&source, key)?;
ensure_background_source_unchanged(key)?;
Ok(decoded)
}
fn decode_open_background_image(
source: &File,
key: &BackgroundImageKey,
) -> Result<DecodedPremultipliedRgbaImage, String> {
ensure_open_background_source_unchanged(source, key)?;
let mut decode_source = source.try_clone().map_err(|error| error.to_string())?;
let mut signature = [0u8; 8];
let signature_bytes = decode_source
.read(&mut signature)
.map_err(|error| error.to_string())?;
decode_source
.seek(SeekFrom::Start(0))
.map_err(|error| error.to_string())?;
let decoded = if signature_bytes == signature.len() && signature == PNG_SIGNATURE {
decode_png_background(decode_source)?
} else {
decode_pixbuf_background(&mut decode_source)?
};
ensure_open_background_source_unchanged(source, key)?;
Ok(decoded)
}
fn background_format_can_scale_safely(format_name: Option<&str>) -> bool {
format_name == Some("jpeg")
}
fn decode_pixbuf_background(file: &mut File) -> Result<DecodedPremultipliedRgbaImage, String> {
let loader = gdk_pixbuf::PixbufLoader::new();
let load_plan = Rc::new(RefCell::new(None::<Result<(), String>>));
let callback_plan = load_plan.clone();
loader.connect_size_prepared(move |loader, source_width, source_height| {
let format_name = loader
.format()
.and_then(|format| format.name())
.map(|name| name.to_string());
let result =
background_pixbuf_load_plan(format_name.as_deref(), source_width, source_height);
match &result {
Ok((decode_width, decode_height)) => loader.set_size(*decode_width, *decode_height),
Err(_) => loader.set_size(1, 1),
}
*callback_plan.borrow_mut() = Some(result.map(|_| ()));
});
let mut buffer = [0u8; 64 * 1024];
loop {
let count = file.read(&mut buffer).map_err(|error| error.to_string())?;
if count == 0 {
break;
}
loader
.write(&buffer[..count])
.map_err(|error| error.to_string())?;
if let Some(Err(error)) = load_plan.borrow().as_ref() {
return Err(error.clone());
}
}
loader.close().map_err(|error| error.to_string())?;
match load_plan.borrow().as_ref() {
Some(Ok(())) => {}
Some(Err(error)) => return Err(error.clone()),
None => return Err("unsupported or invalid image format".to_string()),
}
let pixbuf = loader
.pixbuf()
.ok_or_else(|| "image decoder returned no pixels".to_string())?;
let pixbuf = if pixbuf_orientation_needs_transform(pixbuf.option("orientation").as_deref()) {
pixbuf
.apply_embedded_orientation()
.ok_or_else(|| "failed to apply the image orientation".to_string())?
} else {
pixbuf
};
let width = pixbuf.width();
let height = pixbuf.height();
if !background_decoded_dimensions_valid(width, height) {
return Err(format!(
"decoder produced unsafe dimensions {width}x{height}"
));
}
if pixbuf.bits_per_sample() != 8
|| pixbuf.colorspace() != gdk_pixbuf::Colorspace::Rgb
|| !matches!(pixbuf.n_channels(), 3 | 4)
|| pixbuf.has_alpha() != (pixbuf.n_channels() == 4)
{
return Err("decoder produced an unsupported pixel layout".to_string());
}
let width_usize = usize::try_from(width).map_err(|_| "image width is invalid".to_string())?;
let height_usize =
usize::try_from(height).map_err(|_| "image height is invalid".to_string())?;
let channels = usize::try_from(pixbuf.n_channels())
.map_err(|_| "image channel count is invalid".to_string())?;
let source_stride = usize::try_from(pixbuf.rowstride())
.map_err(|_| "image row stride is invalid".to_string())?;
let source = pixbuf.read_pixel_bytes();
let pixels =
premultiplied_rgba_from_rows(&source, width_usize, height_usize, channels, source_stride)?;
Ok(DecodedPremultipliedRgbaImage {
width,
height,
pixels,
})
}
fn background_pixbuf_load_plan(
format_name: Option<&str>,
source_width: i32,
source_height: i32,
) -> Result<(i32, i32), String> {
let (decode_width, decode_height) = background_decode_dimensions(source_width, source_height)
.ok_or_else(|| {
format!("source dimensions {source_width}x{source_height} exceed safe image limits")
})?;
if format_name == Some("jpeg")
&& source_pixel_count(source_width, source_height)
.is_none_or(|pixels| pixels > BACKGROUND_IMAGE_MAX_JPEG_SOURCE_PIXELS)
{
return Err(format!(
"JPEG source dimensions exceed the {} megapixel decoder limit",
BACKGROUND_IMAGE_MAX_JPEG_SOURCE_PIXELS / 1_000_000
));
}
if (decode_width != source_width || decode_height != source_height)
&& !background_format_can_scale_safely(format_name)
{
return Err(format!(
"oversized {} images cannot be decoded within the safe memory budget",
format_name.unwrap_or("unknown-format")
));
}
Ok((decode_width, decode_height))
}
fn pixbuf_orientation_needs_transform(orientation: Option<&str>) -> bool {
orientation.is_some_and(|orientation| orientation != "1")
}
fn premultiplied_rgba_from_rows(
source: &[u8],
width: usize,
height: usize,
channels: usize,
source_stride: usize,
) -> Result<Vec<u8>, String> {
if !matches!(channels, 3 | 4) {
return Err("decoder returned an invalid channel count".to_string());
}
let source_row_bytes = width
.checked_mul(channels)
.ok_or_else(|| "image row size overflowed".to_string())?;
if source_stride < source_row_bytes {
return Err("decoder returned an invalid row stride".to_string());
}
let required_source_bytes = height
.checked_sub(1)
.and_then(|rows| rows.checked_mul(source_stride))
.and_then(|bytes| bytes.checked_add(source_row_bytes))
.ok_or_else(|| "decoded image buffer size overflowed".to_string())?;
if source.len() < required_source_bytes {
return Err("decoder returned a truncated pixel buffer".to_string());
}
let target_bytes = width
.checked_mul(height)
.and_then(|pixels| pixels.checked_mul(DECODED_TEXTURE_BYTES_PER_PIXEL))
.ok_or_else(|| "decoded image buffer size overflowed".to_string())?;
let mut pixels = Vec::new();
pixels
.try_reserve_exact(target_bytes)
.map_err(|_| "could not allocate the decoded image buffer".to_string())?;
for row in 0..height {
let start = row * source_stride;
let row = &source[start..start + source_row_bytes];
for pixel in row.chunks_exact(channels) {
let alpha = pixel.get(3).copied().unwrap_or(255);
pixels.extend_from_slice(&[
premultiply_byte(pixel[0], alpha),
premultiply_byte(pixel[1], alpha),
premultiply_byte(pixel[2], alpha),
alpha,
]);
}
}
Ok(pixels)
}
fn decode_png_background(file: File) -> Result<DecodedPremultipliedRgbaImage, String> {
decode_png_background_at_size(file, None)
}
fn decode_png_background_at_size(
file: File,
requested_size: Option<(i32, i32)>,
) -> Result<DecodedPremultipliedRgbaImage, String> {
let mut decoder = png::Decoder::new(BufReader::new(file));
decoder.set_limits(png::Limits {
bytes: 16 * 1024 * 1024,
});
decoder.set_ignore_text_chunk(true);
decoder.set_ignore_iccp_chunk(true);
decoder.set_transformations(png::Transformations::normalize_to_color8());
let mut reader = decoder.read_info().map_err(|error| error.to_string())?;
let (source_width, source_height, interlaced, animated, orientation) = {
let info = reader.info();
let decoded_work =
png_source_work_bytes(info.width, info.height, info.color_type, info.bit_depth)
.ok_or_else(|| "PNG source work size overflowed".to_string())?;
if decoded_work > BACKGROUND_IMAGE_MAX_PNG_DECODE_WORK_BYTES {
return Err("PNG source exceeds the decompression work budget".to_string());
}
let orientation = info
.exif_metadata
.as_deref()
.map(parse_exif_orientation)
.transpose()?
.unwrap_or(1);
(
i32::try_from(info.width).map_err(|_| "PNG width is too large".to_string())?,
i32::try_from(info.height).map_err(|_| "PNG height is too large".to_string())?,
info.interlaced,
info.animation_control.is_some(),
orientation,
)
};
if animated {
return Err("animated PNG wallpapers are not supported by the bounded decoder".to_string());
}
let (target_width, target_height) = match requested_size {
Some((width, height)) if background_decoded_dimensions_valid(width, height) => {
(width, height)
}
Some(_) => return Err("requested PNG dimensions exceed safe image limits".to_string()),
None => background_decode_dimensions(source_width, source_height).ok_or_else(|| {
format!("source dimensions {source_width}x{source_height} exceed safe image limits")
})?,
};
let (color_type, bit_depth) = reader.output_color_type();
if bit_depth != png::BitDepth::Eight {
return Err("PNG normalization did not produce 8-bit color".to_string());
}
let pixels = if interlaced {
if target_width != source_width || target_height != source_height {
return Err(
"oversized interlaced PNGs cannot be downscaled within the safe memory budget"
.to_string(),
);
}
decode_interlaced_png(&mut reader, source_width, source_height, color_type)?
} else {
decode_streaming_png(
&mut reader,
source_width,
source_height,
target_width,
target_height,
color_type,
)?
};
reader.finish().map_err(|error| error.to_string())?;
orient_rgba_image(
DecodedPremultipliedRgbaImage {
width: target_width,
height: target_height,
pixels,
},
orientation,
)
}
fn png_source_work_bytes(
width: u32,
height: u32,
color_type: png::ColorType,
bit_depth: png::BitDepth,
) -> Option<u64> {
let bits_per_sample = match bit_depth {
png::BitDepth::One => 1,
png::BitDepth::Two => 2,
png::BitDepth::Four => 4,
png::BitDepth::Eight => 8,
png::BitDepth::Sixteen => 16,
};
u64::from(width)
.checked_mul(u64::try_from(color_type.samples()).ok()?)?
.checked_mul(bits_per_sample)?
.checked_add(7)
.map(|bits| bits / 8)?
.checked_mul(u64::from(height))
}
fn decode_interlaced_png(
reader: &mut png::Reader<BufReader<File>>,
width: i32,
height: i32,
color_type: png::ColorType,
) -> Result<Vec<u8>, String> {
let buffer_size = reader
.output_buffer_size()
.ok_or_else(|| "interlaced PNG buffer size overflowed".to_string())?;
let maximum_size = decoded_texture_bytes(width, height)
.ok_or_else(|| "interlaced PNG dimensions are invalid".to_string())?;
if buffer_size > maximum_size {
return Err("interlaced PNG buffer exceeds the decoded-image budget".to_string());
}
let mut source = zeroed_byte_buffer(buffer_size, "interlaced PNG")?;
let output = reader
.next_frame(&mut source)
.map_err(|error| error.to_string())?;
if output.width != width as u32 || output.height != height as u32 {
return Err("interlaced PNG frame dimensions do not match its canvas".to_string());
}
let source = source
.get(..output.buffer_size())
.ok_or_else(|| "interlaced PNG returned an invalid buffer size".to_string())?;
rgba_from_png_pixels(source, color_type, width, height)
}
fn decode_streaming_png(
reader: &mut png::Reader<BufReader<File>>,
source_width: i32,
source_height: i32,
target_width: i32,
target_height: i32,
color_type: png::ColorType,
) -> Result<Vec<u8>, String> {
let source_width =
usize::try_from(source_width).map_err(|_| "PNG source width is invalid".to_string())?;
let source_height =
usize::try_from(source_height).map_err(|_| "PNG source height is invalid".to_string())?;
let target_width =
usize::try_from(target_width).map_err(|_| "PNG target width is invalid".to_string())?;
let target_height =
usize::try_from(target_height).map_err(|_| "PNG target height is invalid".to_string())?;
let row_size = reader
.output_line_size(source_width as u32)
.ok_or_else(|| "PNG row size overflowed".to_string())?;
let expected_row_size = source_width
.checked_mul(color_type.samples())
.ok_or_else(|| "PNG row size overflowed".to_string())?;
if row_size != expected_row_size {
return Err("PNG decoder returned an unexpected row layout".to_string());
}
let target_bytes = target_width
.checked_mul(target_height)
.and_then(|pixels| pixels.checked_mul(DECODED_TEXTURE_BYTES_PER_PIXEL))
.ok_or_else(|| "PNG target buffer size overflowed".to_string())?;
if target_bytes > BACKGROUND_IMAGE_MAX_DECODED_PIXELS as usize * 4 {
return Err("PNG target exceeds the decoded-image budget".to_string());
}
let mut pixels = zeroed_byte_buffer(target_bytes, "PNG target")?;
let horizontal_samples = (0..target_width)
.map(|x| axis_sample(source_width, target_width, x))
.collect::<Vec<_>>();
let mut lower_row = zeroed_byte_buffer(row_size, "PNG row")?;
let mut upper_row = zeroed_byte_buffer(row_size, "PNG row")?;
read_png_row(reader, &mut upper_row)?;
let mut lower_index = 0usize;
let mut upper_index = 0usize;
for target_y in 0..target_height {
let (source_y0, source_y1, vertical_weight) =
axis_sample(source_height, target_height, target_y);
while upper_index < source_y1 {
std::mem::swap(&mut lower_row, &mut upper_row);
lower_index = upper_index;
read_png_row(reader, &mut upper_row)?;
upper_index += 1;
}
let row0 = if source_y0 == upper_index {
&upper_row
} else if source_y0 == lower_index {
&lower_row
} else {
return Err("PNG row sampler lost its source position".to_string());
};
let row1 = if source_y1 == upper_index {
&upper_row
} else if source_y1 == lower_index {
&lower_row
} else {
return Err("PNG row sampler lost its source position".to_string());
};
let target_start = target_y * target_width * DECODED_TEXTURE_BYTES_PER_PIXEL;
let target_row = &mut pixels
[target_start..target_start + target_width * DECODED_TEXTURE_BYTES_PER_PIXEL];
for (target_pixel, &(source_x0, source_x1, horizontal_weight)) in target_row
.chunks_exact_mut(DECODED_TEXTURE_BYTES_PER_PIXEL)
.zip(horizontal_samples.iter())
{
let top_left = png_pixel(row0, source_x0, color_type)?;
let top_right = png_pixel(row0, source_x1, color_type)?;
let bottom_left = png_pixel(row1, source_x0, color_type)?;
let bottom_right = png_pixel(row1, source_x1, color_type)?;
for channel in 0..DECODED_TEXTURE_BYTES_PER_PIXEL {
let top = lerp_byte(top_left[channel], top_right[channel], horizontal_weight);
let bottom = lerp_byte(
bottom_left[channel],
bottom_right[channel],
horizontal_weight,
);
target_pixel[channel] = lerp_byte(top, bottom, vertical_weight);
}
}
}
while upper_index + 1 < source_height {
read_png_row(reader, &mut upper_row)?;
upper_index += 1;
}
if reader
.read_row(&mut upper_row)
.map_err(|error| error.to_string())?
.is_some()
{
return Err("PNG returned more source rows than advertised".to_string());
}
Ok(pixels)
}
fn read_png_row(reader: &mut png::Reader<BufReader<File>>, row: &mut [u8]) -> Result<(), String> {
reader
.read_row(row)
.map_err(|error| error.to_string())?
.ok_or_else(|| "PNG ended before all source rows were decoded".to_string())?;
Ok(())
}
fn rgba_from_png_pixels(
source: &[u8],
color_type: png::ColorType,
width: i32,
height: i32,
) -> Result<Vec<u8>, String> {
let pixel_count = usize::try_from(width)
.ok()
.and_then(|width| {
usize::try_from(height)
.ok()
.and_then(|height| width.checked_mul(height))
})
.ok_or_else(|| "PNG dimensions are invalid".to_string())?;
let expected_source = pixel_count
.checked_mul(color_type.samples())
.ok_or_else(|| "PNG source buffer size overflowed".to_string())?;
if source.len() != expected_source {
return Err("PNG returned an invalid source buffer".to_string());
}
let rgba_bytes = pixel_count
.checked_mul(DECODED_TEXTURE_BYTES_PER_PIXEL)
.ok_or_else(|| "PNG output buffer size overflowed".to_string())?;
let mut rgba = Vec::new();
rgba.try_reserve_exact(rgba_bytes)
.map_err(|_| "could not allocate the PNG output buffer".to_string())?;
for index in 0..pixel_count {
rgba.extend_from_slice(&png_pixel(source, index, color_type)?);
}
Ok(rgba)
}
fn png_pixel(
row: &[u8],
pixel_index: usize,
color_type: png::ColorType,
) -> Result<[u8; 4], String> {
let offset = pixel_index
.checked_mul(color_type.samples())
.ok_or_else(|| "PNG pixel offset overflowed".to_string())?;
let pixel = row
.get(offset..offset + color_type.samples())
.ok_or_else(|| "PNG row was shorter than advertised".to_string())?;
let rgba = match color_type {
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Indexed => {
return Err("PNG palette expansion did not run".to_string());
}
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
};
let alpha = rgba[3];
Ok([
premultiply_byte(rgba[0], alpha),
premultiply_byte(rgba[1], alpha),
premultiply_byte(rgba[2], alpha),
alpha,
])
}
fn premultiply_byte(color: u8, alpha: u8) -> u8 {
((u16::from(color) * u16::from(alpha) + 127) / 255) as u8
}
fn zeroed_byte_buffer(size: usize, purpose: &str) -> Result<Vec<u8>, String> {
let mut buffer = Vec::new();
buffer
.try_reserve_exact(size)
.map_err(|_| format!("could not allocate the {purpose} buffer"))?;
buffer.resize(size, 0);
Ok(buffer)
}
fn axis_sample(source_size: usize, target_size: usize, target_index: usize) -> (usize, usize, u32) {
debug_assert!(source_size > 0 && target_size > 0 && target_size <= source_size);
let denominator = 2 * target_size as u64;
let numerator = (2 * target_index as u64 + 1) * source_size as u64 - target_size as u64;
let first = usize::try_from(numerator / denominator)
.unwrap_or(source_size - 1)
.min(source_size - 1);
let second = (first + 1).min(source_size - 1);
let weight = ((numerator % denominator) * 65_536 / denominator) as u32;
(first, second, weight)
}
fn lerp_byte(first: u8, second: u8, weight: u32) -> u8 {
let inverse = 65_536 - weight;
((u32::from(first) * inverse + u32::from(second) * weight + 32_768) >> 16) as u8
}
fn parse_exif_orientation(bytes: &[u8]) -> Result<u8, String> {
let bytes = bytes.strip_prefix(b"Exif\0\0").unwrap_or(bytes);
let little_endian = match bytes.get(..2) {
Some(b"II") => true,
Some(b"MM") => false,
_ => return Err("PNG contains malformed EXIF byte order".to_string()),
};
let read_u16 = |offset: usize| -> Option<u16> {
let end = offset.checked_add(2)?;
let bytes: [u8; 2] = bytes.get(offset..end)?.try_into().ok()?;
Some(if little_endian {
u16::from_le_bytes(bytes)
} else {
u16::from_be_bytes(bytes)
})
};
let read_u32 = |offset: usize| -> Option<u32> {
let end = offset.checked_add(4)?;
let bytes: [u8; 4] = bytes.get(offset..end)?.try_into().ok()?;
Some(if little_endian {
u32::from_le_bytes(bytes)
} else {
u32::from_be_bytes(bytes)
})
};
if read_u16(2) != Some(42) {
return Err("PNG contains malformed EXIF metadata".to_string());
}
let directory =
usize::try_from(read_u32(4).ok_or_else(|| "PNG EXIF directory is truncated".to_string())?)
.map_err(|_| "PNG EXIF directory is too large".to_string())?;
let entry_count = usize::from(
read_u16(directory).ok_or_else(|| "PNG EXIF directory is truncated".to_string())?,
);
for entry in 0..entry_count {
let offset = directory
.checked_add(2)
.and_then(|offset| {
entry
.checked_mul(12)
.and_then(|entry| offset.checked_add(entry))
})
.ok_or_else(|| "PNG EXIF directory overflowed".to_string())?;
let type_offset = offset
.checked_add(2)
.ok_or_else(|| "PNG EXIF directory overflowed".to_string())?;
let count_offset = offset
.checked_add(4)
.ok_or_else(|| "PNG EXIF directory overflowed".to_string())?;
let value_offset = offset
.checked_add(8)
.ok_or_else(|| "PNG EXIF directory overflowed".to_string())?;
if read_u16(offset) == Some(0x0112)
&& read_u16(type_offset) == Some(3)
&& read_u32(count_offset) == Some(1)
{
let orientation = read_u16(value_offset)
.ok_or_else(|| "PNG EXIF orientation is truncated".to_string())?;
return u8::try_from(orientation)
.ok()
.filter(|orientation| (1..=8).contains(orientation))
.ok_or_else(|| "PNG EXIF orientation is invalid".to_string());
}
}
Ok(1)
}
fn orient_rgba_image(
image: DecodedPremultipliedRgbaImage,
orientation: u8,
) -> Result<DecodedPremultipliedRgbaImage, String> {
if orientation == 1 {
return Ok(image);
}
let width = usize::try_from(image.width).map_err(|_| "PNG width is invalid".to_string())?;
let height = usize::try_from(image.height).map_err(|_| "PNG height is invalid".to_string())?;
let (output_width, output_height) = if orientation >= 5 {
(height, width)
} else {
(width, height)
};
let mut oriented = zeroed_byte_buffer(image.pixels.len(), "oriented PNG")?;
for y in 0..height {
for x in 0..width {
let (target_x, target_y) = match orientation {
2 => (width - 1 - x, y),
3 => (width - 1 - x, height - 1 - y),
4 => (x, height - 1 - y),
5 => (y, x),
6 => (height - 1 - y, x),
7 => (height - 1 - y, width - 1 - x),
8 => (y, width - 1 - x),
_ => return Err("PNG EXIF orientation is invalid".to_string()),
};
let source = (y * width + x) * DECODED_TEXTURE_BYTES_PER_PIXEL;
let target = (target_y * output_width + target_x) * DECODED_TEXTURE_BYTES_PER_PIXEL;
oriented[target..target + DECODED_TEXTURE_BYTES_PER_PIXEL]
.copy_from_slice(&image.pixels[source..source + DECODED_TEXTURE_BYTES_PER_PIXEL]);
}
}
Ok(DecodedPremultipliedRgbaImage {
width: i32::try_from(output_width).map_err(|_| "PNG width is too large".to_string())?,
height: i32::try_from(output_height).map_err(|_| "PNG height is too large".to_string())?,
pixels: oriented,
})
}
fn ensure_background_source_unchanged(key: &BackgroundImageKey) -> Result<(), String> {
match background_image_key(&key.canonical_path) {
Ok(current) if current == *key => Ok(()),
Ok(_) => Err("image changed while it was loading".to_string()),
Err(error) => Err(format!("image became unavailable while loading: {error}")),
}
}
fn ensure_open_background_source_unchanged(
file: &File,
key: &BackgroundImageKey,
) -> Result<(), String> {
let metadata = file.metadata().map_err(|error| error.to_string())?;
if metadata.is_file()
&& metadata.dev() == key.device
&& metadata.ino() == key.inode
&& metadata.size() == key.size
&& metadata.mtime() == key.modified_seconds
&& metadata.mtime_nsec() == key.modified_nanoseconds
&& metadata.ctime() == key.changed_seconds
&& metadata.ctime_nsec() == key.changed_nanoseconds
{
Ok(())
} else {
Err("opened image changed while it was loading".to_string())
}
}
fn complete_background_image_load(
key: BackgroundImageKey,
result: Result<DecodedPremultipliedRgbaImage, String>,
) {
let targets = BACKGROUND_IMAGE_LOADER.with(|loader| {
let mut loader = loader.borrow_mut();
let Some(active) = loader.active.take() else {
return Vec::new();
};
debug_assert_eq!(active.key, key);
active
.targets
.into_iter()
.filter_map(|target| target.upgrade())
.collect::<Vec<_>>()
});
if !targets.is_empty() {
match result.and_then(DecodedPremultipliedRgbaImage::into_texture) {
Ok(texture) => {
cache_background_texture(key.clone(), &texture);
for target in targets {
target.set_texture(&texture);
}
}
Err(error) => eprintln!(
"failed to load background image '{}': {error}",
terminal_safe_status_text(&key.canonical_path.display().to_string())
),
}
}
start_next_background_image_load();
}
fn start_next_background_image_load() {
let next = BACKGROUND_IMAGE_LOADER.with(|loader| {
let mut loader = loader.borrow_mut();
debug_assert!(loader.active.is_none());
while let Some(load) = loader.pending.pop_front() {
if load.targets.iter().any(|target| target.upgrade().is_some()) {
let key = load.key.clone();
loader.active = Some(load);
return Some(key);
}
}
None
});
if let Some(key) = next {
launch_background_image_load(key);
}
}
fn background_decode_dimensions(width: i32, height: i32) -> Option<(i32, i32)> {
if width <= 0
|| height <= 0
|| width > BACKGROUND_IMAGE_MAX_SOURCE_EDGE
|| height > BACKGROUND_IMAGE_MAX_SOURCE_EDGE
{
return None;
}
let source_pixels = source_pixel_count(width, height)?;
if source_pixels > BACKGROUND_IMAGE_MAX_SOURCE_PIXELS {
return None;
}
let edge_scale = (f64::from(BACKGROUND_IMAGE_MAX_DECODED_EDGE) / f64::from(width))
.min(f64::from(BACKGROUND_IMAGE_MAX_DECODED_EDGE) / f64::from(height));
let pixel_scale = ((BACKGROUND_IMAGE_MAX_DECODED_PIXELS as f64) / source_pixels as f64).sqrt();
let scale = edge_scale.min(pixel_scale).min(1.0);
let mut scaled_width = (f64::from(width) * scale).floor().max(1.0) as i32;
let mut scaled_height = (f64::from(height) * scale).floor().max(1.0) as i32;
while (scaled_width as u64) * (scaled_height as u64) > BACKGROUND_IMAGE_MAX_DECODED_PIXELS {
if scaled_width >= scaled_height && scaled_width > 1 {
scaled_width -= 1;
} else if scaled_height > 1 {
scaled_height -= 1;
} else {
return None;
}
}
debug_assert!(background_decoded_dimensions_valid(
scaled_width,
scaled_height
));
Some((scaled_width, scaled_height))
}
fn source_pixel_count(width: i32, height: i32) -> Option<u64> {
u64::try_from(width)
.ok()?
.checked_mul(u64::try_from(height).ok()?)
}
fn background_decoded_dimensions_valid(width: i32, height: i32) -> bool {
width > 0
&& height > 0
&& width <= BACKGROUND_IMAGE_MAX_DECODED_EDGE
&& height <= BACKGROUND_IMAGE_MAX_DECODED_EDGE
&& u64::try_from(width)
.ok()
.and_then(|width| {
u64::try_from(height)
.ok()
.and_then(|height| width.checked_mul(height))
})
.is_some_and(|pixels| pixels <= BACKGROUND_IMAGE_MAX_DECODED_PIXELS)
}
fn decoded_texture_bytes(width: i32, height: i32) -> Option<usize> {
let width = usize::try_from(width).ok()?;
let height = usize::try_from(height).ok()?;
width
.checked_mul(height)?
.checked_mul(DECODED_TEXTURE_BYTES_PER_PIXEL)
}
fn background_cache_eviction_count(
existing_sizes: &[usize],
incoming_size: usize,
capacity: usize,
byte_budget: usize,
) -> Option<usize> {
if capacity == 0 || incoming_size > byte_budget {
return None;
}
let mut retained_bytes = existing_sizes
.iter()
.fold(incoming_size, |total, size| total.saturating_add(*size));
let mut eviction_count = 0;
while existing_sizes.len() + 1 - eviction_count > capacity || retained_bytes > byte_budget {
let evicted = *existing_sizes.get(eviction_count)?;
retained_bytes = retained_bytes.saturating_sub(evicted);
eviction_count += 1;
}
Some(eviction_count)
}
fn background_base_widget(
color: gdk::RGBA,
opaque: bool,
) -> (gtk::DrawingArea, Rc<RefCell<BackgroundBaseState>>) {
let base = gtk::DrawingArea::new();
base.set_hexpand(true);
base.set_vexpand(true);
base.set_can_target(false);
let state = Rc::new(RefCell::new(BackgroundBaseState { color, opaque }));
let draw_state = state.clone();
base.set_draw_func(move |_, cr, width, height| {
let state = draw_state.borrow();
if !state.opaque {
let _ = cr.save();
cr.set_operator(gtk::cairo::Operator::Clear);
cr.rectangle(0.0, 0.0, f64::from(width), f64::from(height));
let _ = cr.fill();
let _ = cr.restore();
return;
}
cr.rectangle(0.0, 0.0, f64::from(width), f64::from(height));
cr.set_source_rgb(
f64::from(state.color.red()),
f64::from(state.color.green()),
f64::from(state.color.blue()),
);
let _ = cr.fill();
});
(base, state)
}
fn terminal_shade_widget(
color: gdk::RGBA,
opacity: f64,
) -> (gtk::DrawingArea, Rc<RefCell<ShadeState>>) {
let shade = gtk::DrawingArea::new();
shade.set_hexpand(true);
shade.set_vexpand(true);
shade.set_can_target(false);
let state = Rc::new(RefCell::new(ShadeState { color, opacity }));
let draw_state = state.clone();
shade.set_draw_func(move |_, cr, width, height| {
let state = draw_state.borrow();
cr.rectangle(0.0, 0.0, f64::from(width), f64::from(height));
cr.set_source_rgba(
f64::from(state.color.red()),
f64::from(state.color.green()),
f64::from(state.color.blue()),
state.opacity,
);
let _ = cr.fill();
});
(shade, state)
}
fn background_terminal_opacity(config: &BackgroundConfig, has_image: bool) -> f64 {
if has_image && config.terminal_opacity >= 0.999 {
DEFAULT_IMAGE_TERMINAL_OPACITY
} else {
config.terminal_opacity
}
}
pub fn effective_terminal_opacity(config: &BackgroundConfig, has_image: bool) -> f64 {
background_terminal_opacity(config, has_image)
}
fn terminal_theme(config: &TerminalConfig, overlay: Option<&gdk::RGBA>) -> TerminalTheme {
let mut theme = config.theme.with_background_alpha(0.0);
if config.cursor_match_overlay {
if let Some(color) = overlay {
let background = color_to_hex(color);
theme
.apply_overrides(
None,
None,
Some(&background),
Some(MATCHED_CURSOR_FOREGROUND),
None,
)
.expect("generated cursor colors are valid");
}
}
theme
}
fn color_to_hex(color: &gdk::RGBA) -> String {
let red = (color.red().clamp(0.0, 1.0) * 255.0).round() as u8;
let green = (color.green().clamp(0.0, 1.0) * 255.0).round() as u8;
let blue = (color.blue().clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{red:02x}{green:02x}{blue:02x}")
}
fn keep_terminal_pointer_visible(terminal: &vte::Terminal) {
let motion = gtk::EventControllerMotion::new();
let terminal_for_enter = terminal.downgrade();
motion.connect_enter(move |_, _, _| {
if let Some(terminal) = terminal_for_enter.upgrade() {
terminal.set_mouse_autohide(false);
terminal.set_cursor_from_name(Some("default"));
}
});
terminal.add_controller(motion);
}
fn install_visual_bell(terminal: &vte::Terminal) {
let pending_clear = Rc::new(RefCell::new(None::<glib::SourceId>));
let terminal_for_bell = terminal.downgrade();
terminal.connect_bell({
let pending_clear = pending_clear.clone();
move |_| {
let Some(terminal) = terminal_for_bell.upgrade() else {
return;
};
terminal.add_css_class("lios-visual-bell");
if let Some(source) = pending_clear.borrow_mut().take() {
source.remove();
}
let weak_terminal = terminal.downgrade();
let pending_clear_for_timeout = pending_clear.clone();
let source = glib::timeout_add_local_once(VISUAL_BELL_DURATION, move || {
pending_clear_for_timeout.borrow_mut().take();
if let Some(terminal) = weak_terminal.upgrade() {
terminal.remove_css_class("lios-visual-bell");
}
});
*pending_clear.borrow_mut() = Some(source);
}
});
}
fn install_link_opening(terminal: &vte::Terminal) {
URL_MATCH_REGEX.with(|regex| {
match regex.get_or_init(|| {
let regex = vte::Regex::for_match(URL_MATCH_PATTERN, VTE_REGEX_FLAGS)
.map_err(|error| error.to_string())?;
let _ = regex.jit(0);
Ok(regex)
}) {
Ok(regex) => {
let tag = terminal.match_add_regex(regex, 0);
terminal.match_set_cursor_name(tag, "pointer");
}
Err(error) => eprintln!("failed to compile terminal URL matcher: {error}"),
}
});
let click = gtk::GestureClick::new();
click.set_button(gdk::BUTTON_PRIMARY);
click.set_propagation_phase(gtk::PropagationPhase::Capture);
let pending_uri = Rc::new(RefCell::new(None::<String>));
let terminal_for_press = terminal.downgrade();
click.connect_pressed({
let pending_uri = pending_uri.clone();
move |gesture, n_press, x, y| {
pending_uri.borrow_mut().take();
if !is_link_activation_gesture(gesture.current_event_state(), n_press) {
return;
}
let Some(terminal) = terminal_for_press.upgrade() else {
return;
};
let Some(uri) = terminal_uri_at(&terminal, x, y) else {
return;
};
*pending_uri.borrow_mut() = Some(uri);
gesture.set_state(gtk::EventSequenceState::Claimed);
}
});
let terminal_for_release = terminal.downgrade();
click.connect_released({
let pending_uri = pending_uri.clone();
move |gesture, n_press, x, y| {
let Some(uri) = pending_uri.borrow_mut().take() else {
return;
};
let Some(terminal) = terminal_for_release.upgrade() else {
return;
};
let release_uri = terminal_uri_at(&terminal, x, y);
if !should_open_pressed_link(
&uri,
release_uri.as_deref(),
gesture.current_event_state(),
n_press,
) {
return;
}
gesture.set_state(gtk::EventSequenceState::Claimed);
open_uri(&uri);
}
});
click.connect_stopped({
let pending_uri = pending_uri.clone();
move |_| {
pending_uri.borrow_mut().take();
}
});
click.connect_cancel({
let pending_uri = pending_uri.clone();
move |_, _| {
pending_uri.borrow_mut().take();
}
});
terminal.add_controller(click);
}
fn is_link_activation_gesture(state: gdk::ModifierType, n_press: i32) -> bool {
let keyboard_modifiers = state
& (gdk::ModifierType::SHIFT_MASK
| gdk::ModifierType::CONTROL_MASK
| gdk::ModifierType::ALT_MASK
| gdk::ModifierType::SUPER_MASK
| gdk::ModifierType::HYPER_MASK
| gdk::ModifierType::META_MASK);
n_press == 1 && keyboard_modifiers == gdk::ModifierType::CONTROL_MASK
}
pub(crate) fn terminal_uri_at(terminal: &vte::Terminal, x: f64, y: f64) -> Option<String> {
terminal
.check_hyperlink_at(x, y)
.and_then(|uri| launchable_exact_uri(uri.as_str()))
.or_else(|| {
let (uri, _) = terminal.check_match_at(x, y);
uri.and_then(|uri| launchable_matched_uri(uri.as_str()))
})
}
fn launchable_exact_uri(value: &str) -> Option<String> {
if is_supported_launch_uri(value) {
Some(value.to_string())
} else {
None
}
}
fn launchable_matched_uri(value: &str) -> Option<String> {
let uri = trim_matched_uri(value);
if is_supported_launch_uri(uri) {
Some(uri.to_string())
} else {
None
}
}
fn is_supported_launch_uri(uri: &str) -> bool {
if uri.is_empty()
|| uri
.chars()
.any(|character| character.is_whitespace() || character.is_control())
{
return false;
}
let Ok(parsed) = glib::Uri::parse(uri, glib::UriFlags::NONE) else {
return false;
};
match parsed.scheme().to_ascii_lowercase().as_str() {
"http" | "https" => parsed.host().is_some_and(|host| !host.is_empty()),
"file" => !parsed.path().is_empty(),
_ => false,
}
}
fn trim_matched_uri(value: &str) -> &str {
let mut uri = value.trim();
loop {
let Some(last) = uri.chars().next_back() else {
return uri;
};
let should_trim = match last {
'.' | ',' | ';' | ':' | '!' | '?' | '>' | '"' | '。' | ',' | ';' | '!' | '?'
| '”' => true,
')' => delimiter_is_unbalanced(uri, '(', ')'),
']' => delimiter_is_unbalanced(uri, '[', ']'),
'}' => delimiter_is_unbalanced(uri, '{', '}'),
_ => false,
};
if !should_trim {
return uri;
}
uri = &uri[..uri.len() - last.len_utf8()];
}
}
fn delimiter_is_unbalanced(value: &str, open: char, close: char) -> bool {
value.chars().filter(|ch| *ch == close).count() > value.chars().filter(|ch| *ch == open).count()
}
fn should_open_pressed_link(
pressed_uri: &str,
release_uri: Option<&str>,
release_state: gdk::ModifierType,
n_press: i32,
) -> bool {
is_link_activation_gesture(release_state, n_press)
&& release_uri.is_some_and(|uri| uri == pressed_uri)
}
fn open_uri(uri: &str) {
gio::AppInfo::launch_default_for_uri_async(
uri,
None::<&gio::AppLaunchContext>,
None::<&gio::Cancellable>,
|result| {
if let Err(error) = result {
eprintln!(
"failed to open link: {}",
terminal_safe_status_text(&error.to_string())
);
}
},
);
}
fn overlay_color(config: &BackgroundConfig) -> Option<gdk::RGBA> {
if config.overlay_opacity <= 0.0 {
None
} else if config.random_overlay {
Some(random_accent_color())
} else {
config.overlay_color
}
}
fn color_overlay_widget(
color: Option<gdk::RGBA>,
opacity: f64,
) -> (gtk::DrawingArea, Rc<RefCell<TintState>>) {
let area = gtk::DrawingArea::new();
area.set_hexpand(true);
area.set_vexpand(true);
area.set_can_target(false);
let state = Rc::new(RefCell::new(TintState { color, opacity }));
let draw_state = state.clone();
area.set_draw_func(move |_, cr, width, height| {
let state = draw_state.borrow();
let Some(color) = state.color else {
return;
};
cr.rectangle(0.0, 0.0, f64::from(width), f64::from(height));
cr.set_source_rgba(
f64::from(color.red()),
f64::from(color.green()),
f64::from(color.blue()),
state.opacity,
);
let _ = cr.fill();
});
(area, state)
}
fn random_accent_color() -> gdk::RGBA {
let hue = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos() % 360)
.unwrap_or(210) as f64
/ 360.0;
let (red, green, blue) = hsv_to_rgb(hue, 0.62, 0.95);
gdk::RGBA::new(red, green, blue, 1.0)
}
fn hsv_to_rgb(hue: f64, saturation: f64, value: f64) -> (f32, f32, f32) {
let scaled = hue * 6.0;
let sector = scaled.floor();
let fraction = scaled - sector;
let p = value * (1.0 - saturation);
let q = value * (1.0 - fraction * saturation);
let t = value * (1.0 - (1.0 - fraction) * saturation);
let (red, green, blue) = match sector as u8 % 6 {
0 => (value, t, p),
1 => (q, value, p),
2 => (p, value, t),
3 => (p, q, value),
4 => (t, p, value),
_ => (value, p, q),
};
(red as f32, green as f32, blue as f32)
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
fn test_temp_path(kind: &str) -> PathBuf {
prompt_script_path_in(&env::temp_dir(), kind)
}
#[test]
fn close_confirmation_distinguishes_idle_shells_and_running_work() {
assert!(!running_process_needs_confirmation(true, None, None, false));
assert!(!running_process_needs_confirmation(
true,
Some(41),
Some(41),
false
));
assert!(running_process_needs_confirmation(
true,
Some(41),
Some(52),
false
));
assert!(running_process_needs_confirmation(
true,
Some(41),
Some(41),
true
));
assert!(running_process_needs_confirmation(
false,
Some(41),
Some(41),
false
));
}
#[test]
fn failed_foreground_lookup_requires_confirmation_while_child_is_alive() {
assert!(running_process_needs_confirmation(
true,
Some(41),
None,
false
));
}
#[test]
fn background_cache_evicts_by_decoded_bytes_before_entry_count() {
const MIB: usize = 1024 * 1024;
assert_eq!(
background_cache_eviction_count(
&[48 * MIB, 48 * MIB, 16 * MIB],
48 * MIB,
4,
128 * MIB,
),
Some(1)
);
assert_eq!(
background_cache_eviction_count(
&[64 * MIB, 64 * MIB, 64 * MIB],
64 * MIB,
4,
128 * MIB,
),
Some(2)
);
assert_eq!(
background_cache_eviction_count(&[1, 1, 1, 1], 1, 4, 128 * MIB),
Some(1)
);
}
#[test]
fn background_cache_uses_the_full_budget_for_common_large_images() {
const MIB: usize = 1024 * 1024;
let uhd = decoded_texture_bytes(3840, 2160).unwrap();
let eight_k = decoded_texture_bytes(7680, 4320).unwrap();
assert_eq!(
background_cache_eviction_count(&[64 * MIB], 64 * MIB, 4, 128 * MIB),
Some(0)
);
assert_eq!(
background_cache_eviction_count(&[uhd, uhd, uhd], uhd, 4, 128 * MIB),
Some(0)
);
assert_eq!(
background_cache_eviction_count(&[eight_k], eight_k, 4, 128 * MIB),
Some(1)
);
}
#[test]
fn background_cache_bypasses_oversized_or_disabled_entries() {
const MIB: usize = 1024 * 1024;
assert_eq!(
background_cache_eviction_count(&[16 * MIB], 129 * MIB, 4, 128 * MIB),
None
);
assert_eq!(background_cache_eviction_count(&[], 1, 0, 128 * MIB), None);
}
#[test]
fn background_loader_serializes_files_and_prunes_stale_duplicate_targets() {
let key = |name: &str, inode| BackgroundImageKey {
canonical_path: PathBuf::from(name),
device: 1,
inode,
size: 10,
modified_seconds: 20,
modified_nanoseconds: 30,
changed_seconds: 40,
changed_nanoseconds: 50,
};
let first = key("/wallpaper/first.png", 1);
let second = key("/wallpaper/second.png", 2);
let mut loader = BackgroundImageLoader {
active: None,
pending: VecDeque::new(),
};
assert_eq!(
enqueue_background_image_load(
&mut loader,
first.clone(),
glib::WeakRef::<CoverImage>::new(),
),
Some(first.clone())
);
assert_eq!(
enqueue_background_image_load(
&mut loader,
first.clone(),
glib::WeakRef::<CoverImage>::new(),
),
None
);
assert_eq!(
enqueue_background_image_load(
&mut loader,
second.clone(),
glib::WeakRef::<CoverImage>::new(),
),
None
);
assert_eq!(
enqueue_background_image_load(
&mut loader,
second.clone(),
glib::WeakRef::<CoverImage>::new(),
),
None
);
let active = loader.active.as_ref().unwrap();
assert_eq!(active.key, first);
assert_eq!(active.targets.len(), 1);
assert_eq!(loader.pending.len(), 1);
assert_eq!(loader.pending[0].key, second);
assert_eq!(loader.pending[0].targets.len(), 1);
}
#[test]
fn decoded_texture_size_validates_dimensions() {
assert_eq!(decoded_texture_bytes(3840, 2160), Some(33_177_600));
assert_eq!(decoded_texture_bytes(-1, 2160), None);
}
#[test]
fn background_decoder_rejects_a_replaced_path_before_reading_it() {
let path = test_temp_path("background-identity-original");
let displaced = test_temp_path("background-identity-displaced");
fs::write(&path, PNG_SIGNATURE).unwrap();
let key = background_image_key(&path).unwrap();
fs::rename(&path, &displaced).unwrap();
fs::write(&path, b"replacement").unwrap();
let error = decode_background_image_blocking(&key).unwrap_err();
let _ = fs::remove_file(path);
let _ = fs::remove_file(displaced);
assert!(error.contains("opened image changed while it was loading"));
}
#[test]
fn background_decoder_keeps_reading_the_verified_open_file() {
let path = test_temp_path("background-open-file-original.png");
let file = File::create(&path).unwrap();
let mut encoder = png::Encoder::new(file, 1, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&[10, 20, 30, 255]).unwrap();
drop(writer);
let mut key = background_image_key(&path).unwrap();
let source = File::open(&path).unwrap();
key.canonical_path = path.with_extension("path-must-not-be-reopened");
let decoded = decode_open_background_image(&source, &key).unwrap();
assert!(ensure_background_source_unchanged(&key).is_err());
let _ = fs::remove_file(path);
assert_eq!((decoded.width, decoded.height), (1, 1));
assert_eq!(decoded.pixels, vec![10, 20, 30, 255]);
}
#[test]
fn opened_background_identity_detects_in_place_mutation() {
let path = test_temp_path("background-identity-mutation");
fs::write(&path, b"original").unwrap();
let key = background_image_key(&path).unwrap();
let file = File::open(&path).unwrap();
assert!(ensure_open_background_source_unchanged(&file, &key).is_ok());
fs::write(&path, b"changed and longer").unwrap();
let result = ensure_open_background_source_unchanged(&file, &key);
let _ = fs::remove_file(path);
assert!(result.is_err());
}
#[test]
fn background_decode_preserves_images_within_the_safe_budget() {
assert_eq!(background_decode_dimensions(3840, 2160), Some((3840, 2160)));
assert_eq!(background_decode_dimensions(1920, 1080), Some((1920, 1080)));
assert_eq!(background_decode_dimensions(1, 1), Some((1, 1)));
}
#[test]
fn background_decode_downscales_extreme_images_proportionally() {
assert_eq!(background_decode_dimensions(7680, 4320), Some((3840, 2160)));
assert_eq!(background_decode_dimensions(8000, 8000), Some((2880, 2880)));
assert_eq!(background_decode_dimensions(65_000, 100), Some((4096, 6)));
}
#[test]
fn background_decode_rejects_invalid_or_hostile_source_dimensions() {
assert_eq!(background_decode_dimensions(0, 1080), None);
assert_eq!(background_decode_dimensions(1920, -1), None);
assert_eq!(background_decode_dimensions(65_536, 100), None);
assert_eq!(background_decode_dimensions(30_000, 30_000), None);
}
#[test]
fn oversized_backgrounds_require_an_audited_scaling_path() {
assert!(background_format_can_scale_safely(Some("jpeg")));
assert!(!background_format_can_scale_safely(Some("svg")));
assert!(!background_format_can_scale_safely(Some("webp")));
assert!(!background_format_can_scale_safely(Some("tiff")));
assert!(!background_format_can_scale_safely(None));
}
#[test]
fn jpeg_source_area_and_orientation_copy_are_bounded() {
assert_eq!(source_pixel_count(7680, 4320), Some(33_177_600));
assert!(
source_pixel_count(10_000, 4_001).unwrap() > BACKGROUND_IMAGE_MAX_JPEG_SOURCE_PIXELS
);
assert!(!pixbuf_orientation_needs_transform(None));
assert!(!pixbuf_orientation_needs_transform(Some("1")));
assert!(pixbuf_orientation_needs_transform(Some("6")));
}
#[test]
fn background_decode_outputs_always_fit_the_texture_budget() {
for (width, height) in [
(4097, 4097),
(7680, 4320),
(4320, 7680),
(12_345, 6789),
(11_000, 11_000),
(65_535, 1),
(1, 65_535),
] {
let (decoded_width, decoded_height) =
background_decode_dimensions(width, height).unwrap();
assert!(background_decoded_dimensions_valid(
decoded_width,
decoded_height
));
assert!(decoded_width <= width);
assert!(decoded_height <= height);
}
}
#[test]
fn streaming_image_sampling_maps_pixel_centers() {
assert_eq!(axis_sample(4, 4, 0), (0, 1, 0));
assert_eq!(axis_sample(4, 4, 3), (3, 3, 0));
assert_eq!(axis_sample(4, 2, 0), (0, 1, 32_768));
assert_eq!(axis_sample(4, 2, 1), (2, 3, 32_768));
assert_eq!(axis_sample(8, 1, 0), (3, 4, 32_768));
}
#[test]
fn streaming_image_interpolation_uses_premultiplied_color() {
assert_eq!(lerp_byte(10, 20, 0), 10);
assert_eq!(lerp_byte(10, 20, 32_768), 15);
assert_eq!(premultiply_byte(255, 0), 0);
assert_eq!(premultiply_byte(200, 128), 100);
assert_eq!(
png_pixel(&[255, 64, 32, 128], 0, png::ColorType::Rgba).unwrap(),
[128, 32, 16, 128]
);
}
#[test]
fn png_decompression_work_accounts_for_channels_and_bit_depth() {
assert_eq!(
png_source_work_bytes(7680, 4320, png::ColorType::Rgba, png::BitDepth::Eight),
Some(132_710_400)
);
assert_eq!(
png_source_work_bytes(8192, 8192, png::ColorType::Rgba, png::BitDepth::Sixteen),
Some(BACKGROUND_IMAGE_MAX_PNG_DECODE_WORK_BYTES)
);
assert!(
png_source_work_bytes(8193, 8192, png::ColorType::Rgba, png::BitDepth::Sixteen)
.unwrap()
> BACKGROUND_IMAGE_MAX_PNG_DECODE_WORK_BYTES
);
assert_eq!(
png_source_work_bytes(1, 8, png::ColorType::Grayscale, png::BitDepth::One),
Some(8)
);
}
#[test]
fn png_exif_orientation_supports_both_byte_orders() {
let little_endian = [
b'I', b'I', 42, 0, 8, 0, 0, 0, 1, 0, 0x12, 0x01, 3, 0, 1, 0, 0, 0, 6, 0, 0, 0,
];
let big_endian = [
b'M', b'M', 0, 42, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, 8, 0, 0,
];
assert_eq!(parse_exif_orientation(&little_endian).unwrap(), 6);
assert_eq!(parse_exif_orientation(&big_endian).unwrap(), 8);
assert!(parse_exif_orientation(b"not exif").is_err());
}
#[test]
fn png_orientation_rotates_premultiplied_pixels_without_loss() {
let image = DecodedPremultipliedRgbaImage {
width: 2,
height: 1,
pixels: vec![1, 2, 3, 4, 5, 6, 7, 8],
};
let rotated = orient_rgba_image(image, 6).unwrap();
assert_eq!((rotated.width, rotated.height), (1, 2));
assert_eq!(rotated.pixels, vec![1, 2, 3, 4, 5, 6, 7, 8]);
}
#[test]
fn png_orientation_maps_all_exif_transforms() {
let expected = [
(2, 3, vec![1, 2, 3, 4, 5, 6]),
(2, 3, vec![2, 1, 4, 3, 6, 5]),
(2, 3, vec![6, 5, 4, 3, 2, 1]),
(2, 3, vec![5, 6, 3, 4, 1, 2]),
(3, 2, vec![1, 3, 5, 2, 4, 6]),
(3, 2, vec![5, 3, 1, 6, 4, 2]),
(3, 2, vec![6, 4, 2, 5, 3, 1]),
(3, 2, vec![2, 4, 6, 1, 3, 5]),
];
for (orientation, (width, height, expected_ids)) in (1..=8).zip(expected) {
let pixels = (1..=6).flat_map(|id| [id, 0, 0, 255]).collect::<Vec<_>>();
let transformed = orient_rgba_image(
DecodedPremultipliedRgbaImage {
width: 2,
height: 3,
pixels,
},
orientation,
)
.unwrap();
let actual_ids = transformed
.pixels
.chunks_exact(4)
.map(|pixel| pixel[0])
.collect::<Vec<_>>();
assert_eq!((transformed.width, transformed.height), (width, height));
assert_eq!(actual_ids, expected_ids);
}
}
#[test]
fn bounded_png_decoder_streams_and_downscales_rgba() {
let path = test_temp_path("wallpaper-test.png");
let file = File::create(&path).unwrap();
let mut encoder = png::Encoder::new(file, 4, 2);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[
255, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 255, 0, 255, 0, 255, 255, 0, 0, 255,
255, 0, 0, 255, 0, 255, 0, 255, 0, 255, 0, 255,
])
.unwrap();
drop(writer);
let decoded =
decode_png_background_at_size(File::open(&path).unwrap(), Some((2, 1))).unwrap();
let _ = fs::remove_file(path);
assert_eq!((decoded.width, decoded.height), (2, 1));
assert_eq!(decoded.pixels.len(), 2 * 4);
assert_eq!(decoded.pixels[3], 255);
assert_eq!(decoded.pixels[7], 255);
}
#[test]
fn bounded_png_decoder_expands_palette_transparency() {
let path = test_temp_path("palette-wallpaper-test.png");
let file = File::create(&path).unwrap();
let mut encoder = png::Encoder::new(file, 2, 1);
encoder.set_color(png::ColorType::Indexed);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_palette(vec![255, 0, 0, 0, 255, 0]);
encoder.set_trns(vec![0, 255]);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&[0, 1]).unwrap();
drop(writer);
let decoded =
decode_png_background_at_size(File::open(&path).unwrap(), Some((2, 1))).unwrap();
let _ = fs::remove_file(path);
assert_eq!(decoded.pixels, vec![0, 0, 0, 0, 0, 255, 0, 255]);
}
#[test]
fn generic_pixel_copy_handles_alpha_rgb_and_row_padding() {
assert_eq!(
premultiplied_rgba_from_rows(&[255, 64, 32, 128, 0, 255, 0, 255], 2, 1, 4, 8,).unwrap(),
vec![128, 32, 16, 128, 0, 255, 0, 255]
);
assert_eq!(
premultiplied_rgba_from_rows(&[1, 2, 3, 99, 4, 5, 6], 1, 2, 3, 4).unwrap(),
vec![1, 2, 3, 255, 4, 5, 6, 255]
);
assert!(premultiplied_rgba_from_rows(&[1, 2], 1, 1, 3, 3).is_err());
assert!(premultiplied_rgba_from_rows(&[1, 2, 3], 1, 1, 2, 2).is_err());
}
#[test]
fn bounded_png_decoder_normalizes_sixteen_bit_color() {
let path = test_temp_path("sixteen-bit-wallpaper-test.png");
let file = File::create(&path).unwrap();
let mut encoder = png::Encoder::new(file, 1, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Sixteen);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 255, 128, 0, 0, 0, 128, 0])
.unwrap();
drop(writer);
let decoded =
decode_png_background_at_size(File::open(&path).unwrap(), Some((1, 1))).unwrap();
let _ = fs::remove_file(path);
assert_eq!(decoded.pixels, vec![128, 64, 0, 128]);
}
#[test]
fn bounded_png_decoder_rejects_truncated_image_data() {
let path = test_temp_path("truncated-wallpaper-test.png");
let file = File::create(&path).unwrap();
let mut encoder = png::Encoder::new(file, 64, 64);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&[127; 64 * 64 * 4]).unwrap();
drop(writer);
let mut encoded = fs::read(&path).unwrap();
encoded.truncate(encoded.len() / 2);
fs::write(&path, encoded).unwrap();
let result = decode_png_background_at_size(File::open(&path).unwrap(), Some((8, 8)));
let _ = fs::remove_file(path);
assert!(result.is_err());
}
#[test]
fn bounded_png_decoder_rejects_oversized_interlaced_sources() {
const INTERLACED_PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00,
0x01, 0xf6, 0x8d, 0x93, 0x45, 0x00, 0x00, 0x00, 0x13, 0x49, 0x44, 0x41, 0x54, 0x08,
0xd7, 0x63, 0x68, 0x60, 0x68, 0x60, 0x38, 0x00, 0x86, 0x1f, 0x18, 0x3e, 0x00, 0x00,
0x1f, 0x8e, 0x05, 0x21, 0xce, 0x83, 0x12, 0x69, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
let path = test_temp_path("interlaced-wallpaper-test.png");
fs::write(&path, INTERLACED_PNG).unwrap();
let decoded =
decode_png_background_at_size(File::open(&path).unwrap(), Some((4, 4))).unwrap();
assert_eq!((decoded.width, decoded.height), (4, 4));
assert_eq!(decoded.pixels, vec![255; 4 * 4 * 4]);
let result = decode_png_background_at_size(File::open(&path).unwrap(), Some((2, 2)));
let _ = fs::remove_file(path);
assert!(
result
.unwrap_err()
.contains("interlaced PNGs cannot be downscaled")
);
}
#[test]
fn prompt_script_creation_retries_the_explicit_fallback_path() {
let primary = Path::new("/runtime/lios-prompt.sh");
let fallback = Path::new("/tmp/lios-prompt.sh");
let mut attempts = Vec::new();
let result = with_prompt_script_paths(primary, Some(fallback), |path| {
attempts.push(path.to_path_buf());
if path == primary {
Err(std::io::Error::from(std::io::ErrorKind::ReadOnlyFilesystem))
} else {
Ok(path.to_path_buf())
}
})
.unwrap();
assert_eq!(attempts, vec![primary, fallback]);
assert_eq!(result, fallback);
}
#[test]
fn prompt_script_creation_preserves_primary_error_without_fallback() {
let error = with_prompt_script_paths(Path::new("/tmp/primary"), None, |_| {
Err::<(), _>(std::io::Error::from(std::io::ErrorKind::StorageFull))
})
.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::StorageFull);
}
#[test]
fn prompt_runtime_scripts_use_an_owned_private_subdirectory() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let base = env::temp_dir().join(format!(
"lios-private-base-test-{}-{}",
std::process::id(),
PROMPT_SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed)
));
let mut builder = fs::DirBuilder::new();
builder.mode(0o700).create(&base).unwrap();
let private = ensure_private_prompt_directory(&base).unwrap();
let metadata = fs::symlink_metadata(&private).unwrap();
assert_eq!(metadata.uid(), unsafe { libc::geteuid() });
assert_eq!(metadata.mode() & 0o077, 0);
assert!(private.starts_with(&base));
fs::set_permissions(&base, fs::Permissions::from_mode(0o777)).unwrap();
assert!(ensure_private_prompt_directory(&base).is_none());
let non_utf8 = PathBuf::from(OsString::from_vec(b"/tmp/\xff".to_vec()));
assert!(ensure_private_prompt_directory(&non_utf8).is_none());
fs::set_permissions(&base, fs::Permissions::from_mode(0o700)).unwrap();
fs::remove_dir_all(base).unwrap();
}
#[test]
fn child_environment_exports_terminal_identity() {
let envv = child_environment(Some("/tmp"));
assert_eq!(env_values(&envv, "TERM"), vec![CHILD_TERM]);
assert_eq!(env_values(&envv, "COLORTERM"), vec![CHILD_COLORTERM]);
assert_eq!(env_values(&envv, "TERM_PROGRAM"), vec![CHILD_TERM_PROGRAM]);
assert_eq!(env_values(&envv, "TERM_PROGRAM_VERSION").len(), 1);
assert!(env_values(&envv, "VTE_VERSION").is_empty());
assert!(env_values(&envv, "LIOS_IMAGE_PROTOCOL").is_empty());
assert_eq!(env_values(&envv, "PWD"), vec!["/tmp"]);
assert!(env_values(&envv, "LIOS_PS1_PROFILE").is_empty());
}
#[test]
fn child_environment_skips_non_utf8_entries_without_panicking() {
use std::os::unix::ffi::OsStringExt;
let variables = vec![
(OsString::from("PATH"), OsString::from("/usr/bin")),
(
OsString::from_vec(b"NON_UTF8_VALUE".to_vec()),
OsString::from_vec(b"bad-\xff".to_vec()),
),
(
OsString::from_vec(b"BAD_\xff_KEY".to_vec()),
OsString::from("value"),
),
];
let envv = child_environment_from(variables, Some("/tmp"));
assert_eq!(env_values(&envv, "PATH"), vec!["/usr/bin"]);
assert!(env_values(&envv, "NON_UTF8_VALUE").is_empty());
assert_eq!(env_values(&envv, "PWD"), vec!["/tmp"]);
assert_eq!(env_values(&envv, "TERM"), vec![CHILD_TERM]);
}
#[test]
fn terminal_status_text_escapes_control_sequences() {
assert_eq!(
terminal_safe_status_text("bad\u{1b}[31m\npath\u{9b}"),
r"bad\u{1b}[31m\npath\u{9b}"
);
}
#[test]
fn bridge_contained_spawn_flags_retain_vte_scope_bit() {
let flags = terminal_spawn_flags(Some(OsStr::new("1")));
assert_eq!(
flags.bits(),
glib::SpawnFlags::SEARCH_PATH.bits() | vte::ffi::VTE_SPAWN_NO_SYSTEMD_SCOPE as u32
);
}
#[test]
fn bridge_contained_spawn_marker_must_be_exact() {
for marker in [
None,
Some(""),
Some("true"),
Some("01"),
Some(" 1"),
Some("1 "),
] {
let flags = terminal_spawn_flags(marker.map(OsStr::new));
assert_eq!(flags, glib::SpawnFlags::SEARCH_PATH, "marker: {marker:?}");
}
assert!(!should_strip_child_env(LIOS_BRIDGE_CONTAINED));
}
#[test]
fn child_environment_leaves_prompt_syntax_to_the_selected_shell() {
let variables = vec![
(OsString::from("PS1"), OsString::from("shell-owned> ")),
(OsString::from("LIOS_PS1_PROFILE"), OsString::from("stale")),
];
let envv = child_environment_from(variables, Some("/tmp"));
assert_eq!(env_values(&envv, "PS1"), vec!["shell-owned> "]);
assert!(env_values(&envv, "LIOS_PS1_PROFILE").is_empty());
}
#[test]
fn startup_script_tracking_accepts_only_the_generated_bash_argv_shape() {
let argv = vec![
"/bin/bash".to_string(),
"--rcfile".to_string(),
"/tmp/lios-startup.sh".to_string(),
"-i".to_string(),
];
assert_eq!(
startup_prompt_script_from_argv(&argv),
Some(PathBuf::from("/tmp/lios-startup.sh"))
);
assert_eq!(
startup_prompt_script_from_argv(&["/bin/bash".to_string()]),
None
);
}
#[test]
fn bash_prompt_rcfile_sources_user_bashrc_then_applies_prompt() {
let path = test_temp_path("startup-test");
let prompt = PromptConfig::named("akira").unwrap();
write_bash_prompt_rcfile_at(&path, &prompt).unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains(". \"$HOME/.bashrc\""));
assert!(contents.starts_with("rm -f -- "));
assert!(contents.contains("export LIOS_PS1_PROFILE='akira'"));
assert!(contents.contains(&format!("export PS1={}", shell_quote(AKIRA_PS1))));
assert!(contents.contains("rm -f -- "));
let _ = fs::remove_file(path);
}
#[test]
fn bash_startup_applies_selected_prompt() {
let bash = Path::new("/bin/bash");
if !bash.is_file() {
return;
}
let startup_path = test_temp_path("startup-test");
let home_path = test_temp_path("home-test");
fs::create_dir(&home_path).unwrap();
fs::write(home_path.join(".bashrc"), "").unwrap();
write_bash_prompt_rcfile_at(&startup_path, &PromptConfig::named("akira").unwrap()).unwrap();
let output = Command::new(bash)
.arg("--rcfile")
.arg(&startup_path)
.arg("-ic")
.arg("printf '%s:%s' \"$LIOS_PS1_PROFILE\" \"$PS1\"")
.env("HOME", &home_path)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.starts_with("akira:"));
assert!(stdout.contains("AKIRA"));
let _ = fs::remove_file(startup_path);
let _ = fs::remove_dir_all(home_path);
}
#[test]
fn interactive_bash_pty_starts_with_selected_profile_without_setup_input() {
let bash = Path::new("/bin/bash");
let script = Path::new("/usr/bin/script");
if !bash.is_file() || !script.is_file() {
return;
}
let startup_path = test_temp_path("startup-test");
let home_path = test_temp_path("home-test");
fs::create_dir(&home_path).unwrap();
fs::write(home_path.join(".bashrc"), "").unwrap();
write_bash_prompt_rcfile_at(&startup_path, &PromptConfig::named("akira").unwrap()).unwrap();
let shell_command = format!(
"/bin/bash --rcfile {} -i",
shell_quote(&startup_path.to_string_lossy())
);
let mut child = Command::new(script)
.arg("-qfec")
.arg(shell_command)
.arg("/dev/null")
.env("HOME", &home_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
thread::sleep(Duration::from_millis(100));
let mut stdin = child.stdin.take().unwrap();
stdin
.write_all(
b"printf '__LIOS_PROFILE__%s__PROMPT__%s__END__\\n' \"$LIOS_PS1_PROFILE\" \"$PS1\"; exit\n",
)
.unwrap();
drop(stdin);
let output = child.wait_with_output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(stdout.contains("__LIOS_PROFILE__akira__PROMPT__"));
assert!(stdout.contains("AKIRA"));
assert!(!stdout.contains(". /tmp/"));
let _ = fs::remove_file(startup_path);
let _ = fs::remove_dir_all(home_path);
}
#[test]
fn bash_shell_detection_accepts_bash_only() {
assert!(is_bash_shell("/bin/bash"));
assert!(is_bash_shell("/usr/bin/rbash"));
assert!(!is_bash_shell("/bin/zsh"));
}
#[test]
fn prompt_profile_builds_session_export_script() {
let prompt = PromptConfig::named("lightbar").unwrap();
let script = prompt.activation_script().unwrap();
assert!(script.contains("LIOS_ORIGINAL_PS1"));
assert!(script.contains("export LIOS_PS1_PROFILE='lightbar'"));
assert!(script.contains(&format!("export PS1={}", shell_quote(LIGHTBAR_PS1))));
}
#[test]
fn prompt_profile_builds_multiline_prompt_exports() {
let prompt = PromptConfig::named("timebar").unwrap();
let script = prompt.activation_script().unwrap();
assert!(script.contains("export LIOS_PS1_PROFILE='timebar'"));
assert!(script.contains(&format!("export PS1={}", shell_quote(TIMEBAR_PS1))));
assert!(script.contains(&format!("export PS2={}", shell_quote(TIMEBAR_PS2))));
assert!(script.contains(&format!("export PS3={}", shell_quote(TIMEBAR_PS3))));
}
#[test]
fn prompt_profile_accepts_themed_aliases() {
assert_eq!(
PromptConfig::canonical_name("time-travel").unwrap(),
"timebar"
);
assert_eq!(
PromptConfig::canonical_name("fibonacci").unwrap(),
"fibonnaci"
);
assert_eq!(PromptConfig::canonical_name("5.56").unwrap(), "fano-plane");
assert_eq!(PromptConfig::canonical_name("leet").unwrap(), "alice-bob");
assert_eq!(PromptConfig::canonical_name("neo-tokyo").unwrap(), "akira");
assert!(PromptConfig::canonical_name("white-rabbit").is_err());
assert!(PromptConfig::canonical_name("hacker").is_err());
assert!(PromptConfig::canonical_name("1776").is_err());
}
#[test]
fn prompt_profile_accepts_default_aliases() {
assert_eq!(
PromptConfig::canonical_name("original").unwrap(),
SHELL_DEFAULT_PROMPT_PROFILE
);
assert!(
PromptConfig::named("shell-default")
.unwrap()
.ps1()
.is_none()
);
}
#[test]
fn child_environment_strips_inherited_terminal_identity() {
for key in [
"COLORTERM",
"TERM",
"TERM_PROGRAM",
"TERM_PROGRAM_VERSION",
"VTE_VERSION",
"GSK_RENDERER",
"LIOS_IMAGE_PROTOCOL",
"LIOS_PS1_PROFILE",
] {
assert!(should_strip_child_env(key), "{key} should be stripped");
}
assert!(!should_strip_child_env("PATH"));
assert!(!should_strip_child_env("SHELL"));
assert!(!should_strip_child_env("PS1"));
assert!(!should_strip_child_env("PS2"));
assert!(!should_strip_child_env("PS3"));
}
#[test]
fn launchable_uri_accepts_local_http_url() {
assert_eq!(
launchable_matched_uri("http://localhost:3000").as_deref(),
Some("http://localhost:3000")
);
assert_eq!(
launchable_exact_uri("HTTPS://EXAMPLE.TEST/path").as_deref(),
Some("HTTPS://EXAMPLE.TEST/path")
);
}
#[test]
fn launchable_uri_trims_terminal_punctuation() {
assert_eq!(
launchable_matched_uri(" https://example.test/path). ").as_deref(),
Some("https://example.test/path")
);
}
#[test]
fn exact_hyperlink_preserves_valid_trailing_characters() {
assert_eq!(
launchable_exact_uri("http://[::1]/wiki/Function_(math).").as_deref(),
Some("http://[::1]/wiki/Function_(math).")
);
}
#[test]
fn matched_uri_preserves_balanced_delimiters() {
assert_eq!(
launchable_matched_uri("https://example.test/wiki/Function_(math)").as_deref(),
Some("https://example.test/wiki/Function_(math)")
);
assert_eq!(
launchable_matched_uri("http://[::1]/").as_deref(),
Some("http://[::1]/")
);
}
#[test]
fn matched_uri_accepts_file_uri_and_trims_unicode_punctuation() {
assert_eq!(
launchable_matched_uri("file:/tmp/report.txt。").as_deref(),
Some("file:/tmp/report.txt")
);
}
#[test]
fn launchable_uri_rejects_unsupported_scheme() {
assert!(launchable_matched_uri("javascript:alert(1)").is_none());
}
#[test]
fn launchable_uri_rejects_malformed_or_unsafe_targets() {
for uri in [
"https://",
"https://example.test/a b",
"https://example.test/a\nb",
"file:",
"file:\0/tmp/report.txt",
] {
assert!(launchable_exact_uri(uri).is_none(), "accepted {uri:?}");
}
}
#[test]
fn link_activation_requires_same_uri_single_control_click() {
let control = gdk::ModifierType::CONTROL_MASK;
let shift = gdk::ModifierType::SHIFT_MASK;
let lock = gdk::ModifierType::LOCK_MASK;
let uri = "https://example.test/path";
assert!(is_link_activation_gesture(control, 1));
assert!(is_link_activation_gesture(control | lock, 1));
assert!(!is_link_activation_gesture(control | shift, 1));
assert!(!is_link_activation_gesture(
control | gdk::ModifierType::HYPER_MASK,
1
));
assert!(should_open_pressed_link(uri, Some(uri), control, 1));
assert!(!should_open_pressed_link(uri, None, control, 1));
assert!(!should_open_pressed_link(
uri,
Some("https://example.test/other"),
control,
1
));
assert!(!should_open_pressed_link(uri, Some(uri), shift, 1));
assert!(!should_open_pressed_link(uri, Some(uri), control, 2));
}
#[test]
fn vte_regex_flags_include_required_defaults() {
let defaults = vte::ffi::VTE_REGEX_FLAGS_DEFAULT as u32;
assert_eq!(VTE_REGEX_FLAGS & defaults, defaults);
assert_eq!(VTE_REGEX_FLAGS & PCRE2_MULTILINE, PCRE2_MULTILINE);
assert_eq!(VTE_REGEX_FLAGS & PCRE2_UCP, PCRE2_UCP);
}
#[test]
fn effective_terminal_opacity_dims_opaque_image_backgrounds() {
let config = BackgroundConfig {
image: Some(PathBuf::from("background.png")),
..BackgroundConfig::default()
};
assert_eq!(
effective_terminal_opacity(&config, true),
DEFAULT_IMAGE_TERMINAL_OPACITY
);
assert_eq!(
effective_terminal_opacity(&config, false),
DEFAULT_TERMINAL_OPACITY
);
}
#[test]
fn effective_terminal_opacity_preserves_explicit_image_shade() {
let config = BackgroundConfig {
image: Some(PathBuf::from("background.png")),
terminal_opacity: 0.42,
..BackgroundConfig::default()
};
assert_eq!(effective_terminal_opacity(&config, true), 0.42);
}
#[test]
fn tint_only_changes_do_not_rebuild_the_background() {
let current = TerminalConfig::default();
let mut next = current.clone();
next.background.random_overlay = false;
next.background.overlay_color = Some(TerminalTheme::parse_color("#10b981").unwrap());
next.background.overlay_opacity = 0.42;
assert_eq!(
classify_config_changes(¤t, &next),
ConfigChanges {
font: false,
scrollback: false,
terminal_appearance: false,
background: false,
background_style: false,
image_opacity: false,
tint: true,
}
);
}
#[test]
fn tint_changes_refresh_a_matched_cursor_without_rebuilding_the_background() {
let current = TerminalConfig {
cursor_match_overlay: true,
..TerminalConfig::default()
};
let mut next = current.clone();
next.background.overlay_opacity = 0.42;
let changes = classify_config_changes(¤t, &next);
assert!(changes.tint);
assert!(changes.terminal_appearance);
assert!(!changes.background);
}
#[test]
fn only_image_source_changes_rebuild_the_background() {
let current = TerminalConfig::default();
let mut image = current.clone();
image.background.image = Some(PathBuf::from("background.png"));
assert!(classify_config_changes(¤t, &image).background);
let mut image_opacity = current.clone();
image_opacity.background.image_opacity = 0.5;
let changes = classify_config_changes(¤t, &image_opacity);
assert!(changes.image_opacity);
assert!(!changes.background);
let mut terminal_opacity = current.clone();
terminal_opacity.background.terminal_opacity = 0.5;
let changes = classify_config_changes(¤t, &terminal_opacity);
assert!(changes.background_style);
assert!(!changes.background);
}
#[test]
fn unified_accent_enables_visible_random_overlay() {
let mut config = TerminalConfig {
background: BackgroundConfig {
overlay_opacity: 0.0,
random_overlay: false,
..BackgroundConfig::default()
},
..TerminalConfig::default()
};
config.set_unified_accent(true);
assert!(config.unified_accent);
assert!(config.cursor_match_overlay);
assert!(config.background.random_overlay);
assert_eq!(config.background.overlay_opacity, DEFAULT_OVERLAY_OPACITY);
}
#[test]
fn disabling_unified_accent_preserves_overlay() {
let mut config = TerminalConfig {
cursor_match_overlay: true,
background: BackgroundConfig {
overlay_opacity: 0.42,
random_overlay: false,
..BackgroundConfig::default()
},
..TerminalConfig::default()
};
config.set_unified_accent(false);
assert!(!config.unified_accent);
assert!(!config.cursor_match_overlay);
assert!(!config.background.random_overlay);
assert_eq!(config.background.overlay_opacity, 0.42);
}
#[test]
fn reconcile_unified_accent_preserves_explicit_overlay_disable() {
let mut config = TerminalConfig {
cursor_match_overlay: true,
unified_accent: true,
background: BackgroundConfig {
overlay_opacity: 0.0,
random_overlay: false,
..BackgroundConfig::default()
},
..TerminalConfig::default()
};
config.reconcile_unified_accent();
assert!(!config.unified_accent);
assert!(config.cursor_match_overlay);
assert!(!config.background.random_overlay);
assert_eq!(config.background.overlay_opacity, 0.0);
}
#[test]
fn terminal_theme_matches_cursor_to_effective_overlay() {
let config = TerminalConfig {
cursor_match_overlay: true,
..TerminalConfig::default()
};
let overlay = TerminalTheme::parse_color("#7c3aed").unwrap();
let theme = terminal_theme(&config, Some(&overlay));
assert_eq!(
color_to_hex(&theme.cursor_background_color().unwrap()),
"#7c3aed"
);
assert_eq!(
color_to_hex(&theme.cursor_foreground_color().unwrap()),
"#020303"
);
}
#[test]
fn terminal_theme_keeps_cursor_when_no_effective_overlay() {
let mut config = TerminalConfig {
cursor_match_overlay: true,
..TerminalConfig::default()
};
config
.theme
.apply_overrides(None, None, Some("#112233"), Some("#445566"), None)
.unwrap();
let theme = terminal_theme(&config, None);
assert_eq!(
color_to_hex(&theme.cursor_background_color().unwrap()),
"#112233"
);
assert_eq!(
color_to_hex(&theme.cursor_foreground_color().unwrap()),
"#445566"
);
}
fn env_values<'a>(envv: &'a [String], key: &str) -> Vec<&'a str> {
let prefix = format!("{key}=");
envv.iter()
.filter_map(|entry| entry.strip_prefix(&prefix))
.collect()
}
}