use gdk::prelude::GdkCairoContextExt;
use gdk_pixbuf::Pixbuf;
use gtk::prelude::*;
use gtk::{gdk, gio, glib, pango};
use std::cell::{OnceCell, RefCell};
use std::env;
use std::ffi::OsStr;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use vte::prelude::*;
use crate::theme::TerminalTheme;
const SPAWN_TIMEOUT_MS: i32 = 30_000;
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: AtomicU64 = AtomicU64::new(0);
thread_local! {
static URL_MATCH_REGEX: OnceCell<Result<vte::Regex, String>> = const { OnceCell::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;
#[derive(Debug, Clone, Copy, PartialEq)]
struct CoverGeometry {
scale: f64,
x: f64,
y: f64,
}
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, Copy, PartialEq, Eq)]
struct SourceCommandClear {
start_column: usize,
rows: usize,
}
#[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: "xfce".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("xfce").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 session_script(&self) -> String {
self.activation_script().unwrap_or_else(|| {
"if [ \"${LIOS_ORIGINAL_PS1+x}\" = x ]; then export PS1=\"$LIOS_ORIGINAL_PS1\"; unset LIOS_ORIGINAL_PS1; fi; if [ \"${LIOS_ORIGINAL_PS2+x}\" = x ]; then export PS2=\"$LIOS_ORIGINAL_PS2\"; unset LIOS_ORIGINAL_PS2; fi; if [ \"${LIOS_ORIGINAL_PS3+x}\" = x ]; then export PS3=\"$LIOS_ORIGINAL_PS3\"; unset LIOS_ORIGINAL_PS3; fi; unset LIOS_PS1_PROFILE".to_string()
})
}
}
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"
));
}
}
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>,
tint: RefCell<Option<gtk::DrawingArea>>,
effective_overlay: RefCell<Option<gdk::RGBA>>,
}
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"));
root.set_child(Some(&background_widget(&config.theme, &config.background)));
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"));
keep_terminal_pointer_visible(&terminal);
install_link_highlighting(&terminal);
let overlay = overlay_color(&config.background);
terminal_theme(config, overlay.as_ref()).apply_to(&terminal);
let tint =
overlay.map(|color| color_overlay_widget(color, config.background.overlay_opacity));
if let Some(tint_widget) = &tint {
root.add_overlay(tint_widget);
root.set_clip_overlay(tint_widget, 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()),
tint: RefCell::new(tint),
effective_overlay: RefCell::new(overlay),
}
}
pub fn widget(&self) -> >k::Overlay {
&self.root
}
pub fn terminal(&self) -> &vte::Terminal {
&self.terminal
}
pub fn focus(&self) {
self.terminal.grab_focus();
}
pub fn feed_text(&self, text: &str) {
self.terminal.feed(text.as_bytes());
}
pub fn apply_prompt_profile(&self, prompt: &PromptConfig) -> Result<(), String> {
if *self.prompt.borrow() == *prompt {
return Ok(());
}
feed_child_script(&self.terminal, &prompt.session_script())?;
*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 font_changed = current.font != config.font;
let scrollback_changed = current.scrollback_lines != config.scrollback_lines;
let appearance_changed = current.theme_name != config.theme_name
|| current.cursor_background != config.cursor_background
|| current.cursor_foreground != config.cursor_foreground
|| current.cursor_match_overlay != config.cursor_match_overlay
|| current.theme != config.theme
|| current.background != config.background;
let background_changed = current.theme.background_color()
!= config.theme.background_color()
|| current.background != config.background;
drop(current);
let overlay = if background_changed {
overlay_color(&config.background)
} else {
*self.effective_overlay.borrow()
};
if font_changed {
self.terminal
.set_font(Some(&pango::FontDescription::from_string(&config.font)));
}
if scrollback_changed {
self.terminal
.set_scrollback_lines(config.scrollback_lines as _);
}
if appearance_changed {
self.terminal.set_cursor_from_name(Some("default"));
terminal_theme(config, overlay.as_ref()).apply_to(&self.terminal);
}
if background_changed {
*self.effective_overlay.borrow_mut() = overlay;
self.root
.set_child(Some(&background_widget(&config.theme, &config.background)));
self.root.remove_overlay(&self.terminal);
if let Some(old_tint) = self.tint.borrow_mut().take() {
self.root.remove_overlay(&old_tint);
}
if let Some(color) = overlay {
let tint = color_overlay_widget(color, config.background.overlay_opacity);
self.root.add_overlay(&tint);
self.root.set_clip_overlay(&tint, true);
*self.tint.borrow_mut() = Some(tint);
}
self.root.add_overlay(&self.terminal);
self.root.set_clip_overlay(&self.terminal, true);
self.root.set_measure_overlay(&self.terminal, true);
}
*self.applied_config.borrow_mut() = config.clone();
Ok(())
}
pub fn spawn(&self, launch: LaunchConfig) {
let prompt = self.prompt.borrow().clone();
let argv = match launch.command.to_argv(&prompt) {
Ok(argv) => argv,
Err(message) => {
self.report_spawn_error(&message);
return;
}
};
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(), &prompt);
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();
self.terminal.spawn_async(
vte::PtyFlags::DEFAULT,
working_directory.as_deref(),
&argv_refs,
&env_refs,
spawn_flags,
|| {},
SPAWN_TIMEOUT_MS,
gio::Cancellable::NONE,
move |result| {
if let Err(error) = result {
terminal_for_error
.feed(format!("\r\nFailed to execute child: {error}\r\n").as_bytes());
}
},
);
}
fn report_spawn_error(&self, message: &str) {
self.terminal
.feed(format!("Failed to start terminal shell: {message}\r\n").as_bytes());
}
}
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 {
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);
prompt_runtime_directory().join(format!(
"lp{}-{kind}-{counter:x}-{nanos:x}.sh",
std::process::id()
))
}
fn prompt_runtime_directory() -> PathBuf {
env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.filter(|path| path.is_dir())
.unwrap_or_else(env::temp_dir)
}
fn feed_child_script(terminal: &vte::Terminal, script: &str) -> Result<(), String> {
let command = hidden_child_command(terminal, script)
.map_err(|error| format!("failed to prepare Bash prompt profile: {error}"))?;
terminal.feed_child(command.as_bytes());
Ok(())
}
fn hidden_child_command(terminal: &vte::Terminal, script: &str) -> Result<String, std::io::Error> {
let path = prompt_script_path("prompt");
let command = source_child_command(&path);
let clear = source_command_clear(terminal, &command);
write_prompt_script(&path, script, clear)?;
Ok(command)
}
fn source_child_command(path: &Path) -> String {
format!(" . {}\r", shell_quote(&path.to_string_lossy()))
}
fn source_command_clear(terminal: &vte::Terminal, command: &str) -> SourceCommandClear {
let columns = terminal.column_count().max(1) as usize;
let (cursor_column, _) = terminal.cursor_position();
source_command_clear_for(command, columns, cursor_column.max(0) as usize)
}
fn source_command_clear_for(
command: &str,
columns: usize,
start_column: usize,
) -> SourceCommandClear {
let columns = columns.max(1);
let start_column = start_column.min(columns - 1);
let command_width = command.trim_end_matches('\r').chars().count();
SourceCommandClear {
start_column,
rows: ((start_column + command_width).saturating_sub(1) / columns) + 1,
}
}
fn source_command_cleanup(clear: SourceCommandClear) -> String {
let mut sequence = format!(
"\\033[s\\033[{}A\\033[{}G\\033[K",
clear.rows,
clear.start_column + 1
);
for _ in 1..clear.rows {
sequence.push_str("\\033[B\\r\\033[2K");
}
sequence.push_str("\\033[u");
sequence
}
fn write_prompt_script(
path: &Path,
script: &str,
clear: SourceCommandClear,
) -> Result<(), std::io::Error> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
writeln!(file, "{script}")?;
writeln!(file, "rm -f -- {}", shell_quote(&path.to_string_lossy()))?;
writeln!(file, "printf '{}'", source_command_cleanup(clear))?;
Ok(())
}
#[cfg(test)]
fn write_test_prompt_script(
script: &str,
clear: SourceCommandClear,
) -> Result<String, std::io::Error> {
let path = prompt_script_path("test");
write_prompt_script(&path, script, clear)?;
let contents = fs::read_to_string(&path)?;
let _ = fs::remove_file(path);
Ok(contents)
}
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> {
let path = prompt_script_path("startup");
write_bash_prompt_rcfile_at(&path, prompt)?;
Ok(path)
}
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>, prompt: &PromptConfig) -> Vec<String> {
let mut envv = Vec::new();
let mut has_pwd = false;
for (key, value) in env::vars() {
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")
));
append_prompt_environment(&mut envv, prompt);
envv
}
fn append_prompt_environment(envv: &mut Vec<String>, prompt: &PromptConfig) {
if let Some(ps1) = prompt.ps1.as_deref() {
envv.push(format!("LIOS_PS1_PROFILE={}", prompt.profile_name));
envv.push(format!("PS1={ps1}"));
}
if let Some(ps2) = prompt.ps2.as_deref() {
envv.push(format!("PS2={ps2}"));
}
if let Some(ps3) = prompt.ps3.as_deref() {
envv.push(format!("PS3={ps3}"));
}
}
fn should_strip_child_env(key: &str) -> bool {
matches!(
key,
"COLUMNS"
| "LINES"
| "WINDOWID"
| "COLORTERM"
| "TERM"
| "TERM_PROGRAM"
| "TERM_PROGRAM_VERSION"
| "VTE_VERSION"
| "LIOS_IMAGE_PROTOCOL"
| "LIOS_ORIGINAL_PS1"
| "LIOS_ORIGINAL_PS2"
| "LIOS_ORIGINAL_PS3"
| "LIOS_PS1_PROFILE"
| "PS1"
| "PS2"
| "PS3"
)
}
fn background_widget(theme: &TerminalTheme, config: &BackgroundConfig) -> gtk::Widget {
if let Some(path) = config.image.as_ref().filter(|path| path.is_file()) {
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");
background.set_child(Some(&background_base_widget(theme.background_color())));
if let Ok(pixbuf) = Pixbuf::from_file(path) {
let image = cover_image_widget(pixbuf, config.image_opacity);
background.add_overlay(&image);
background.set_clip_overlay(&image, true);
} else {
eprintln!("failed to load background image: {}", path.display());
}
let shade =
terminal_shade_widget(theme.background_color(), effective_terminal_opacity(config));
background.add_overlay(&shade);
background.set_clip_overlay(&shade, true);
background.upcast()
} else {
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");
if config.terminal_opacity >= 0.999 {
background.set_child(Some(&background_base_widget(theme.background_color())));
} else {
background.set_child(Some(&transparent_background_widget()));
}
let shade = terminal_shade_widget(theme.background_color(), config.terminal_opacity);
background.add_overlay(&shade);
background.set_clip_overlay(&shade, true);
background.upcast()
}
}
fn cover_image_widget(pixbuf: Pixbuf, opacity: f64) -> gtk::DrawingArea {
let image = gtk::DrawingArea::new();
image.set_hexpand(true);
image.set_vexpand(true);
image.set_can_target(false);
let scaled = RefCell::new(None::<(i32, i32, i32, f64, f64, gtk::cairo::ImageSurface)>);
image.set_draw_func(move |widget, cr, width, height| {
let scale_factor = widget.scale_factor().max(1);
let needs_scale = scaled.borrow().as_ref().is_none_or(
|(cached_width, cached_height, cached_scale, _, _, _)| {
*cached_width != width || *cached_height != height || *cached_scale != scale_factor
},
);
if needs_scale {
let Some(geometry) = cover_geometry(pixbuf.width(), pixbuf.height(), width, height)
else {
return;
};
let scaled_width =
(f64::from(pixbuf.width()) * geometry.scale * f64::from(scale_factor)).ceil()
as i32;
let scaled_height =
(f64::from(pixbuf.height()) * geometry.scale * f64::from(scale_factor)).ceil()
as i32;
let Some(scaled_pixbuf) = pixbuf.scale_simple(
scaled_width.max(1),
scaled_height.max(1),
gdk_pixbuf::InterpType::Bilinear,
) else {
return;
};
let Ok(surface) = gtk::cairo::ImageSurface::create(
gtk::cairo::Format::ARgb32,
scaled_pixbuf.width(),
scaled_pixbuf.height(),
) else {
return;
};
let Ok(surface_context) = gtk::cairo::Context::new(&surface) else {
return;
};
surface_context.set_source_pixbuf(&scaled_pixbuf, 0.0, 0.0);
if surface_context.paint().is_err() {
return;
}
surface.flush();
let x = (f64::from(width) * f64::from(scale_factor) - f64::from(surface.width())) / 2.0;
let y =
(f64::from(height) * f64::from(scale_factor) - f64::from(surface.height())) / 2.0;
*scaled.borrow_mut() = Some((width, height, scale_factor, x, y, surface));
}
let scaled = scaled.borrow();
let Some((_, _, scale_factor, x, y, surface)) = scaled.as_ref() else {
return;
};
if cr.save().is_err() {
return;
}
cr.rectangle(0.0, 0.0, f64::from(width), f64::from(height));
cr.clip();
cr.scale(
1.0 / f64::from(*scale_factor),
1.0 / f64::from(*scale_factor),
);
if cr.set_source_surface(surface, *x, *y).is_err() {
let _ = cr.restore();
return;
}
let _ = cr.paint_with_alpha(opacity.clamp(0.0, 1.0));
let _ = cr.restore();
});
image
}
fn cover_geometry(
source_width: i32,
source_height: i32,
target_width: i32,
target_height: i32,
) -> Option<CoverGeometry> {
if source_width <= 0 || source_height <= 0 || target_width <= 0 || target_height <= 0 {
return None;
}
let source_width = f64::from(source_width);
let source_height = f64::from(source_height);
let target_width = f64::from(target_width);
let target_height = f64::from(target_height);
let scale = (target_width / source_width).max(target_height / source_height);
Some(CoverGeometry {
scale,
x: (target_width - source_width * scale) / 2.0,
y: (target_height - source_height * scale) / 2.0,
})
}
fn background_base_widget(color: gdk::RGBA) -> gtk::DrawingArea {
let base = gtk::DrawingArea::new();
base.set_hexpand(true);
base.set_vexpand(true);
base.set_can_target(false);
base.set_draw_func(move |_, cr, width, height| {
cr.rectangle(0.0, 0.0, f64::from(width), f64::from(height));
cr.set_source_rgb(
f64::from(color.red()),
f64::from(color.green()),
f64::from(color.blue()),
);
let _ = cr.fill();
});
base
}
fn transparent_background_widget() -> gtk::DrawingArea {
let base = gtk::DrawingArea::new();
base.set_hexpand(true);
base.set_vexpand(true);
base.set_can_target(false);
base.set_draw_func(|_, cr, width, height| {
cr.save().ok();
cr.set_operator(gtk::cairo::Operator::Clear);
cr.rectangle(0.0, 0.0, f64::from(width), f64::from(height));
let _ = cr.fill();
cr.restore().ok();
});
base
}
fn terminal_shade_widget(color: gdk::RGBA, opacity: f64) -> gtk::DrawingArea {
let shade = gtk::DrawingArea::new();
shade.set_hexpand(true);
shade.set_vexpand(true);
shade.set_can_target(false);
shade.set_draw_func(move |_, cr, width, height| {
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()),
opacity,
);
let _ = cr.fill();
});
shade
}
pub fn effective_terminal_opacity(config: &BackgroundConfig) -> f64 {
if config.image.is_some() && config.terminal_opacity >= 0.999 {
DEFAULT_IMAGE_TERMINAL_OPACITY
} else {
config.terminal_opacity
}
}
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_link_highlighting(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}"),
}
});
}
#[cfg(test)]
fn launchable_exact_uri(value: &str) -> Option<String> {
if is_supported_launch_uri(value) {
Some(value.to_string())
} else {
None
}
}
#[cfg(test)]
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
}
}
#[cfg(test)]
fn is_supported_launch_uri(uri: &str) -> bool {
let uri = uri.to_ascii_lowercase();
uri.starts_with("http://") || uri.starts_with("https://") || uri.starts_with("file:")
}
#[cfg(test)]
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()];
}
}
#[cfg(test)]
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 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: gdk::RGBA, opacity: f64) -> gtk::DrawingArea {
let area = gtk::DrawingArea::new();
area.set_hexpand(true);
area.set_vexpand(true);
area.set_can_target(false);
area.set_draw_func(move |_, cr, width, height| {
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()),
opacity,
);
let _ = cr.fill();
});
area
}
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;
#[test]
fn child_environment_exports_terminal_identity() {
let envv = child_environment(Some("/tmp"), &PromptConfig::default());
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 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_exports_prompt_profile_as_fallback() {
let prompt = PromptConfig::named("timebar").unwrap();
let envv = child_environment(Some("/tmp"), &prompt);
assert_eq!(env_values(&envv, "LIOS_PS1_PROFILE"), vec!["timebar"]);
assert_eq!(env_values(&envv, "PS1"), vec![TIMEBAR_PS1]);
assert_eq!(env_values(&envv, "PS2"), vec![TIMEBAR_PS2]);
assert_eq!(env_values(&envv, "PS3"), vec![TIMEBAR_PS3]);
}
#[test]
fn bash_prompt_rcfile_sources_user_bashrc_then_applies_prompt() {
let path = prompt_script_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 source_child_command_keeps_typed_input_short() {
let command = source_child_command(Path::new("/tmp/lios-prompt-test.sh"));
assert_eq!(command, " . '/tmp/lios-prompt-test.sh'\r");
assert!(!command.contains("export PS1"));
assert!(!command.contains("stty"));
assert!(command.ends_with('\r'));
}
#[test]
fn prompt_script_contains_exports_and_screen_cleanup() {
let clear = SourceCommandClear {
start_column: 12,
rows: 2,
};
let contents = write_test_prompt_script("export PS1='test'", clear).unwrap();
assert!(contents.contains("export PS1='test'"));
assert!(contents.contains("rm -f -- "));
assert!(contents.contains("printf '\\033[s\\033[2A\\033[13G\\033[K"));
assert!(contents.contains("\\033[B\\r\\033[2K\\033[u'"));
assert!(!contents.contains("\\033[J"));
}
#[test]
fn source_command_clear_accounts_for_cursor_column() {
assert_eq!(
source_command_clear_for(" . '/tmp/x'\r", 80, 0),
SourceCommandClear {
start_column: 0,
rows: 1
}
);
assert_eq!(
source_command_clear_for(" . '/tmp/x'\r", 10, 5),
SourceCommandClear {
start_column: 5,
rows: 2
}
);
}
#[test]
fn interactive_bash_pty_redraws_selected_prompt_immediately() {
let bash = Path::new("/bin/bash");
let script = Path::new("/usr/bin/script");
if !bash.is_file() || !script.is_file() {
return;
}
let prompt_path = prompt_script_path("prompt-test");
write_prompt_script(
&prompt_path,
&PromptConfig::named("akira").unwrap().session_script(),
SourceCommandClear {
start_column: 0,
rows: 1,
},
)
.unwrap();
let mut child = Command::new(script)
.arg("-qfec")
.arg("/bin/bash --noprofile --norc -i")
.arg("/dev/null")
.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(format!("{}exit\n", source_child_command(&prompt_path)).as_bytes())
.unwrap();
drop(stdin);
let output = child.wait_with_output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
let prompt_position = stdout.find("AKIRA").expect("Akira prompt was not redrawn");
let exit_position = stdout.rfind("exit").expect("exit input was not echoed");
assert!(prompt_position < exit_position);
let _ = fs::remove_file(prompt_path);
}
#[test]
fn bash_startup_applies_selected_prompt() {
let bash = Path::new("/bin/bash");
if !bash.is_file() {
return;
}
let startup_path = prompt_script_path("startup-test");
let home_path = prompt_script_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_switches_profile_and_exports_values() {
let bash = Path::new("/bin/bash");
let script = Path::new("/usr/bin/script");
if !bash.is_file() || !script.is_file() {
return;
}
let startup_path = prompt_script_path("startup-test");
let prompt_path = prompt_script_path("prompt-test");
let home_path = prompt_script_path("home-test");
fs::create_dir(&home_path).unwrap();
fs::write(home_path.join(".bashrc"), "").unwrap();
write_bash_prompt_rcfile_at(&startup_path, &PromptConfig::default()).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();
let prompt = PromptConfig::named("akira").unwrap();
write_prompt_script(
&prompt_path,
&prompt.session_script(),
SourceCommandClear {
start_column: 0,
rows: 1,
},
)
.unwrap();
thread::sleep(Duration::from_millis(100));
let mut stdin = child.stdin.take().unwrap();
stdin
.write_all(
format!(
"{}printf '__LIOS_PROFILE__%s__PROMPT__%s__END__\\n' \"$LIOS_PS1_PROFILE\" \"$PS1\"; exit\n",
source_child_command(&prompt_path)
)
.as_bytes(),
)
.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"));
let _ = fs::remove_file(startup_path);
let _ = fs::remove_file(prompt_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 = prompt_script_path("startup-test");
let home_path = prompt_script_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",
"LIOS_IMAGE_PROTOCOL",
"LIOS_PS1_PROFILE",
"PS1",
"PS2",
"PS3",
] {
assert!(should_strip_child_env(key), "{key} should be stripped");
}
assert!(!should_strip_child_env("PATH"));
assert!(!should_strip_child_env("SHELL"));
}
#[test]
fn launchable_uri_accepts_local_http_url() {
assert_eq!(
launchable_matched_uri("http://localhost:3000").as_deref(),
Some("http://localhost:3000")
);
}
#[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 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),
DEFAULT_IMAGE_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), 0.42);
}
#[test]
fn cover_geometry_center_crops_wide_images() {
let geometry = cover_geometry(1920, 1080, 600, 600).unwrap();
assert_eq!(geometry.scale, 600.0 / 1080.0);
assert!(geometry.x < 0.0);
assert_eq!(geometry.y, 0.0);
assert_eq!(1920.0 * geometry.scale + 2.0 * geometry.x, 600.0);
}
#[test]
fn cover_geometry_center_crops_tall_images() {
let geometry = cover_geometry(800, 1200, 800, 400).unwrap();
assert_eq!(geometry.scale, 1.0);
assert_eq!(geometry.x, 0.0);
assert_eq!(geometry.y, -400.0);
}
#[test]
fn cover_geometry_rejects_empty_dimensions() {
assert!(cover_geometry(0, 100, 100, 100).is_none());
assert!(cover_geometry(100, 100, 0, 100).is_none());
}
#[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()
}
}