use gtk::prelude::*;
use gtk::{gdk, gio, glib, pango};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::{Mutex, OnceLock, mpsc};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use vte::prelude::*;
use crate::cli::{Cli, CliAction};
use crate::compat::compatibility_report;
use crate::config::{
AppSettings, RendererPreference, ThemeProfile, config_path_for_write, expand_user_path,
run_config_command,
};
use crate::desktop::{icon_name, icon_search_path, install_desktop_entry, uninstall_desktop_entry};
use crate::drag_drop::install_file_drop;
use crate::paste::PasteController;
use crate::search::{
OverlayDismissal, SearchOverlay, build_search_revealer, close_search, open_search,
overlay_dismissal,
};
use crate::terminal::{
CHILD_COLORTERM, CHILD_TERM, CHILD_TERM_PROGRAM, DEFAULT_IMAGE_OPACITY,
DEFAULT_IMAGE_TERMINAL_OPACITY, DEFAULT_OVERLAY_OPACITY, DEFAULT_TERMINAL_OPACITY,
LaunchCommand, LaunchConfig, MAX_SCROLLBACK_LINES, PROMPT_PROFILE_NAMES, PromptConfig,
SHELL_DEFAULT_PROMPT_PROFILE, TerminalPane, effective_terminal_opacity,
};
use crate::theme::{BRONCO_ACCENT, TerminalTheme, XFCE_CURSOR_BACKGROUND, XFCE_CURSOR_FOREGROUND};
const APP_ID: &str = "dev.lios.Terminal";
const LIVE_EDIT_DEBOUNCE: Duration = Duration::from_millis(800);
const CONTEXT_PERSIST_DEBOUNCE: Duration = Duration::from_millis(300);
const TITLE_UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
const MAX_TITLE_INPUT_SCALARS: usize = 512;
const MAX_CONFIG_TITLE_CHARS: usize = 64;
const MAX_TERMINAL_TITLE_CHARS: usize = 128;
static CONFIG_WRITER: OnceLock<ConfigWriter> = OnceLock::new();
const ACCENT_PRESETS: &[(&str, &str, &str)] = &[
("Violet", "#7c3aed", "accent_violet"),
("Cyan", BRONCO_ACCENT, "accent_cyan"),
("Emerald", "#10b981", "accent_emerald"),
("Amber", "#f59e0b", "accent_amber"),
("Rose", "#f43f5e", "accent_rose"),
];
struct CursorPreset {
label: &'static str,
background: &'static str,
foreground: &'static str,
action_name: &'static str,
}
const CURSOR_PRESET_FOREGROUND: &str = "#020303";
const DEFAULT_MATCH_ACCENT: &str = "#10b981";
const CURSOR_PRESETS: &[CursorPreset] = &[
CursorPreset {
label: "XFCE Red",
background: XFCE_CURSOR_BACKGROUND,
foreground: XFCE_CURSOR_FOREGROUND,
action_name: "cursor_xfce_red",
},
CursorPreset {
label: "Violet",
background: "#7c3aed",
foreground: CURSOR_PRESET_FOREGROUND,
action_name: "cursor_violet",
},
CursorPreset {
label: "Cyan",
background: "#0ea5e9",
foreground: CURSOR_PRESET_FOREGROUND,
action_name: "cursor_cyan",
},
CursorPreset {
label: "Emerald",
background: DEFAULT_MATCH_ACCENT,
foreground: CURSOR_PRESET_FOREGROUND,
action_name: "cursor_emerald",
},
CursorPreset {
label: "Amber",
background: "#f59e0b",
foreground: CURSOR_PRESET_FOREGROUND,
action_name: "cursor_amber",
},
CursorPreset {
label: "Rose",
background: "#f43f5e",
foreground: CURSOR_PRESET_FOREGROUND,
action_name: "cursor_rose",
},
];
const CURSOR_PICKER_BACKGROUND_FALLBACK: &str = "#ffffff";
const CURSOR_PICKER_FOREGROUND_FALLBACK: &str = "#000000";
enum ConfigWriteMessage {
Debounced {
path: PathBuf,
baseline: AppSettings,
settings: AppSettings,
},
Immediate {
path: PathBuf,
baseline: AppSettings,
settings: AppSettings,
reply: mpsc::SyncSender<Result<AppSettings, String>>,
},
Shutdown {
reply: mpsc::SyncSender<Result<(), String>>,
},
}
struct PendingConfig {
baseline: AppSettings,
settings: AppSettings,
}
#[derive(Default)]
struct ConfigCache {
revision: u64,
values: HashMap<PathBuf, (u64, AppSettings)>,
}
impl ConfigCache {
fn begin(&mut self, path: &Path, baseline: &AppSettings) -> u64 {
self.revision += 1;
self.values
.entry(path.to_path_buf())
.or_insert_with(|| (0, baseline.clone()));
self.revision
}
fn complete(&mut self, path: &Path, revision: u64, settings: &AppSettings) {
if let Some((current, cached)) = self.values.get_mut(path) {
// GTK futures can resume out of order. An older completion must not
// overwrite a newer successful save or a queued context-menu edit.
if revision >= *current {
*current = revision;
*cached = settings.clone();
}
}
}
}
struct ConfigSubmission {
revision: u64,
result: mpsc::Receiver<Result<AppSettings, String>>,
}
struct ConfigWriter {
sender: mpsc::Sender<ConfigWriteMessage>,
worker: Mutex<Option<JoinHandle<()>>>,
latest: Mutex<ConfigCache>,
}
impl ConfigWriter {
fn start() -> Self {
let (sender, receiver) = mpsc::channel();
let worker = thread::Builder::new()
.name("lios-config-writer".to_string())
.spawn(move || config_writer_loop(receiver))
.expect("failed to start the Lios config writer");
Self {
sender,
worker: Mutex::new(Some(worker)),
latest: Mutex::new(ConfigCache::default()),
}
}
fn queue(
&self,
path: PathBuf,
baseline: AppSettings,
settings: AppSettings,
) -> Result<(), String> {
let mut latest = self
.latest
.lock()
.map_err(|_| "the Lios config state lock was poisoned".to_string())?;
self.sender
.send(ConfigWriteMessage::Debounced {
path: path.clone(),
baseline,
settings: settings.clone(),
})
.map_err(|_| "the Lios config writer stopped unexpectedly".to_string())?;
let revision = latest.begin(&path, &settings);
latest.complete(&path, revision, &settings);
Ok(())
}
fn submit(
&self,
path: PathBuf,
baseline: AppSettings,
settings: AppSettings,
) -> Result<ConfigSubmission, String> {
let mut latest = self
.latest
.lock()
.map_err(|_| "the Lios config state lock was poisoned".to_string())?;
let (reply, result) = mpsc::sync_channel(1);
self.sender
.send(ConfigWriteMessage::Immediate {
path: path.clone(),
baseline: baseline.clone(),
settings,
reply,
})
.map_err(|_| "the Lios config writer stopped unexpectedly".to_string())?;
let revision = latest.begin(&path, &baseline);
Ok(ConfigSubmission { revision, result })
}
fn persist_now(
&self,
path: PathBuf,
baseline: AppSettings,
settings: AppSettings,
) -> Result<AppSettings, String> {
let result = self.submit(path.clone(), baseline, settings)?;
let persisted = result
.result
.recv()
.map_err(|_| "the Lios config writer stopped before saving".to_string())??;
self.latest
.lock()
.map_err(|_| "the Lios config state lock was poisoned".to_string())?
.complete(&path, result.revision, &persisted);
Ok(persisted)
}
fn latest(&self, path: &Path) -> Option<AppSettings> {
self.latest
.lock()
.ok()?
.values
.get(path)
.map(|(_, settings)| settings.clone())
}
fn shutdown(&self) -> Result<(), String> {
let (reply, result) = mpsc::sync_channel(1);
self.sender
.send(ConfigWriteMessage::Shutdown { reply })
.map_err(|_| "the Lios config writer stopped unexpectedly".to_string())?;
let result = result
.recv()
.map_err(|_| "the Lios config writer stopped before shutdown".to_string())?;
let worker = self
.worker
.lock()
.map_err(|_| "the Lios config writer lock was poisoned".to_string())?
.take();
if let Some(worker) = worker {
worker
.join()
.map_err(|_| "the Lios config writer panicked".to_string())?;
}
result
}
}
async fn persist_config_async(
path: PathBuf,
baseline: AppSettings,
settings: AppSettings,
) -> Result<AppSettings, String> {
let writer = CONFIG_WRITER.get_or_init(ConfigWriter::start);
let submission = writer.submit(path.clone(), baseline, settings)?;
let persisted = gio::spawn_blocking(move || submission.result.recv())
.await
.map_err(|_| "the config save waiter stopped unexpectedly".to_string())?
.map_err(|_| "the config writer stopped before saving".to_string())??;
writer
.latest
.lock()
.map_err(|_| "the Lios config state lock was poisoned".to_string())?
.complete(&path, submission.revision, &persisted);
Ok(persisted)
}
fn config_writer_loop(receiver: mpsc::Receiver<ConfigWriteMessage>) {
let mut pending = HashMap::<PathBuf, PendingConfig>::new();
let mut wait_for_change_after_error = false;
loop {
let message = if pending.is_empty() || wait_for_change_after_error {
receiver
.recv()
.map_err(|_| mpsc::RecvTimeoutError::Disconnected)
} else {
receiver.recv_timeout(CONTEXT_PERSIST_DEBOUNCE)
};
let next_state = process_config_write_message(message, &mut pending);
match next_state {
ConfigWriterState::Continue => wait_for_change_after_error = false,
ConfigWriterState::PendingAfterImmediateError => {}
ConfigWriterState::WaitAfterError(message) => {
eprintln!("{message}");
wait_for_change_after_error = true;
}
ConfigWriterState::Shutdown(result, reply) => {
let _ = reply.send(result);
break;
}
ConfigWriterState::Disconnected => break,
}
}
}
enum ConfigWriterState {
Continue,
PendingAfterImmediateError,
WaitAfterError(String),
Shutdown(Result<(), String>, mpsc::SyncSender<Result<(), String>>),
Disconnected,
}
fn process_config_write_message(
message: Result<ConfigWriteMessage, mpsc::RecvTimeoutError>,
pending: &mut HashMap<PathBuf, PendingConfig>,
) -> ConfigWriterState {
match message {
Ok(ConfigWriteMessage::Debounced {
path,
baseline,
settings,
}) => {
// Retain the baseline from before the first coalesced edit so every
// changed field is included in the eventual disk transaction.
let (baseline, settings) = if let Some(previous) = pending.remove(&path) {
match settings.merge_changes(&baseline, &previous.settings) {
Ok(settings) => (previous.baseline, settings),
Err(message) => {
pending.insert(path, previous);
return ConfigWriterState::WaitAfterError(message);
}
}
} else {
(baseline, settings)
};
pending.insert(path, PendingConfig { baseline, settings });
ConfigWriterState::Continue
}
Ok(ConfigWriteMessage::Immediate {
path,
baseline,
settings,
reply,
}) => {
let previous = pending.remove(&path);
let result = if let Some(previous) = &previous {
settings
.merge_changes(&baseline, &previous.settings)
.and_then(|settings| settings.persist_changes(&previous.baseline, &path))
} else {
settings.persist_changes(&baseline, &path)
};
if result.is_err() {
if let Some(previous) = previous {
pending.insert(path, previous);
}
}
let pending_after_error = result.is_err() && !pending.is_empty();
let _ = reply.send(result);
if pending_after_error {
ConfigWriterState::PendingAfterImmediateError
} else {
ConfigWriterState::Continue
}
}
Ok(ConfigWriteMessage::Shutdown { reply }) => {
let result = persist_pending_configs(pending);
ConfigWriterState::Shutdown(result, reply)
}
Err(mpsc::RecvTimeoutError::Timeout) => match persist_pending_configs(pending) {
Ok(()) => ConfigWriterState::Continue,
Err(message) => ConfigWriterState::WaitAfterError(message),
},
Err(mpsc::RecvTimeoutError::Disconnected) => {
if let Err(message) = persist_pending_configs(pending) {
eprintln!("{message}");
}
ConfigWriterState::Disconnected
}
}
}
fn persist_pending_configs(pending: &mut HashMap<PathBuf, PendingConfig>) -> Result<(), String> {
let mut first_error = None;
for (path, update) in std::mem::take(pending) {
match update.settings.persist_changes(&update.baseline, &path) {
Ok(_) => {}
Err(message) => {
first_error.get_or_insert(message);
pending.insert(path, update);
}
}
}
first_error.map_or(Ok(()), Err)
}
fn queue_config_persist(
path: &Path,
baseline: &AppSettings,
settings: AppSettings,
) -> Result<(), String> {
CONFIG_WRITER.get_or_init(ConfigWriter::start).queue(
path.to_path_buf(),
baseline.clone(),
settings,
)
}
fn persist_config_now(
path: &Path,
baseline: &AppSettings,
settings: &AppSettings,
) -> Result<AppSettings, String> {
CONFIG_WRITER.get_or_init(ConfigWriter::start).persist_now(
path.to_path_buf(),
baseline.clone(),
settings.clone(),
)
}
fn latest_config_settings(path: Option<&Path>, fallback: &AppSettings) -> AppSettings {
let mut latest = path
.and_then(|path| CONFIG_WRITER.get().and_then(|writer| writer.latest(path)))
.unwrap_or_else(|| fallback.clone());
latest.terminal.prompt = fallback.terminal.prompt.clone();
latest
}
fn shutdown_config_writer() -> Result<(), String> {
CONFIG_WRITER.get().map_or(Ok(()), ConfigWriter::shutdown)
}
pub fn run() -> glib::ExitCode {
let cli = match Cli::parse() {
Ok(cli) => cli,
Err(message) => {
eprintln!("{message}");
return glib::ExitCode::FAILURE;
}
};
match cli.action {
CliAction::ShowHelp => {
print!("{}", Cli::help_text());
glib::ExitCode::SUCCESS
}
CliAction::ShowVersion => {
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
glib::ExitCode::SUCCESS
}
CliAction::CheckSystem => {
let report = compatibility_report();
print!("{}", report.text);
if report.compatible {
glib::ExitCode::SUCCESS
} else {
glib::ExitCode::FAILURE
}
}
CliAction::Config(command) => match run_config_command(command) {
Ok(output) => {
print!("{output}");
glib::ExitCode::SUCCESS
}
Err(message) => {
eprintln!("{message}");
glib::ExitCode::FAILURE
}
},
CliAction::InstallDesktop => match install_desktop_entry() {
Ok(output) => {
print!("{output}");
glib::ExitCode::SUCCESS
}
Err(message) => {
eprintln!("{message}");
glib::ExitCode::FAILURE
}
},
CliAction::UninstallDesktop => match uninstall_desktop_entry() {
Ok(output) => {
print!("{output}");
glib::ExitCode::SUCCESS
}
Err(message) => {
eprintln!("{message}");
glib::ExitCode::FAILURE
}
},
CliAction::Run {
launch,
config_path,
overrides,
} => match AppSettings::load(config_path.clone(), *overrides) {
Ok(settings) => {
run_application(launch, settings, config_path_for_write(config_path).ok())
}
Err(message) => {
eprintln!("{message}");
glib::ExitCode::FAILURE
}
},
}
}
fn run_application(
launch: LaunchConfig,
settings: AppSettings,
config_path: Option<PathBuf>,
) -> glib::ExitCode {
apply_renderer(settings.window.renderer);
let application = gtk::Application::builder()
.application_id(APP_ID)
.flags(gio::ApplicationFlags::NON_UNIQUE)
.build();
application.connect_startup(|_| {
install_app_icon();
install_app_css();
});
application.connect_activate(move |app| {
build_window(app, launch.clone(), settings.clone(), config_path.clone())
});
let exit_code = application.run_with_args(&[env!("CARGO_PKG_NAME")]);
if let Err(message) = shutdown_config_writer() {
eprintln!("{message}");
}
exit_code
}
fn apply_renderer(renderer: RendererPreference) {
if let Some(value) = renderer.gsk_renderer() {
// SAFETY: this runs before GTK creates the application/window and before this
// process spawns any threads. GTK reads GSK_RENDERER during initialization.
unsafe { std::env::set_var("GSK_RENDERER", value) };
}
}
fn apply_window_opacity(window: >k::ApplicationWindow, opacity: f64) {
let opacity = opacity.clamp(0.0, 1.0);
if (window.opacity() - opacity).abs() > f64::EPSILON {
window.set_opacity(opacity);
}
}
fn apply_live_app_settings(
window: >k::ApplicationWindow,
pane: &TerminalPane,
settings: &AppSettings,
) -> Result<(), String> {
pane.apply_config(&settings.terminal)?;
apply_window_opacity(window, settings.window.opacity);
if window.is_decorated() != settings.window.decorated {
window.set_decorated(settings.window.decorated);
if settings.window.decorated {
let header = build_headerbar();
window.set_titlebar(Some(&header));
} else {
window.set_titlebar(None::<>k::Widget>);
}
}
Ok(())
}
fn build_window(
app: >k::Application,
launch: LaunchConfig,
settings: AppSettings,
config_path: Option<PathBuf>,
) {
let fallback_title = sanitized_config_title(&settings.window.title);
let window = gtk::ApplicationWindow::builder()
.application(app)
.title(&fallback_title)
.default_width(settings.window.default_width)
.default_height(settings.window.default_height)
.decorated(settings.window.decorated)
.icon_name(icon_name())
.build();
window.add_css_class("lios-window");
let state = Rc::new(RefCell::new(settings));
apply_window_opacity(&window, state.borrow().window.opacity);
let pane = Rc::new(TerminalPane::new(&state.borrow().terminal));
let terminal = pane.terminal().clone();
install_file_drop(&terminal);
let preferences =
build_preferences_revealer(&window, pane.clone(), state.clone(), config_path.clone());
let search = build_search_revealer(&terminal);
let root = gtk::Overlay::new();
root.add_css_class("lios-content");
root.set_child(Some(pane.widget()));
preferences.set_halign(gtk::Align::Center);
preferences.set_valign(gtk::Align::Start);
preferences.set_hexpand(false);
preferences.set_vexpand(false);
preferences.set_margin_top(10);
preferences.set_margin_start(12);
preferences.set_margin_end(12);
root.add_overlay(&preferences);
root.set_measure_overlay(&preferences, false);
root.set_clip_overlay(&preferences, true);
search.revealer.set_halign(gtk::Align::Center);
search.revealer.set_valign(gtk::Align::Start);
search.revealer.set_hexpand(false);
search.revealer.set_vexpand(false);
search.revealer.set_margin_top(10);
search.revealer.set_margin_start(12);
search.revealer.set_margin_end(12);
root.add_overlay(&search.revealer);
root.set_measure_overlay(&search.revealer, false);
root.set_clip_overlay(&search.revealer, true);
if state.borrow().window.decorated {
let header = build_headerbar();
window.set_titlebar(Some(&header));
}
let context_menu = install_window_actions(
&window,
pane.clone(),
state.clone(),
config_path.clone(),
launch.clone(),
&preferences,
&search,
);
install_keyboard_shortcuts(
&window,
&terminal,
&preferences,
&search,
KeyboardShortcutContext {
launch: launch.clone(),
settings: state.clone(),
config_path: config_path.clone(),
context_menu,
},
);
keep_window_title_in_sync(&window, &terminal, fallback_title);
install_close_guard(&window, &pane);
window.set_child(Some(&root));
window.present();
pane.spawn(launch);
pane.focus();
}
fn install_app_icon() {
gtk::Window::set_default_icon_name(icon_name());
let Some(display) = gdk::Display::default() else {
return;
};
if let Ok(path) = icon_search_path() {
gtk::IconTheme::for_display(&display).add_search_path(path);
}
}
fn install_app_css() {
let Some(display) = gdk::Display::default() else {
return;
};
let provider = gtk::CssProvider::new();
provider.load_from_data(
"
.lios-topbar {
background: linear-gradient(90deg, #030405, #0a0d0f 55%, #111417);
color: #e7e5de;
border-bottom: 1px solid rgba(188, 185, 170, 0.16);
min-height: 36px;
}
.lios-window,
.lios-content,
.lios-terminal-root {
background: transparent;
}
.lios-terminal {
background: transparent;
}
.lios-terminal.lios-drop-ready {
box-shadow: inset 0 0 0 2px rgba(16, 185, 129, 0.78), inset 0 0 48px rgba(16, 185, 129, 0.10);
}
.lios-terminal.lios-visual-bell {
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.92), inset 0 0 48px rgba(245, 158, 11, 0.24);
}
.lios-brand {
font-weight: 800;
letter-spacing: 0.08em;
color: #f2f0e8;
}
.lios-brand-row {
border-radius: 999px;
padding: 2px 8px;
}
.lios-brand-logo {
margin-right: 7px;
}
.lios-prefs-button {
border-radius: 999px;
padding: 5px 16px;
background: linear-gradient(135deg, #15191b, #080a0b);
color: #e7e5de;
border: 1px solid rgba(188, 185, 170, 0.24);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.36);
}
.lios-search {
background: linear-gradient(115deg, #040506, #0a0d0f 72%, #07130f);
color: #e7e5de;
border: 1px solid rgba(16, 185, 129, 0.42);
border-radius: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 18px 60px rgba(0, 0, 0, 0.52);
padding: 5px;
}
.lios-search-entry {
min-height: 28px;
border-radius: 8px;
background: #020303;
color: #f2f0e8;
border: 1px solid rgba(188, 185, 170, 0.22);
padding: 2px 8px;
}
.lios-search-entry:focus {
border-color: rgba(16, 185, 129, 0.82);
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.22);
}
.lios-search-status {
color: #d8d3c3;
font-size: 11px;
font-weight: 700;
min-width: 64px;
}
.lios-search-status.lios-search-warning {
color: #f2b8a0;
}
.lios-search-button {
min-height: 28px;
border-radius: 8px;
padding: 3px 7px;
background: #050607;
color: #c9c7bd;
border: 1px solid rgba(188, 185, 170, 0.18);
box-shadow: none;
}
.lios-search-button:hover {
color: #f2f0e8;
border-color: rgba(16, 185, 129, 0.52);
}
.lios-search-button:disabled {
opacity: 0.42;
}
.lios-search-close {
color: #f0c7c7;
}
.lios-drawer {
background: #14191f;
color: #edf1f5;
border: 1px solid #35414b;
border-radius: 16px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.45);
padding: 16px;
}
.lios-drawer-header { padding: 0 0 6px; }
.lios-drawer-title { font-size: 24px; font-weight: 700; color: #f5f8fa; }
.lios-drawer-footer { padding-top: 12px; border-top: 1px solid #303b45; }
.lios-drawer-scroll, .lios-drawer-scroll viewport { background: transparent; border: none; }
.lios-preferences-page { padding: 4px 2px 8px; }
.lios-preferences-tabs { padding: 3px; border-radius: 10px; background: #0c1117; }
.lios-preferences-tabs button {
border: none; border-radius: 7px; padding: 7px 12px;
background: transparent; color: #aab6c2; box-shadow: none; font-weight: 600;
}
.lios-preferences-tabs button:checked { background: #263541; color: #d6f8e9; }
.lios-preferences-tabs button:hover { color: #ffffff; }
.lios-card {
background: #1b232c;
border: 1px solid #303d49;
border-radius: 12px;
padding: 16px;
}
.lios-card > .lios-row-label { font-size: 15px; font-weight: 700; color: #eff4f8; }
.lios-row-label { color: #d6dfe7; font-weight: 600; }
.lios-muted { color: #aab7c4; font-size: 12px; }
.lios-hint { color: #a1aebc; font-size: 11px; }
.lios-muted.lios-error { color: #ffb5a9; }
.lios-unsaved {
color: #ffe0a3; background: #49391e; border-radius: 6px;
padding: 3px 7px; font-size: 10px; font-weight: 700;
}
.lios-quick-bar { padding: 6px 0; }
.lios-quick-copy { color: #aab7c4; font-size: 11px; }
.lios-master-toggle { font-weight: 600; }
.lios-choice, .lios-collapse, .lios-live-edit {
border-radius: 8px; padding: 6px 10px;
background: #242f3a; color: #d8e2eb; border: 1px solid #41505d; box-shadow: none;
}
.lios-choice:hover, .lios-collapse:hover, .lios-live-edit:hover { background: #30404e; color: #ffffff; }
.lios-choice.lios-selected, .lios-live-edit.lios-live-active {
background: #21493c; color: #b3f3d6; border-color: #539b7f; box-shadow: none;
}
.lios-field {
border-radius: 8px; background: #111820; color: #edf3f8;
border: 1px solid #42505e; padding: 6px 10px; min-height: 24px;
}
.lios-field:focus-within { border-color: #6fcfa6; }
dropdown.lios-field { padding: 0; }
dropdown.lios-field > button {
background: transparent; color: #edf3f8; border: none;
box-shadow: none; padding: 8px 10px; border-radius: 8px;
}
dropdown.lios-field > button:hover { background: #263541; }
dropdown.lios-field popover contents, dropdown.lios-field popover listview {
background: #1b232c; color: #edf3f8;
}
dropdown.lios-field popover row:selected { background: #21493c; }
.lios-color-button { min-width: 36px; min-height: 32px; border-radius: 8px; padding: 0; }
.lios-slider { color: #dfe8ef; padding: 8px 0; }
.lios-slider trough { min-height: 6px; border-radius: 9px; background: #0e151d; }
.lios-slider highlight { border-radius: 9px; background: #65c99f; }
.lios-slider slider { min-width: 16px; min-height: 16px; border-radius: 50%; background: #dbfff0; border: 1px solid #65c99f; }
.lios-apply {
border-radius: 8px; padding: 7px 14px;
background: #87deb7; color: #10271c; border: 1px solid #9aedc9;
font-weight: 700; box-shadow: none;
}
.lios-apply:hover { background: #a6efce; }
.lios-drawer button:focus-visible { outline: 2px solid #9aebc7; outline-offset: 2px; }
.lios-drawer button:disabled { opacity: 0.45; }
",
);
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
fn build_headerbar() -> gtk::HeaderBar {
let header = gtk::HeaderBar::new();
header.add_css_class("lios-topbar");
header.set_show_title_buttons(true);
let title_row = gtk::Box::new(gtk::Orientation::Horizontal, 0);
title_row.add_css_class("lios-brand-row");
let logo = gtk::Image::from_icon_name(icon_name());
logo.set_pixel_size(22);
logo.add_css_class("lios-brand-logo");
title_row.append(&logo);
let title = gtk::Label::new(Some("LIOS"));
title.add_css_class("lios-brand");
title_row.append(&title);
header.set_title_widget(Some(&title_row));
header
}
fn build_preferences_revealer(
parent: >k::ApplicationWindow,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
) -> gtk::Revealer {
let revealer = gtk::Revealer::new();
revealer.set_reveal_child(false);
revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown);
revealer.set_transition_duration(180);
let initialized = Rc::new(Cell::new(false));
let parent = parent.downgrade();
revealer.connect_notify_local(Some("reveal-child"), move |revealer, _| {
if !revealer.reveals_child() || initialized.replace(true) {
return;
}
let Some(parent) = parent.upgrade() else {
return;
};
let drawer = build_preferences_drawer(
&parent,
pane.clone(),
settings.clone(),
config_path.clone(),
revealer,
);
revealer.set_child(Some(&drawer));
});
revealer
}
fn build_preferences_drawer(
parent: >k::ApplicationWindow,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
revealer: >k::Revealer,
) -> gtk::Box {
let snapshot = {
let current = settings.borrow();
latest_config_settings(config_path.as_deref(), ¤t)
};
*settings.borrow_mut() = snapshot.clone();
let status = gtk::Label::new(None);
status.add_css_class("lios-muted");
status.set_ellipsize(pango::EllipsizeMode::End);
status.set_wrap(true);
status.set_lines(2);
status.set_max_width_chars(60);
status.set_widget_name("preferences-status");
status.set_xalign(0.0);
status.set_hexpand(true);
let dirty = Rc::new(Cell::new(false));
let unsaved = gtk::Label::new(Some("UNSAVED"));
unsaved.add_css_class("lios-unsaved");
unsaved.set_valign(gtk::Align::Center);
unsaved.set_visible(false);
let updating_controls = Rc::new(Cell::new(false));
let dirty_actions = Rc::new(RefCell::new(Vec::<glib::WeakRef<gtk::Button>>::new()));
let live_edit_enabled = Rc::new(Cell::new(false));
let live_edit_action = Rc::new(RefCell::new(None::<Rc<dyn Fn()>>));
let live_edit_source = Rc::new(RefCell::new(None::<glib::SourceId>));
let update_dirty_actions: Rc<dyn Fn(bool)> = {
let dirty = dirty.clone();
let dirty_actions = dirty_actions.clone();
let unsaved = unsaved.clone();
Rc::new(move |is_dirty| {
dirty.set(is_dirty);
unsaved.set_visible(is_dirty);
for button in dirty_actions
.borrow()
.iter()
.filter_map(|button| button.upgrade())
{
button.set_sensitive(is_dirty);
}
})
};
let mark_dirty: Rc<dyn Fn()> = {
let status = status.clone();
let update_dirty_actions = update_dirty_actions.clone();
let updating_controls = updating_controls.clone();
let live_edit_enabled = live_edit_enabled.clone();
let live_edit_action = live_edit_action.clone();
let live_edit_source = live_edit_source.clone();
Rc::new(move || {
if updating_controls.get() {
return;
}
status.remove_css_class("lios-error");
update_dirty_actions(true);
if live_edit_enabled.get() {
if let Some(action) = live_edit_action.borrow().as_ref().cloned() {
if let Some(source) = live_edit_source.borrow_mut().take() {
source.remove();
}
status.set_text("Live Edit pending...");
let source_state = live_edit_source.clone();
let source = glib::timeout_add_local_once(LIVE_EDIT_DEBOUNCE, move || {
source_state.borrow_mut().take();
action();
});
*live_edit_source.borrow_mut() = Some(source);
} else {
status.set_text("Live Edit pending. Apply to save.");
}
} else {
status.set_text("Unsaved changes. Apply to save.");
}
})
};
let drawer = gtk::Box::new(gtk::Orientation::Vertical, 10);
drawer.set_widget_name("preferences-drawer");
drawer.add_css_class("lios-drawer");
drawer.set_vexpand(false);
let header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
header.add_css_class("lios-drawer-header");
let title_block = gtk::Box::new(gtk::Orientation::Vertical, 2);
let title = gtk::Label::new(Some("Preferences"));
title.add_css_class("lios-drawer-title");
title.set_xalign(0.0);
let title_row = gtk::Box::new(gtk::Orientation::Horizontal, 7);
title_row.append(&title);
title_row.append(&unsaved);
let subtitle = gtk::Label::new(Some("Your space. Your settings."));
subtitle.add_css_class("lios-muted");
subtitle.set_ellipsize(pango::EllipsizeMode::End);
subtitle.set_xalign(0.0);
let config_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let config_destination = preferences_config_destination(config_path.as_deref());
let config_label = gtk::Label::new(Some(&config_destination));
config_label.add_css_class("lios-hint");
config_label.set_xalign(0.0);
config_label.set_ellipsize(pango::EllipsizeMode::Middle);
config_label.set_hexpand(true);
config_label.set_tooltip_text(Some(&config_destination));
let copy_config_path = gtk::Button::with_label("Copy Path");
copy_config_path.add_css_class("lios-choice");
copy_config_path.set_sensitive(config_path.is_some());
copy_config_path.set_tooltip_text(Some("Copy the active config destination"));
let open_config_folder = gtk::Button::with_label("Open Folder");
open_config_folder.add_css_class("lios-choice");
open_config_folder.set_sensitive(config_path.is_some());
open_config_folder.set_tooltip_text(Some("Open the active config directory"));
if let Some(path) = config_path.clone() {
let status = status.clone();
copy_config_path.connect_clicked(move |_| {
let Some(display) = gdk::Display::default() else {
status.set_text("No display clipboard is available.");
return;
};
display.clipboard().set_text(&path.to_string_lossy());
status.set_text("Config path copied.");
});
}
if let Some(directory) = preferences_config_directory(config_path.as_deref()) {
let status = status.clone();
open_config_folder.connect_clicked(move |_| {
let uri = local_path_uri(&directory);
match gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>) {
Ok(()) => status.set_text("Opened config directory."),
Err(error) => status.set_text(&format!("Could not open config directory: {error}")),
}
});
}
config_row.append(&config_label);
config_row.append(&open_config_folder);
config_row.append(©_config_path);
title_block.append(&title_row);
title_block.append(&subtitle);
title_block.set_hexpand(true);
let collapse = gtk::Button::from_icon_name("window-close-symbolic");
collapse.set_tooltip_text(Some("Close preferences (Esc)"));
collapse.set_valign(gtk::Align::Start);
collapse.add_css_class("lios-collapse");
let revealer_for_collapse = revealer.downgrade();
let pane_for_collapse = pane.clone();
collapse.connect_clicked(move |_| {
if let Some(revealer) = revealer_for_collapse.upgrade() {
revealer.set_reveal_child(false);
pane_for_collapse.focus();
}
});
header.append(&title_block);
header.append(&collapse);
drawer.append(&header);
let quick_bar = gtk::Box::new(gtk::Orientation::Horizontal, 6);
quick_bar.add_css_class("lios-quick-bar");
let cursor_match_overlay = gtk::ToggleButton::with_label("Unified Accent");
cursor_match_overlay.add_css_class("lios-choice");
cursor_match_overlay.add_css_class("lios-master-toggle");
cursor_match_overlay.set_active(snapshot.terminal.unified_accent);
set_choice_toggle_visual(&cursor_match_overlay, snapshot.terminal.unified_accent);
cursor_match_overlay.set_tooltip_text(Some(
"Enable an accent overlay and keep this pane's cursor matched to it",
));
let quick_copy = gtk::Label::new(Some("Overlay + cursor, matched with one switch"));
quick_copy.add_css_class("lios-quick-copy");
quick_copy.set_ellipsize(pango::EllipsizeMode::End);
quick_copy.set_xalign(0.0);
quick_copy.set_hexpand(true);
let dark_default = gtk::Button::with_label("Original Look");
dark_default.set_widget_name("preferences-original-look");
dark_default.add_css_class("lios-choice");
dark_default.set_tooltip_text(Some(
"Restore the original bundled wallpaper, shading, and tint; Apply to save or Revert to undo",
));
quick_bar.append(&cursor_match_overlay);
quick_bar.append(&quick_copy);
quick_bar.append(&dark_default);
let pages = gtk::Stack::new();
pages.set_widget_name("preferences-pages");
pages.set_hhomogeneous(false);
pages.set_vhomogeneous(false);
pages.set_transition_type(gtk::StackTransitionType::Crossfade);
pages.set_transition_duration(120);
let tabs = gtk::StackSwitcher::new();
tabs.add_css_class("lios-preferences-tabs");
tabs.set_stack(Some(&pages));
tabs.set_halign(gtk::Align::Fill);
drawer.append(&tabs);
let compact_navigation = gtk::DropDown::from_strings(&[
"Appearance",
"Background",
"Terminal",
"Profiles",
"Plugins",
]);
compact_navigation.add_css_class("lios-field");
compact_navigation.set_tooltip_text(Some("Preferences section"));
compact_navigation.set_visible(false);
drawer.append(&compact_navigation);
let display_card =
preference_card("Rendering", "Changes take effect the next time Lios starts");
let appearance_card = preference_card("Color & type", "Make the terminal feel like yours");
let session_card = preference_card("Terminal", "Shell defaults and window controls");
appearance_card.append(&quick_bar);
let config_card = preference_card("Configuration file", "Where your saved preferences live");
config_card.append(&config_row);
let gpu_acceleration = bool_choice(snapshot.window.renderer.gpu_enabled());
gpu_acceleration.connect_changed(mark_dirty.clone());
display_card.append(&preference_column(
"GPU acceleration",
&gpu_acceleration.widget,
));
let gpu_mode_choice = choice_grid(
&RendererPreference::GPU_MODE_NAMES,
snapshot.window.renderer.gpu_mode_config(),
3,
);
gpu_mode_choice.connect_changed(mark_dirty.clone());
display_card.append(&preference_column("GPU mode", &gpu_mode_choice.widget));
let selectable_theme_names = snapshot.selectable_theme_names();
let selectable_theme_name_refs = selectable_theme_names
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
let theme_choice = dropdown_choice(
&selectable_theme_name_refs,
&snapshot.terminal.theme_name,
"Choose the terminal color theme",
);
appearance_card.append(&preference_column("Color theme", &theme_choice.widget));
let cursor_card = preference_card("Cursor", "Manual colors or quick presets");
let cursor_touched = Rc::new(Cell::new(false));
let legacy_cursor_match = Rc::new(Cell::new(
snapshot.terminal.cursor_match_overlay && !snapshot.terminal.unified_accent,
));
let mark_cursor_dirty: Rc<dyn Fn()> = {
let cursor_touched = cursor_touched.clone();
let legacy_cursor_match = legacy_cursor_match.clone();
let mark_dirty = mark_dirty.clone();
Rc::new(move || {
cursor_touched.set(true);
legacy_cursor_match.set(false);
mark_dirty();
})
};
let cursor_background = CursorColorControl::new(
"Block hex",
&cursor_entry_text(
snapshot.terminal.cursor_background.as_deref(),
snapshot.terminal.theme.cursor_background_color(),
),
CURSOR_PICKER_BACKGROUND_FALLBACK,
mark_cursor_dirty.clone(),
);
let cursor_foreground = CursorColorControl::new(
"Text hex",
&cursor_entry_text(
snapshot.terminal.cursor_foreground.as_deref(),
snapshot.terminal.theme.cursor_foreground_color(),
),
CURSOR_PICKER_FOREGROUND_FALLBACK,
mark_cursor_dirty,
);
let cursor_controls = gtk::Box::new(gtk::Orientation::Vertical, 6);
cursor_controls.append(&cursor_background.widget);
cursor_controls.append(&cursor_foreground.widget);
let cursor_hint = gtk::Label::new(Some("Type hex, pick a swatch, or use a preset."));
cursor_hint.add_css_class("lios-muted");
cursor_hint.set_xalign(0.0);
cursor_hint.set_ellipsize(pango::EllipsizeMode::End);
cursor_controls.append(&cursor_hint);
let cursor_actions = gtk::Grid::new();
cursor_actions.set_column_spacing(6);
cursor_actions.set_row_spacing(6);
cursor_actions.set_hexpand(true);
cursor_background
.widget
.set_sensitive(!snapshot.terminal.unified_accent);
cursor_foreground
.widget
.set_sensitive(!snapshot.terminal.unified_accent);
for (index, preset) in CURSOR_PRESETS.iter().enumerate() {
let button = gtk::Button::with_label(preset.label);
button.add_css_class("lios-choice");
button.set_hexpand(true);
let label = preset.label;
let background = preset.background.to_string();
let foreground = preset.foreground.to_string();
let cursor_background_for_preset = cursor_background.clone();
let cursor_foreground_for_preset = cursor_foreground.clone();
let cursor_match_for_preset = cursor_match_overlay.clone();
let status_for_preset = status.clone();
let mark_dirty_for_preset = mark_dirty.clone();
let live_edit_for_preset = live_edit_enabled.clone();
let updating_controls_for_preset = updating_controls.clone();
let cursor_touched_for_preset = cursor_touched.clone();
let legacy_cursor_match_for_preset = legacy_cursor_match.clone();
button.connect_clicked(move |_| {
let changed = cursor_selection_changed(
&cursor_background_for_preset.text(),
&cursor_foreground_for_preset.text(),
&background,
&foreground,
cursor_match_for_preset.is_active(),
legacy_cursor_match_for_preset.get(),
);
updating_controls_for_preset.set(true);
cursor_match_for_preset.set_active(false);
cursor_background_for_preset.set_text(&background);
cursor_foreground_for_preset.set_text(&foreground);
updating_controls_for_preset.set(false);
if changed {
cursor_touched_for_preset.set(true);
legacy_cursor_match_for_preset.set(false);
mark_dirty_for_preset();
if !live_edit_for_preset.get() {
status_for_preset.set_text(&format!("{label} cursor selected. Apply to save."));
}
} else {
status_for_preset.set_text(&format!("{label} cursor already selected."));
}
});
cursor_actions.attach(&button, (index as i32) % 3, (index as i32) / 3, 1, 1);
}
let inherit_cursor = gtk::Button::with_label("Inherit");
inherit_cursor.add_css_class("lios-choice");
inherit_cursor.set_hexpand(true);
let cursor_background_for_inherit = cursor_background.clone();
let cursor_foreground_for_inherit = cursor_foreground.clone();
let cursor_match_for_inherit = cursor_match_overlay.clone();
let status_for_inherit = status.clone();
let mark_dirty_for_inherit = mark_dirty.clone();
let live_edit_for_inherit = live_edit_enabled.clone();
let updating_controls_for_inherit = updating_controls.clone();
let cursor_touched_for_inherit = cursor_touched.clone();
let legacy_cursor_match_for_inherit = legacy_cursor_match.clone();
inherit_cursor.connect_clicked(move |_| {
updating_controls_for_inherit.set(true);
cursor_match_for_inherit.set_active(false);
cursor_background_for_inherit.set_text("");
cursor_foreground_for_inherit.set_text("");
updating_controls_for_inherit.set(false);
cursor_touched_for_inherit.set(true);
legacy_cursor_match_for_inherit.set(false);
mark_dirty_for_inherit();
if !live_edit_for_inherit.get() {
status_for_inherit.set_text("Theme cursor colors inherited. Apply to save.");
}
});
let clear_index = CURSOR_PRESETS.len() as i32;
cursor_actions.attach(&inherit_cursor, clear_index % 3, clear_index / 3, 1, 1);
cursor_controls.append(&cursor_actions);
cursor_card.append(&preference_column("Colors", &cursor_controls));
theme_choice.connect_changed({
let theme_value = theme_choice.value.clone();
let cursor_background = cursor_background.clone();
let cursor_foreground = cursor_foreground.clone();
let updating_controls = updating_controls.clone();
let mark_dirty = mark_dirty.clone();
let status = status.clone();
let catalog = snapshot.theme_catalog.clone();
let cursor_touched = cursor_touched.clone();
Rc::new(move || {
match catalog.resolve(&theme_value.borrow()) {
Ok((_, theme)) => {
updating_controls.set(true);
set_cursor_controls_from_theme(&cursor_background, &cursor_foreground, &theme);
cursor_touched.set(false);
updating_controls.set(false);
}
Err(message) => status.set_text(&message),
}
mark_dirty();
})
});
let font_entry = gtk::Entry::new();
font_entry.add_css_class("lios-field");
font_entry.set_text(&snapshot.terminal.font);
font_entry.set_placeholder_text(Some("Monospace 12"));
font_entry.connect_changed({
let mark_dirty = mark_dirty.clone();
move |_| mark_dirty()
});
appearance_card.append(&preference_column("Font", &font_entry));
let prompt_profile = prompt_profile_dropdown(&snapshot.terminal.prompt.profile_name);
prompt_profile.connect_changed(mark_dirty.clone());
session_card.append(&preference_column(
"Bash prompt · this terminal",
&prompt_profile.widget,
));
let decorated = bool_choice(snapshot.window.decorated);
decorated.connect_changed(mark_dirty.clone());
session_card.append(&preference_column("Window title bar", &decorated.widget));
let background_card = preference_card("Background", "Image source and file picker");
let image_entry = gtk::Entry::new();
image_entry.add_css_class("lios-field");
image_entry.set_placeholder_text(Some("/path/to/background.jpg"));
if let Some(image) = &snapshot.terminal.background.image {
image_entry.set_text(&image.to_string_lossy());
}
image_entry.connect_changed({
let mark_dirty = mark_dirty.clone();
move |_| mark_dirty()
});
let image_opacity = opacity_slider(snapshot.terminal.background.image_opacity);
image_opacity.connect_value_changed({
let mark_dirty = mark_dirty.clone();
move |_| mark_dirty()
});
let terminal_opacity = opacity_slider(snapshot.terminal.background.terminal_opacity);
terminal_opacity.connect_value_changed({
let mark_dirty = mark_dirty.clone();
move |_| mark_dirty()
});
let image_picker = gtk::Box::new(gtk::Orientation::Vertical, 6);
image_entry.set_hexpand(true);
let choose_image = gtk::Button::with_label("Browse…");
choose_image.add_css_class("lios-choice");
choose_image.set_tooltip_text(Some("Choose an image; Apply saves your selection"));
choose_image.connect_clicked({
let parent = parent.downgrade();
let image_entry = image_entry.clone();
let terminal_opacity = terminal_opacity.clone();
let image_opacity = image_opacity.clone();
move |_| {
let Some(parent) = parent.upgrade() else {
return;
};
let image_entry = image_entry.clone();
let terminal_opacity = terminal_opacity.clone();
let image_opacity = image_opacity.clone();
open_background_image_dialog(&parent, move |path| {
image_entry.set_text(&path.to_string_lossy());
if terminal_opacity.value() >= 0.999 {
terminal_opacity.set_value(DEFAULT_IMAGE_TERMINAL_OPACITY);
}
if image_opacity.value() <= 0.0 {
image_opacity.set_value(DEFAULT_IMAGE_OPACITY);
}
});
}
});
let clear_image = gtk::Button::with_label("Clear");
clear_image.add_css_class("lios-choice");
clear_image.set_tooltip_text(Some("Remove the image; Apply saves your selection"));
clear_image.connect_clicked({
let image_entry = image_entry.clone();
move |_| image_entry.set_text("")
});
let image_actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
choose_image.set_hexpand(true);
clear_image.set_hexpand(true);
image_actions.append(&choose_image);
image_actions.append(&clear_image);
image_picker.append(&image_entry);
image_picker.append(&image_actions);
background_card.append(&preference_column("Image", &image_picker));
let opacity_card = preference_card("Opacity", "Master first, then nested layers");
let master_opacity = opacity_slider(snapshot.window.opacity);
master_opacity.connect_value_changed({
let mark_dirty = mark_dirty.clone();
move |_| mark_dirty()
});
opacity_card.append(&preference_row("Master", &master_opacity));
opacity_card.append(&preference_row("Terminal shade", &terminal_opacity));
opacity_card.append(&preference_row("Image", &image_opacity));
let overlay_opacity = opacity_slider(snapshot.terminal.background.overlay_opacity);
overlay_opacity.connect_value_changed({
let cursor_match_overlay = cursor_match_overlay.downgrade();
let updating_controls = updating_controls.clone();
let mark_dirty = mark_dirty.clone();
move |scale| {
let cursor_match_overlay = cursor_match_overlay.upgrade();
if !updating_controls.get()
&& scale.value() <= 0.0
&& cursor_match_overlay
.as_ref()
.is_some_and(|button| button.is_active())
{
updating_controls.set(true);
cursor_match_overlay.unwrap().set_active(false);
updating_controls.set(false);
}
mark_dirty();
}
});
opacity_card.append(&preference_row("Accent tint", &overlay_opacity));
let overlay_card = preference_card("Accent", "Tint color and random accent");
let random_overlay = bool_choice(snapshot.terminal.background.random_overlay);
let overlay_entry = gtk::Entry::new();
overlay_entry.add_css_class("lios-field");
overlay_entry.set_placeholder_text(Some("#7c3aed"));
if let Some(color) = &snapshot.terminal.background.overlay_color {
overlay_entry.set_text(&color_to_hex(color));
}
let overlay_picker = gtk::ColorButton::new();
overlay_picker.set_title("Choose Accent Color");
overlay_picker.set_use_alpha(false);
overlay_picker.set_modal(true);
overlay_picker.add_css_class("lios-color-button");
overlay_picker.set_tooltip_text(Some("Pick a fixed accent color"));
overlay_picker.set_valign(gtk::Align::Center);
set_cursor_color_picker(
&overlay_picker,
overlay_entry.text().trim(),
ACCENT_PRESETS[0].1,
);
let overlay_syncing = Rc::new(Cell::new(false));
let overlay_editor = gtk::Box::new(gtk::Orientation::Horizontal, 6);
overlay_entry.set_hexpand(true);
overlay_editor.append(&overlay_entry);
overlay_editor.append(&overlay_picker);
let accent_touched = Rc::new(Cell::new(false));
{
let accent_touched_for_change = accent_touched.clone();
let overlay_opacity_for_change = overlay_opacity.clone();
let overlay_picker_for_change = overlay_picker.downgrade();
let overlay_syncing_for_change = overlay_syncing.clone();
let random_overlay_for_change = random_overlay.clone();
let cursor_match_for_change = cursor_match_overlay.downgrade();
let mark_dirty = mark_dirty.clone();
let updating_controls = updating_controls.clone();
overlay_entry.connect_changed(move |entry| {
let color = entry.text();
let color = color.trim();
if !overlay_syncing_for_change.get() {
if let Some(picker) = overlay_picker_for_change.upgrade() {
overlay_syncing_for_change.set(true);
set_cursor_color_picker(&picker, color, ACCENT_PRESETS[0].1);
overlay_syncing_for_change.set(false);
}
}
if updating_controls.get() {
return;
}
accent_touched_for_change.set(true);
if !color.is_empty() {
random_overlay_for_change.set(false);
if overlay_opacity_for_change.value() <= 0.0 {
overlay_opacity_for_change.set_value(DEFAULT_OVERLAY_OPACITY);
}
} else if cursor_match_for_change
.upgrade()
.is_some_and(|button| button.is_active())
{
random_overlay_for_change.set(true);
if overlay_opacity_for_change.value() <= 0.0 {
overlay_opacity_for_change.set_value(DEFAULT_OVERLAY_OPACITY);
}
}
mark_dirty();
});
}
overlay_picker.connect_rgba_notify({
let overlay_entry = overlay_entry.downgrade();
let overlay_syncing = overlay_syncing.clone();
let updating_controls = updating_controls.clone();
move |picker| {
if updating_controls.get() || overlay_syncing.get() {
return;
}
if let Some(entry) = overlay_entry.upgrade() {
entry.set_text(&color_to_hex(&picker.rgba()));
}
}
});
cursor_match_overlay.connect_toggled({
let cursor_background = cursor_background.clone();
let cursor_foreground = cursor_foreground.clone();
let overlay_entry = overlay_entry.clone();
let overlay_opacity = overlay_opacity.clone();
let random_overlay = random_overlay.clone();
let accent_touched = accent_touched.clone();
let status = status.clone();
let mark_dirty = mark_dirty.clone();
let live_edit_enabled = live_edit_enabled.clone();
let updating_controls = updating_controls.clone();
let legacy_cursor_match = legacy_cursor_match.clone();
move |button: >k::ToggleButton| {
let active = button.is_active();
set_choice_toggle_visual(button, active);
cursor_background.widget.set_sensitive(!active);
cursor_foreground.widget.set_sensitive(!active);
if updating_controls.get() {
return;
}
legacy_cursor_match.set(false);
if active {
updating_controls.set(true);
if overlay_entry.text().trim().is_empty() {
random_overlay.set(true);
accent_touched.set(false);
}
if overlay_opacity.value() <= 0.0 {
overlay_opacity.set_value(DEFAULT_OVERLAY_OPACITY);
}
updating_controls.set(false);
}
mark_dirty();
if !live_edit_enabled.get() {
if active {
status.set_text("Unified Accent will match overlay and cursor. Apply to save.");
} else {
status.set_text("Unified Accent disabled. Apply to save.");
}
}
}
});
overlay_card.append(&preference_row("Color", &overlay_editor));
let accent_presets = gtk::Grid::new();
accent_presets.set_column_spacing(6);
accent_presets.set_row_spacing(6);
accent_presets.set_hexpand(true);
for (index, (label, color, _)) in ACCENT_PRESETS.iter().enumerate() {
let button = gtk::Button::with_label(label);
button.add_css_class("lios-choice");
button.set_hexpand(true);
let color = (*color).to_string();
let overlay_entry_for_preset = overlay_entry.clone();
let overlay_opacity_for_preset = overlay_opacity.clone();
let random_overlay_for_preset = random_overlay.clone();
let status_for_preset = status.clone();
let mark_dirty_for_preset = mark_dirty.clone();
let live_edit_for_preset = live_edit_enabled.clone();
let updating_controls_for_preset = updating_controls.clone();
let accent_touched_for_preset = accent_touched.clone();
button.connect_clicked(move |_| {
let changed = overlay_entry_for_preset.text().trim() != color.as_str()
|| random_overlay_for_preset.value.get()
|| overlay_opacity_for_preset.value() <= 0.0;
updating_controls_for_preset.set(true);
overlay_entry_for_preset.set_text(&color);
random_overlay_for_preset.set(false);
if overlay_opacity_for_preset.value() <= 0.0 {
overlay_opacity_for_preset.set_value(DEFAULT_OVERLAY_OPACITY);
}
accent_touched_for_preset.set(true);
updating_controls_for_preset.set(false);
if changed {
mark_dirty_for_preset();
if !live_edit_for_preset.get() {
status_for_preset.set_text("Manual accent selected. Apply to save.");
}
} else {
status_for_preset.set_text("Manual accent already selected.");
}
});
accent_presets.attach(&button, (index as i32) % 3, (index as i32) / 3, 1, 1);
}
let clear_accent = gtk::Button::with_label("Clear");
clear_accent.add_css_class("lios-choice");
clear_accent.set_hexpand(true);
let overlay_entry_for_clear = overlay_entry.clone();
let overlay_opacity_for_clear = overlay_opacity.clone();
let random_overlay_for_clear = random_overlay.clone();
let cursor_match_for_clear = cursor_match_overlay.clone();
let status_for_clear_accent = status.clone();
let mark_dirty_for_clear_accent = mark_dirty.clone();
let live_edit_for_clear_accent = live_edit_enabled.clone();
let updating_controls_for_clear_accent = updating_controls.clone();
let accent_touched_for_clear_accent = accent_touched.clone();
clear_accent.connect_clicked(move |_| {
let changed = !overlay_entry_for_clear.text().trim().is_empty()
|| overlay_opacity_for_clear.value() > 0.0
|| random_overlay_for_clear.value.get()
|| cursor_match_for_clear.is_active();
updating_controls_for_clear_accent.set(true);
overlay_entry_for_clear.set_text("");
overlay_opacity_for_clear.set_value(0.0);
random_overlay_for_clear.set(false);
cursor_match_for_clear.set_active(false);
accent_touched_for_clear_accent.set(true);
updating_controls_for_clear_accent.set(false);
if changed {
mark_dirty_for_clear_accent();
if !live_edit_for_clear_accent.get() {
status_for_clear_accent.set_text("Accent cleared. Apply to save.");
}
} else {
status_for_clear_accent.set_text("Accent already clear.");
}
});
let clear_index = ACCENT_PRESETS.len() as i32;
accent_presets.attach(&clear_accent, clear_index % 3, clear_index / 3, 1, 1);
overlay_card.append(&preference_column("Presets", &accent_presets));
overlay_card.append(&preference_column("Random overlay", &random_overlay.widget));
random_overlay.connect_changed({
let accent_touched = accent_touched.clone();
let overlay_entry = overlay_entry.clone();
let overlay_opacity = overlay_opacity.clone();
let random_overlay_value = random_overlay.value.clone();
let cursor_match_overlay = cursor_match_overlay.downgrade();
let updating_controls = updating_controls.clone();
let mark_dirty = mark_dirty.clone();
Rc::new(move || {
accent_touched.set(false);
updating_controls.set(true);
if random_overlay_value.get() {
if overlay_opacity.value() <= 0.0 {
overlay_opacity.set_value(DEFAULT_OVERLAY_OPACITY);
}
} else if overlay_entry.text().trim().is_empty() {
if let Some(button) = cursor_match_overlay.upgrade() {
if button.is_active() {
button.set_active(false);
}
}
}
updating_controls.set(false);
mark_dirty();
})
});
theme_choice.connect_changed({
let theme_value = theme_choice.value.clone();
let prompt_profile = prompt_profile.clone();
let overlay_entry = overlay_entry.clone();
let overlay_picker = overlay_picker.clone();
let overlay_opacity = overlay_opacity.clone();
let random_overlay = random_overlay.clone();
let accent_touched = accent_touched.clone();
let updating_controls = updating_controls.clone();
let mark_dirty = mark_dirty.clone();
Rc::new(move || {
if theme_value.borrow().as_str() != "bronco" {
return;
}
updating_controls.set(true);
prompt_profile.set("lightbar");
overlay_entry.set_text(BRONCO_ACCENT);
set_cursor_color_picker(&overlay_picker, BRONCO_ACCENT, BRONCO_ACCENT);
random_overlay.set(false);
if overlay_opacity.value() <= 0.0 {
overlay_opacity.set_value(DEFAULT_OVERLAY_OPACITY);
}
accent_touched.set(false);
updating_controls.set(false);
mark_dirty();
})
});
let controls = PreferenceFormControls {
baseline: Rc::new(RefCell::new(snapshot.clone())),
gpu_acceleration: gpu_acceleration.clone(),
gpu_mode_choice: gpu_mode_choice.clone(),
theme_choice: theme_choice.clone(),
prompt_profile: prompt_profile.clone(),
cursor_match_overlay: cursor_match_overlay.clone(),
cursor_background: cursor_background.clone(),
cursor_foreground: cursor_foreground.clone(),
cursor_touched: cursor_touched.clone(),
legacy_cursor_match: legacy_cursor_match.clone(),
font_entry: font_entry.clone(),
decorated: decorated.clone(),
image_entry: image_entry.clone(),
master_opacity: master_opacity.clone(),
terminal_opacity: terminal_opacity.clone(),
image_opacity: image_opacity.clone(),
overlay_opacity: overlay_opacity.clone(),
overlay_entry: overlay_entry.clone(),
overlay_picker: overlay_picker.clone(),
random_overlay: random_overlay.clone(),
accent_touched: accent_touched.clone(),
dirty: dirty.clone(),
updating_controls: updating_controls.clone(),
update_dirty_actions: update_dirty_actions.clone(),
status: status.clone(),
};
dark_default.connect_clicked({
let controls = controls.clone();
let mark_dirty = mark_dirty.clone();
move |_| {
let baseline = controls.baseline.borrow().clone();
// Recovery must work even when a custom wallpaper has disappeared
// or an appearance field currently contains invalid text.
let mut next = baseline.clone();
let result: Result<AppSettings, String> = (|| {
next.window.renderer = RendererPreference::from_gpu_settings(
controls.gpu_acceleration.value.get(),
&controls.gpu_mode_choice.value.borrow(),
)?;
next.terminal.prompt =
PromptConfig::named(&controls.prompt_profile.value.borrow())?;
reset_original_look_settings(&mut next)?;
Ok(next)
})();
match result {
Ok(next) => {
controls.sync_from(&next, None);
*controls.baseline.borrow_mut() = baseline;
controls.cursor_touched.set(true);
controls.accent_touched.set(true);
mark_dirty();
}
Err(message) => controls.status.set_text(&message),
}
}
});
let profiles_card = build_theme_profiles_card(
parent,
pane.clone(),
settings.clone(),
config_path.clone(),
controls.clone(),
);
let plugins_card = crate::plugins::build_card(&status, config_path.clone());
let page_names = [
"appearance",
"background",
"terminal",
"profiles",
"plugins",
];
for (name, title, cards) in [
(
"appearance",
"Appearance",
vec![appearance_card, cursor_card, overlay_card],
),
(
"background",
"Background",
vec![background_card, opacity_card],
),
(
"terminal",
"Terminal",
vec![session_card, display_card, config_card],
),
("profiles", "Profiles", vec![profiles_card]),
("plugins", "Plugins", vec![plugins_card]),
] {
let page = gtk::Box::new(gtk::Orientation::Vertical, 12);
page.add_css_class("lios-preferences-page");
for card in cards {
page.append(&card);
}
pages.add_titled(&page, Some(name), title);
}
compact_navigation.connect_selected_notify({
let pages = pages.downgrade();
move |navigation| {
if let (Some(pages), Some(name)) = (
pages.upgrade(),
page_names.get(navigation.selected() as usize),
) {
pages.set_visible_child_name(name);
}
}
});
pages.connect_visible_child_name_notify({
let navigation = compact_navigation.downgrade();
move |pages| {
if let (Some(navigation), Some(name)) =
(navigation.upgrade(), pages.visible_child_name())
{
if let Some(index) = page_names.iter().position(|candidate| *candidate == name) {
navigation.set_selected(index as u32);
}
}
}
});
let scroll = gtk::ScrolledWindow::new();
scroll.add_css_class("lios-drawer-scroll");
scroll.set_policy(gtk::PolicyType::Automatic, gtk::PolicyType::Automatic);
scroll.set_propagate_natural_height(true);
scroll.set_min_content_height(0);
scroll.set_max_content_height(360);
scroll.set_vexpand(true);
scroll.set_child(Some(&pages));
pages.connect_visible_child_name_notify({
let scroll = scroll.downgrade();
move |_| {
if let Some(scroll) = scroll.upgrade() {
scroll.vadjustment().set_value(0.0);
}
}
});
drawer.append(&scroll);
let footer = gtk::Box::new(gtk::Orientation::Vertical, 8);
footer.add_css_class("lios-drawer-footer");
let revert = gtk::Button::with_label("Revert");
revert.set_widget_name("preferences-revert");
revert.add_css_class("lios-collapse");
revert.set_tooltip_text(Some("Revert pending changes (Ctrl+R)"));
revert.set_sensitive(false);
let settings_for_revert = settings.clone();
let controls_for_revert = controls.clone();
revert.connect_clicked({
let parent = parent.downgrade();
let pane = pane.clone();
let config_path = config_path.clone();
let source = live_edit_source.clone();
move |_| {
if let Some(source) = source.borrow_mut().take() {
source.remove();
}
let current = latest_config_settings(
config_path.as_deref(),
&controls_for_revert.baseline.borrow(),
);
if let Some(parent) = parent.upgrade() {
if let Err(message) = apply_live_app_settings(&parent, &pane, ¤t) {
controls_for_revert.status.set_text(&message);
return;
}
}
*settings_for_revert.borrow_mut() = current.clone();
controls_for_revert.sync_from(¤t, Some("Reverted unsaved changes."));
}
});
let apply = gtk::Button::with_label("Apply & Save");
apply.set_widget_name("preferences-apply");
apply.add_css_class("lios-apply");
apply.set_tooltip_text(Some(
"Apply and save pending changes (Ctrl+S or Ctrl+Enter)",
));
apply.set_sensitive(false);
dirty_actions.borrow_mut().push(revert.downgrade());
dirty_actions.borrow_mut().push(apply.downgrade());
let saving = Rc::new(Cell::new(false));
let save_action: Rc<dyn Fn()> = {
let context = ProfileActionContext {
parent: parent.downgrade(),
pane: pane.clone(),
settings: settings.clone(),
config_path: config_path.clone(),
controls: controls.clone(),
};
let pages = pages.downgrade();
let quick_bar = quick_bar.downgrade();
let saving = saving.clone();
let apply = apply.downgrade();
let revert = revert.downgrade();
let source = live_edit_source.clone();
Rc::new(move || {
if saving.get() {
return;
}
if let Some(source) = source.borrow_mut().take() {
source.remove();
}
save_preferences(
context.clone(),
pages.clone(),
quick_bar.clone(),
saving.clone(),
apply.clone(),
revert.clone(),
);
})
};
apply.connect_clicked({
let save_action = save_action.clone();
move |_| save_action()
});
let preference_shortcuts = gtk::EventControllerKey::new();
preference_shortcuts.set_propagation_phase(gtk::PropagationPhase::Capture);
preference_shortcuts.connect_key_pressed({
let apply = apply.downgrade();
let revert = revert.downgrade();
let drawer = drawer.downgrade();
let parent = parent.downgrade();
move |_, key, _, state| {
let modifiers = shortcut_modifiers(state);
if matches!(key, gdk::Key::Tab | gdk::Key::ISO_Left_Tab)
&& (modifiers.is_empty() || modifiers == gdk::ModifierType::SHIFT_MASK)
{
let direction = if modifiers.is_empty() {
gtk::DirectionType::TabForward
} else {
gtk::DirectionType::TabBackward
};
if let Some(drawer) = drawer.upgrade() {
if !drawer.child_focus(direction) {
if let Some(parent) = parent.upgrade() {
GtkWindowExt::set_focus(&parent, None::<>k::Widget>);
}
drawer.child_focus(direction);
}
}
return glib::Propagation::Stop;
}
match preferences_shortcut(key, state) {
Some(PreferencesShortcut::Apply) => {
if let Some(apply) = apply.upgrade() {
if apply.is_sensitive() {
apply.emit_clicked();
}
}
}
Some(PreferencesShortcut::Revert) => {
if let Some(revert) = revert.upgrade() {
if revert.is_sensitive() {
revert.emit_clicked();
}
}
}
None => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
}
});
drawer.add_controller(preference_shortcuts);
drawer.connect_map(|drawer| {
drawer.child_focus(gtk::DirectionType::TabForward);
});
let live_edit = gtk::ToggleButton::with_label("Live Edit");
live_edit.set_widget_name("preferences-live-edit");
live_edit.add_css_class("lios-live-edit");
live_edit.set_tooltip_text(Some(
"Apply and save changes immediately while this drawer is open",
));
let live_edit_syncing = Rc::new(Cell::new(false));
*live_edit_action.borrow_mut() = Some(save_action);
live_edit.connect_toggled({
let live_edit_enabled = live_edit_enabled.clone();
let live_edit_action = live_edit_action.clone();
let live_edit_syncing = live_edit_syncing.clone();
let live_edit_source = live_edit_source.clone();
let status = status.clone();
let dirty = dirty.clone();
move |button: >k::ToggleButton| {
let active = button.is_active();
set_live_edit_button_visual(button, active);
live_edit_enabled.set(active);
if !active {
if let Some(source) = live_edit_source.borrow_mut().take() {
source.remove();
}
}
if live_edit_syncing.get() {
return;
}
if active {
if dirty.get() {
if let Some(action) = live_edit_action.borrow().as_ref().cloned() {
action();
}
} else {
status.set_text("Live Edit on. Changes apply and save immediately.");
}
} else if dirty.get() {
status.set_text("Live Edit off. Unsaved changes still need Apply.");
} else {
status.set_text("Live Edit off.");
}
}
});
let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
spacer.set_hexpand(true);
actions.append(&live_edit);
actions.append(&spacer);
actions.append(&revert);
actions.append(&apply);
footer.append(&actions);
footer.append(&status);
drawer.append(&footer);
let resize_drawer: Rc<dyn Fn(i32, i32)> = {
let drawer = drawer.downgrade();
let previous = Cell::new((0, 0));
let tabs = tabs.downgrade();
let navigation = compact_navigation.downgrade();
let scroll = scroll.downgrade();
Rc::new(move |width, height| {
let Some(drawer) = drawer.upgrade() else {
return;
};
let size = ((width - 48).clamp(1, 760), (height - 40).clamp(1, 640));
if previous.replace(size) != size {
drawer.set_size_request(size.0, size.1);
let compact = size.0 < 600;
if let Some(tabs) = tabs.upgrade() {
tabs.set_visible(!compact);
}
if let Some(navigation) = navigation.upgrade() {
navigation.set_visible(compact);
}
if let Some(scroll) = scroll.upgrade() {
scroll.set_max_content_height((size.1 - 200).max(1));
}
}
})
};
resize_drawer(parent.width(), parent.height());
// Follow allocation events instead of keeping a frame-clock tick running
// continuously while an otherwise idle preferences page is open.
if let Some(surface) = parent.surface() {
let handler = surface.connect_layout(move |_, width, height| resize_drawer(width, height));
let handler = RefCell::new(Some(handler));
let surface = surface.downgrade();
drawer.connect_destroy(move |_| {
if let (Some(surface), Some(handler)) = (surface.upgrade(), handler.borrow_mut().take())
{
surface.disconnect(handler);
}
});
}
let settings_for_reveal = settings.clone();
let controls_for_reveal = controls.clone();
let live_edit_for_reveal = live_edit.clone();
let live_edit_enabled_for_reveal = live_edit_enabled.clone();
let live_edit_syncing_for_reveal = live_edit_syncing.clone();
let config_path_for_reveal = config_path.clone();
revealer.connect_notify_local(Some("reveal-child"), move |revealer, _| {
live_edit_syncing_for_reveal.set(true);
live_edit_enabled_for_reveal.set(false);
live_edit_for_reveal.set_active(false);
set_live_edit_button_visual(&live_edit_for_reveal, false);
live_edit_syncing_for_reveal.set(false);
if revealer.reveals_child() && !controls_for_reveal.is_dirty() {
let current = {
let local = settings_for_reveal.borrow();
latest_config_settings(config_path_for_reveal.as_deref(), &local)
};
*settings_for_reveal.borrow_mut() = current.clone();
controls_for_reveal.sync_from(¤t, None);
} else if !revealer.reveals_child() && controls_for_reveal.is_dirty() {
controls_for_reveal
.status
.set_text("Live Edit off. Unsaved changes still need Apply.");
}
});
drawer.connect_destroy({
let live_edit_action = live_edit_action.clone();
let live_edit_source = live_edit_source.clone();
let dirty_actions = dirty_actions.clone();
move |_| {
if let Some(source) = live_edit_source.borrow_mut().take() {
source.remove();
}
live_edit_action.borrow_mut().take();
dirty_actions.borrow_mut().clear();
}
});
drawer
}
fn toggle_revealer(revealer: >k::Revealer) -> bool {
let reveal = !revealer.reveals_child();
revealer.set_reveal_child(reveal);
reveal
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PreferencesShortcut {
Apply,
Revert,
}
fn preferences_shortcut(key: gdk::Key, state: gdk::ModifierType) -> Option<PreferencesShortcut> {
if shortcut_modifiers(state) != gdk::ModifierType::CONTROL_MASK {
return None;
}
if matches!(
key,
gdk::Key::Return | gdk::Key::ISO_Enter | gdk::Key::KP_Enter
) {
return Some(PreferencesShortcut::Apply);
}
match key
.to_unicode()
.map(|character| character.to_ascii_lowercase())
{
Some('s') => Some(PreferencesShortcut::Apply),
Some('r') => Some(PreferencesShortcut::Revert),
_ => None,
}
}
fn preferences_config_destination(path: Option<&Path>) -> String {
path.map(|path| format!("Config: {}", path.display()))
.unwrap_or_else(|| "Config: session only".to_string())
}
fn preferences_config_directory(path: Option<&Path>) -> Option<PathBuf> {
path.and_then(Path::parent).map(Path::to_path_buf)
}
#[derive(Clone)]
struct CursorColorControl {
widget: gtk::Box,
entry: gtk::Entry,
picker: gtk::ColorButton,
fallback: String,
syncing: Rc<Cell<bool>>,
}
impl CursorColorControl {
fn new(label: &str, initial: &str, fallback: &str, mark_dirty: Rc<dyn Fn()>) -> Self {
let entry = cursor_color_entry(initial);
let picker = gtk::ColorButton::new();
picker.set_title(&format!("Choose {label} Cursor Color"));
picker.set_use_alpha(false);
picker.set_modal(true);
picker.add_css_class("lios-color-button");
picker.set_tooltip_text(Some("Pick a cursor color; clear the text field to inherit"));
picker.set_valign(gtk::Align::Center);
set_cursor_color_picker(&picker, initial, fallback);
let syncing = Rc::new(Cell::new(false));
entry.connect_changed({
let picker = picker.downgrade();
let fallback = fallback.to_string();
let syncing = syncing.clone();
let mark_dirty = mark_dirty.clone();
move |entry| {
if syncing.get() {
return;
}
if let Some(picker) = picker.upgrade() {
syncing.set(true);
set_cursor_color_picker(&picker, entry.text().trim(), &fallback);
syncing.set(false);
}
mark_dirty();
}
});
picker.connect_rgba_notify({
let entry = entry.downgrade();
let syncing = syncing.clone();
move |picker| {
if syncing.get() {
return;
}
let Some(entry) = entry.upgrade() else {
return;
};
syncing.set(true);
entry.set_text(&color_to_hex(&picker.rgba()));
syncing.set(false);
mark_dirty();
}
});
let editor = gtk::Box::new(gtk::Orientation::Horizontal, 6);
entry.set_hexpand(true);
editor.append(&entry);
editor.append(&picker);
Self {
widget: preference_row(label, &editor),
entry,
picker,
fallback: fallback.to_string(),
syncing,
}
}
fn text(&self) -> String {
self.entry.text().trim().to_string()
}
fn set_text(&self, text: &str) {
self.syncing.set(true);
self.entry.set_text(text);
set_cursor_color_picker(&self.picker, text, &self.fallback);
self.syncing.set(false);
}
}
#[derive(Clone)]
struct PreferenceFormControls {
baseline: Rc<RefCell<AppSettings>>,
gpu_acceleration: BoolChoice,
gpu_mode_choice: ChoiceGroup,
theme_choice: DropDownChoice,
prompt_profile: DropDownChoice,
cursor_match_overlay: gtk::ToggleButton,
cursor_background: CursorColorControl,
cursor_foreground: CursorColorControl,
cursor_touched: Rc<Cell<bool>>,
legacy_cursor_match: Rc<Cell<bool>>,
font_entry: gtk::Entry,
decorated: BoolChoice,
image_entry: gtk::Entry,
master_opacity: gtk::Scale,
terminal_opacity: gtk::Scale,
image_opacity: gtk::Scale,
overlay_opacity: gtk::Scale,
overlay_entry: gtk::Entry,
overlay_picker: gtk::ColorButton,
random_overlay: BoolChoice,
accent_touched: Rc<Cell<bool>>,
dirty: Rc<Cell<bool>>,
updating_controls: Rc<Cell<bool>>,
update_dirty_actions: Rc<dyn Fn(bool)>,
status: gtk::Label,
}
impl PreferenceFormControls {
fn is_dirty(&self) -> bool {
self.dirty.get()
}
fn sync_from(&self, current: &AppSettings, status_text: Option<&str>) {
self.status.remove_css_class("lios-error");
*self.baseline.borrow_mut() = current.clone();
self.updating_controls.set(true);
self.gpu_acceleration
.set(current.window.renderer.gpu_enabled());
self.gpu_mode_choice
.set(current.window.renderer.gpu_mode_config());
self.theme_choice.set(¤t.terminal.theme_name);
self.prompt_profile
.set(¤t.terminal.prompt.profile_name);
self.cursor_match_overlay
.set_active(current.terminal.unified_accent);
set_choice_toggle_visual(&self.cursor_match_overlay, current.terminal.unified_accent);
self.cursor_background.set_text(&cursor_entry_text(
current.terminal.cursor_background.as_deref(),
current.terminal.theme.cursor_background_color(),
));
self.cursor_foreground.set_text(&cursor_entry_text(
current.terminal.cursor_foreground.as_deref(),
current.terminal.theme.cursor_foreground_color(),
));
self.cursor_background
.widget
.set_sensitive(!current.terminal.unified_accent);
self.cursor_foreground
.widget
.set_sensitive(!current.terminal.unified_accent);
self.font_entry.set_text(¤t.terminal.font);
self.decorated.set(current.window.decorated);
if let Some(image) = ¤t.terminal.background.image {
self.image_entry.set_text(&image.to_string_lossy());
} else {
self.image_entry.set_text("");
}
self.master_opacity.set_value(current.window.opacity);
self.terminal_opacity
.set_value(current.terminal.background.terminal_opacity);
self.image_opacity
.set_value(current.terminal.background.image_opacity);
self.overlay_opacity
.set_value(current.terminal.background.overlay_opacity);
if let Some(color) = ¤t.terminal.background.overlay_color {
self.overlay_entry.set_text(&color_to_hex(color));
} else {
self.overlay_entry.set_text("");
}
set_cursor_color_picker(
&self.overlay_picker,
self.overlay_entry.text().trim(),
ACCENT_PRESETS[0].1,
);
self.random_overlay
.set(current.terminal.background.random_overlay);
self.updating_controls.set(false);
self.accent_touched.set(false);
self.cursor_touched.set(false);
self.legacy_cursor_match
.set(current.terminal.cursor_match_overlay && !current.terminal.unified_accent);
(self.update_dirty_actions)(false);
self.status.set_text(status_text.unwrap_or(""));
}
}
#[derive(Clone)]
struct ProfileActionContext {
parent: glib::WeakRef<gtk::ApplicationWindow>,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
controls: PreferenceFormControls,
}
fn save_preferences(
context: ProfileActionContext,
pages: glib::WeakRef<gtk::Stack>,
quick_bar: glib::WeakRef<gtk::Box>,
saving: Rc<Cell<bool>>,
apply: glib::WeakRef<gtk::Button>,
revert: glib::WeakRef<gtk::Button>,
) {
let baseline = context.controls.baseline.borrow().clone();
let (edited, renderer_changed) = match settings_from_preferences(&baseline, &context.controls) {
Ok(settings) => settings,
Err(message) => {
context.controls.status.add_css_class("lios-error");
context.controls.status.set_text(&message);
return;
}
};
let prompt_changed = edited.terminal.prompt != baseline.terminal.prompt;
let current =
latest_config_settings(context.config_path.as_deref(), &context.settings.borrow());
let next = match edited.merge_changes(&baseline, ¤t) {
Ok(next) => next,
Err(message) => {
context.controls.status.set_text(&message);
return;
}
};
let Some(parent) = context.parent.upgrade() else {
return;
};
if let Err(message) = apply_live_app_settings(&parent, &context.pane, &next) {
context.controls.status.set_text(&message);
return;
}
*context.settings.borrow_mut() = next.clone();
context.controls.status.remove_css_class("lios-error");
let Some(path) = context.config_path.clone() else {
context
.controls
.sync_from(&next, Some("Applied for this session."));
return;
};
saving.set(true);
if let Some(pages) = pages.upgrade() {
pages.set_sensitive(false);
}
if let Some(quick_bar) = quick_bar.upgrade() {
quick_bar.set_sensitive(false);
}
if let Some(button) = apply.upgrade() {
button.set_sensitive(false);
button.set_label("Saving…");
}
if let Some(button) = revert.upgrade() {
button.set_sensitive(false);
}
context.controls.status.set_text("Saving preferences…");
let application_hold = parent.application().map(|app| app.hold());
glib::spawn_future_local(async move {
let _application_hold = application_hold;
let result = persist_config_async(path, baseline, edited).await;
saving.set(false);
if let Some(pages) = pages.upgrade() {
pages.set_sensitive(true);
}
if let Some(quick_bar) = quick_bar.upgrade() {
quick_bar.set_sensitive(true);
}
if let Some(button) = apply.upgrade() {
button.set_label("Apply & Save");
}
match result {
Ok(persisted) => {
// A different window may have committed independent edits while
// this transaction ran; the disk merge is now authoritative.
let preview_error = context.parent.upgrade().and_then(|parent| {
apply_live_app_settings(&parent, &context.pane, &persisted).err()
});
*context.settings.borrow_mut() = persisted.clone();
let message = if renderer_changed {
"Saved. Rendering changes take effect next launch."
} else if prompt_changed {
"Saved. This Bash session updates at its next prompt; press Enter when idle."
} else {
"Preferences saved."
};
context.controls.sync_from(&persisted, Some(message));
if let Some(message) = preview_error {
context.controls.status.add_css_class("lios-error");
context.controls.status.set_text(&format!(
"Saved, but could not update the preview: {message}"
));
}
}
Err(message) => {
(context.controls.update_dirty_actions)(true);
context.controls.status.add_css_class("lios-error");
context.controls.status.set_text(&format!(
"Not saved: {message}. Your edits are kept; try Apply again."
));
}
}
});
}
fn settings_from_preferences(
current: &AppSettings,
controls: &PreferenceFormControls,
) -> Result<(AppSettings, bool), String> {
let baseline = controls.baseline.borrow();
let mut next = baseline.clone();
let theme_name = controls.theme_choice.value.borrow().clone();
let prompt_profile = controls.prompt_profile.value.borrow().clone();
let gpu_mode = controls.gpu_mode_choice.value.borrow().clone();
let theme_changed = !theme_name.eq_ignore_ascii_case(&baseline.terminal.theme_name);
if theme_changed {
next.select_theme(&theme_name)?;
}
if controls.cursor_touched.get() {
next.set_theme_cursor_overrides(
cursor_override_value(&controls.cursor_background.text()),
cursor_override_value(&controls.cursor_foreground.text()),
)?;
}
let renderer =
RendererPreference::from_gpu_settings(controls.gpu_acceleration.value.get(), &gpu_mode)?;
let overlay_text = controls.overlay_entry.text().trim().to_string();
let overlay_color = if overlay_text.is_empty() {
None
} else {
Some(TerminalTheme::parse_color(&overlay_text)?)
};
if controls.accent_touched.get()
&& overlay_color.is_some()
&& controls.random_overlay.value.get()
{
controls.random_overlay.set(false);
}
let image_path = image_path_from_text(controls.image_entry.text().trim())?;
let renderer_changed = renderer != current.window.renderer;
next.window.renderer = renderer;
next.window.opacity = controls.master_opacity.value();
next.window.decorated = controls.decorated.value.get();
next.terminal.prompt = PromptConfig::named(&prompt_profile)?;
let font = controls.font_entry.text();
if font.trim().is_empty() {
return Err("Enter a font, for example Monospace 12.".to_string());
}
next.terminal.font = font.trim().to_string();
next.terminal.background.image = image_path;
next.terminal.background.image_opacity = controls.image_opacity.value();
next.terminal.background.terminal_opacity = controls.terminal_opacity.value();
next.terminal.background.overlay_color = overlay_color;
next.terminal.background.overlay_opacity = controls.overlay_opacity.value();
next.terminal.background.random_overlay = controls.random_overlay.value.get();
apply_unified_accent_preference(
&mut next,
&baseline,
controls.cursor_match_overlay.is_active(),
controls.cursor_touched.get(),
);
let next = next.merge_changes(&baseline, current)?;
Ok((next, renderer_changed))
}
fn build_theme_profiles_card(
parent: >k::ApplicationWindow,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
controls: PreferenceFormControls,
) -> gtk::Box {
let card = preference_card("Profiles", "Name a look; Enter adds or updates it");
let profile_name = gtk::Entry::new();
profile_name.add_css_class("lios-field");
profile_name.set_placeholder_text(Some("Profile name"));
let save_profile = gtk::Button::with_label("Add / Update");
save_profile.add_css_class("lios-choice");
save_profile.set_tooltip_text(Some("Save the current controls under this name"));
let save_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
profile_name.set_hexpand(true);
save_profile.set_hexpand(false);
save_row.append(&profile_name);
save_row.append(&save_profile);
card.append(&preference_row("New look", &save_row));
let profile_grid = gtk::Grid::new();
profile_grid.set_column_spacing(6);
profile_grid.set_row_spacing(6);
profile_grid.set_hexpand(true);
let context = ProfileActionContext {
parent: parent.downgrade(),
pane,
settings: settings.clone(),
config_path,
controls,
};
rebuild_theme_profile_buttons(
&profile_grid,
&settings.borrow().theme_profiles.clone(),
&context,
);
card.append(&profile_grid);
save_profile.connect_clicked({
let context = context.clone();
let profile_grid = profile_grid.clone();
let profile_name = profile_name.clone();
move |_| {
let current = {
let current = context.settings.borrow();
latest_config_settings(context.config_path.as_deref(), ¤t)
};
let (mut next, _) = match settings_from_preferences(¤t, &context.controls) {
Ok(next) => next,
Err(message) => {
context.controls.status.set_text(&message);
return;
}
};
next.window.renderer = current.window.renderer;
let profile = match ThemeProfile::from_settings(&profile_name.text(), &next) {
Ok(profile) => profile,
Err(message) => {
context.controls.status.set_text(&message);
return;
}
};
let profile_label = profile.name.clone();
next.upsert_theme_profile(profile);
if let Some(path) = &context.config_path {
match persist_config_now(path, ¤t, &next) {
Ok(persisted) => next = persisted,
Err(message) => {
context.controls.status.set_text(&message);
return;
}
}
}
let apply_result = if let Some(parent) = context.parent.upgrade() {
apply_live_app_settings(&parent, &context.pane, &next)
} else {
context.pane.apply_config(&next.terminal)
};
if let Err(message) = apply_result {
context.controls.status.set_text(&message);
return;
}
*context.settings.borrow_mut() = next;
let current = context.settings.borrow();
let profiles = current.theme_profiles.clone();
let message = if context.config_path.is_some() {
format!("Profile '{profile_label}' saved.")
} else {
format!("Profile '{profile_label}' saved for this session.")
};
context.controls.sync_from(¤t, Some(&message));
drop(current);
rebuild_theme_profile_buttons(&profile_grid, &profiles, &context);
profile_name.set_text("");
}
});
profile_name.connect_activate({
let save_profile = save_profile.downgrade();
move |_| {
if let Some(button) = save_profile.upgrade() {
button.emit_clicked();
}
}
});
card
}
fn rebuild_theme_profile_buttons(
grid: >k::Grid,
profiles: &[ThemeProfile],
context: &ProfileActionContext,
) {
while let Some(child) = grid.first_child() {
grid.remove(&child);
}
if profiles.is_empty() {
return;
}
for (index, profile) in profiles.iter().cloned().enumerate() {
add_theme_profile_button(grid, index, profile, context.clone());
}
}
fn add_theme_profile_button(
grid: >k::Grid,
index: usize,
profile: ThemeProfile,
context: ProfileActionContext,
) {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 3);
let apply = gtk::Button::with_label(&profile.name);
apply.add_css_class("lios-choice");
apply.set_hexpand(true);
apply.set_tooltip_text(Some(&format!(
"Apply {} ({})",
profile.name,
choice_label(&profile.theme_name)
)));
let profile_for_apply = profile.clone();
let context_for_apply = context.clone();
apply.connect_clicked(move |_| {
let Some(parent) = context_for_apply.parent.upgrade() else {
return;
};
let profile = profile_for_apply.clone();
let profile_label = profile.name.clone();
let result = apply_app_settings(
&parent,
&context_for_apply.pane,
&context_for_apply.settings,
context_for_apply.config_path.as_deref(),
move |next| profile.apply_to(next),
);
match result {
Ok(()) => {
let current = context_for_apply.settings.borrow();
let message = if context_for_apply.config_path.is_some() {
format!("Profile '{profile_label}' applied and saved.")
} else {
format!("Profile '{profile_label}' applied for this session.")
};
context_for_apply
.controls
.sync_from(¤t, Some(&message));
}
Err(message) => context_for_apply.controls.status.set_text(&message),
}
});
let remove = gtk::Button::with_label("Remove");
remove.add_css_class("lios-collapse");
remove.set_tooltip_text(Some(&format!("Remove saved profile '{}'", profile.name)));
remove.connect_clicked({
let grid = grid.downgrade();
let profile_name = profile.name.clone();
move |_| {
let mut next = {
let current = context.settings.borrow();
latest_config_settings(context.config_path.as_deref(), ¤t)
};
let baseline = next.clone();
if !next.remove_theme_profile(&profile_name) {
context.controls.status.set_text("Profile already removed.");
return;
}
if let Some(path) = &context.config_path {
match persist_config_now(path, &baseline, &next) {
Ok(persisted) => next = persisted,
Err(message) => {
context.controls.status.set_text(&message);
return;
}
}
}
let profiles = next.theme_profiles.clone();
*context.settings.borrow_mut() = next;
if let Some(grid) = grid.upgrade() {
rebuild_theme_profile_buttons(&grid, &profiles, &context);
}
let message = if context.config_path.is_some() {
format!("Profile '{profile_name}' removed and saved.")
} else {
format!("Profile '{profile_name}' removed for this session.")
};
context.controls.status.set_text(&message);
}
});
row.append(&apply);
row.append(&remove);
grid.attach(&row, (index as i32) % 2, (index as i32) / 2, 1, 1);
}
fn set_live_edit_button_visual(button: >k::ToggleButton, active: bool) {
if active {
button.add_css_class("lios-live-active");
} else {
button.remove_css_class("lios-live-active");
}
}
fn set_choice_toggle_visual(button: >k::ToggleButton, active: bool) {
if active {
button.add_css_class("lios-selected");
} else {
button.remove_css_class("lios-selected");
}
}
#[derive(Clone)]
struct ChoiceGroup {
widget: gtk::Grid,
value: Rc<RefCell<String>>,
last_reported_value: Rc<RefCell<String>>,
buttons: Rc<RefCell<Vec<glib::WeakRef<gtk::Button>>>>,
options: Rc<Vec<String>>,
}
impl ChoiceGroup {
fn connect_changed(&self, on_change: Rc<dyn Fn()>) {
for button in self
.buttons
.borrow()
.iter()
.filter_map(|button| button.upgrade())
{
let on_change = on_change.clone();
let value = self.value.clone();
let last_reported_value = self.last_reported_value.clone();
button.connect_clicked(move |_| {
let current = value.borrow().clone();
if current != *last_reported_value.borrow() {
*last_reported_value.borrow_mut() = current;
on_change();
}
});
}
}
fn set(&self, selected: &str) {
*self.value.borrow_mut() = selected.to_string();
*self.last_reported_value.borrow_mut() = selected.to_string();
for (index, button) in self
.buttons
.borrow()
.iter()
.filter_map(|button| button.upgrade())
.enumerate()
{
let is_selected = self
.options
.get(index)
.map(|option| option == selected)
.unwrap_or(false);
if is_selected {
button.add_css_class("lios-selected");
} else {
button.remove_css_class("lios-selected");
}
}
}
}
#[derive(Clone)]
struct DropDownChoice {
widget: gtk::DropDown,
value: Rc<RefCell<String>>,
options: Rc<RefCell<Vec<String>>>,
model: gtk::StringList,
syncing: Rc<Cell<bool>>,
}
impl DropDownChoice {
fn connect_changed(&self, on_change: Rc<dyn Fn()>) {
let value = self.value.clone();
let options = self.options.clone();
let syncing = self.syncing.clone();
self.widget.connect_selected_notify(move |dropdown| {
if syncing.get() {
return;
}
let selected = dropdown.selected() as usize;
let Some(option) = options.borrow().get(selected).cloned() else {
return;
};
if *value.borrow() != option {
*value.borrow_mut() = option;
on_change();
}
});
}
fn set(&self, selected: &str) {
self.syncing.set(true);
*self.value.borrow_mut() = selected.to_string();
let existing_index = self
.options
.borrow()
.iter()
.position(|option| option == selected);
let index = match existing_index {
Some(index) => index,
None => {
let mut options = self.options.borrow_mut();
let index = options.len();
options.push(selected.to_string());
self.model.append(&choice_label(selected));
index
}
};
self.widget.set_selected(index as u32);
self.syncing.set(false);
}
}
#[derive(Clone)]
struct BoolChoice {
widget: gtk::Grid,
value: Rc<Cell<bool>>,
last_reported_value: Rc<Cell<bool>>,
buttons: Rc<RefCell<Vec<glib::WeakRef<gtk::Button>>>>,
}
impl BoolChoice {
fn connect_changed(&self, on_change: Rc<dyn Fn()>) {
for button in self
.buttons
.borrow()
.iter()
.filter_map(|button| button.upgrade())
{
let on_change = on_change.clone();
let value = self.value.clone();
let last_reported_value = self.last_reported_value.clone();
button.connect_clicked(move |_| {
let current = value.get();
if current != last_reported_value.get() {
last_reported_value.set(current);
on_change();
}
});
}
}
fn set(&self, selected: bool) {
self.value.set(selected);
self.last_reported_value.set(selected);
for (index, button) in self
.buttons
.borrow()
.iter()
.filter_map(|button| button.upgrade())
.enumerate()
{
if selected == (index == 0) {
button.add_css_class("lios-selected");
} else {
button.remove_css_class("lios-selected");
}
}
}
}
fn choice_grid(options: &[&str], selected: &str, columns: i32) -> ChoiceGroup {
let grid = gtk::Grid::new();
grid.set_column_spacing(6);
grid.set_row_spacing(6);
grid.set_hexpand(true);
let value = Rc::new(RefCell::new(selected.to_string()));
let last_reported_value = Rc::new(RefCell::new(selected.to_string()));
let options = Rc::new(
options
.iter()
.map(|option| (*option).to_string())
.collect::<Vec<_>>(),
);
let buttons = Rc::new(RefCell::new(Vec::<glib::WeakRef<gtk::Button>>::new()));
for (index, option) in options.iter().enumerate() {
let option_value = (*option).to_string();
let button = gtk::Button::with_label(&choice_label(option));
button.add_css_class("lios-choice");
button.set_hexpand(true);
if *option == selected {
button.add_css_class("lios-selected");
}
let buttons_for_click = buttons.clone();
let value_for_click = value.clone();
let option_for_click = option_value.clone();
button.connect_clicked(move |clicked| {
*value_for_click.borrow_mut() = option_for_click.clone();
for button in buttons_for_click
.borrow()
.iter()
.filter_map(|button| button.upgrade())
{
button.remove_css_class("lios-selected");
}
clicked.add_css_class("lios-selected");
});
buttons.borrow_mut().push(button.downgrade());
grid.attach(
&button,
(index as i32) % columns,
(index as i32) / columns,
1,
1,
);
}
ChoiceGroup {
widget: grid,
value,
last_reported_value,
buttons,
options,
}
}
fn prompt_profile_dropdown(selected: &str) -> DropDownChoice {
let selected = PromptConfig::canonical_name(selected).unwrap_or(SHELL_DEFAULT_PROMPT_PROFILE);
dropdown_choice(
PROMPT_PROFILE_NAMES,
selected,
"Apply to this Bash session at its next prompt (press Enter when idle); no setup commands are typed into the terminal",
)
}
fn dropdown_choice(options: &[&str], selected: &str, tooltip: &str) -> DropDownChoice {
let option_values = dropdown_option_values(options, selected);
let options = Rc::new(RefCell::new(option_values));
let labels = options
.borrow()
.iter()
.map(|option| choice_label(option))
.collect::<Vec<_>>();
let label_refs = labels.iter().map(String::as_str).collect::<Vec<_>>();
let widget = gtk::DropDown::from_strings(&label_refs);
let model = widget
.model()
.and_downcast::<gtk::StringList>()
.expect("string dropdown uses a string-list model");
widget.add_css_class("lios-field");
widget.set_hexpand(true);
widget.set_tooltip_text(Some(tooltip));
if let Some(index) = options
.borrow()
.iter()
.position(|option| option == selected)
{
widget.set_selected(index as u32);
}
DropDownChoice {
widget,
value: Rc::new(RefCell::new(selected.to_string())),
options,
model,
syncing: Rc::new(Cell::new(false)),
}
}
fn dropdown_option_values(options: &[&str], selected: &str) -> Vec<String> {
let mut values = options
.iter()
.map(|option| (*option).to_string())
.collect::<Vec<_>>();
if !values.iter().any(|option| option == selected) {
values.push(selected.to_string());
}
values
}
fn bool_choice(selected: bool) -> BoolChoice {
let grid = gtk::Grid::new();
grid.set_column_spacing(6);
grid.set_hexpand(true);
let value = Rc::new(Cell::new(selected));
let last_reported_value = Rc::new(Cell::new(selected));
let buttons = Rc::new(RefCell::new(Vec::<glib::WeakRef<gtk::Button>>::new()));
for (index, (label, state)) in [("On", true), ("Off", false)].iter().enumerate() {
let button = gtk::Button::with_label(label);
button.add_css_class("lios-choice");
button.set_hexpand(true);
if *state == selected {
button.add_css_class("lios-selected");
}
let buttons_for_click = buttons.clone();
let value_for_click = value.clone();
let state_for_click = *state;
button.connect_clicked(move |clicked| {
value_for_click.set(state_for_click);
for button in buttons_for_click
.borrow()
.iter()
.filter_map(|button| button.upgrade())
{
button.remove_css_class("lios-selected");
}
clicked.add_css_class("lios-selected");
});
buttons.borrow_mut().push(button.downgrade());
grid.attach(&button, index as i32, 0, 1, 1);
}
BoolChoice {
widget: grid,
value,
last_reported_value,
buttons,
}
}
fn choice_label(value: &str) -> String {
match value {
"auto" => "Auto".to_string(),
"gl" => "OpenGL".to_string(),
"vulkan" => "Vulkan".to_string(),
"cairo" => "Cairo".to_string(),
"shell-default" => "Shell Default".to_string(),
"lightbar" => "Lightbar".to_string(),
"timebar" => "Timebar".to_string(),
"fibonnaci" => "Fibonnaci".to_string(),
"fano-plane" => "Fano Plane".to_string(),
"alice-bob" => "Alice & Bob".to_string(),
"akira" => "Akira".to_string(),
"default" => "Default".to_string(),
"solarized-dark" => "Solarized Dark".to_string(),
"bronco" => "Bronco".to_string(),
"dark-pastels" => "Dark Pastels".to_string(),
"dracula" => "Dracula".to_string(),
"cyberpunk" => "Cyberpunk".to_string(),
"matrix" => "Matrix".to_string(),
"ocean" => "Ocean".to_string(),
"ember" => "Ember".to_string(),
"green-on-black" => "Green / Black".to_string(),
"black-on-white" => "Black / White".to_string(),
"white-on-black" => "White / Black".to_string(),
other => other.replace(['-', '_'], " "),
}
}
fn preference_card(title: &str, subtitle: &str) -> gtk::Box {
let card = gtk::Box::new(gtk::Orientation::Vertical, 10);
card.add_css_class("lios-card");
card.set_hexpand(true);
card.set_vexpand(false);
card.set_size_request(0, -1);
let title = gtk::Label::new(Some(title));
title.add_css_class("lios-row-label");
title.set_xalign(0.0);
let subtitle = gtk::Label::new(Some(subtitle));
subtitle.add_css_class("lios-muted");
subtitle.set_xalign(0.0);
subtitle.set_wrap(true);
card.append(&title);
card.append(&subtitle);
card
}
fn preference_column(label: &str, control: &impl IsA<gtk::Widget>) -> gtk::Box {
let column = gtk::Box::new(gtk::Orientation::Vertical, 6);
let column_label = gtk::Label::new(Some(label));
column_label.add_css_class("lios-row-label");
column_label.set_xalign(0.0);
control.as_ref().set_hexpand(true);
column.append(&column_label);
column.append(control);
column
}
fn preference_row(label: &str, control: &impl IsA<gtk::Widget>) -> gtk::Box {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let row_label = gtk::Label::new(Some(label));
row_label.add_css_class("lios-row-label");
row_label.set_width_chars(12);
row_label.set_wrap(true);
row_label.set_max_width_chars(16);
row_label.set_xalign(0.0);
control.as_ref().set_hexpand(true);
row.append(&row_label);
row.append(control);
row
}
fn opacity_slider(value: f64) -> gtk::Scale {
let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.01);
scale.add_css_class("lios-slider");
scale.set_draw_value(true);
scale.set_value_pos(gtk::PositionType::Right);
scale.set_digits(2);
scale.set_increments(0.01, 0.05);
scale.set_value(value.clamp(0.0, 1.0));
scale
}
fn cursor_color_entry(initial: &str) -> gtk::Entry {
let entry = gtk::Entry::new();
entry.add_css_class("lios-field");
entry.set_placeholder_text(Some("#rrggbb"));
entry.set_tooltip_text(Some("Type a hex cursor color, for example #00b4d8"));
entry.set_width_chars(8);
entry.set_text(initial);
entry
}
fn set_cursor_color_picker(picker: >k::ColorButton, text: &str, fallback: &str) {
let color = if text.trim().is_empty() {
fallback
} else {
text.trim()
};
if let Ok(color) = TerminalTheme::parse_color(color) {
picker.set_rgba(&color);
}
}
fn cursor_entry_text(config_color: Option<&str>, theme_color: Option<gdk::RGBA>) -> String {
config_color
.map(str::to_string)
.or_else(|| theme_color.map(|color| color_to_hex(&color)))
.unwrap_or_default()
}
fn set_cursor_controls_from_theme(
background: &CursorColorControl,
foreground: &CursorColorControl,
theme: &TerminalTheme,
) {
background.set_text(&cursor_entry_text(None, theme.cursor_background_color()));
foreground.set_text(&cursor_entry_text(None, theme.cursor_foreground_color()));
}
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 image_path_from_text(text: &str) -> Result<Option<PathBuf>, String> {
if text.trim().is_empty() {
return Ok(None);
}
let path = if text.trim() == "@default" {
crate::resources::ensure_default_background()?
} else {
expand_user_path(PathBuf::from(text.trim()))
};
if !path.exists() {
return Err(format!("background image not found: {}", path.display()));
}
if !path.is_file() {
return Err(format!(
"background image is not a file: {}",
path.display()
));
}
Ok(Some(path))
}
fn open_background_image_dialog(
parent: >k::ApplicationWindow,
on_path: impl FnOnce(PathBuf) + 'static,
) {
let dialog = gtk::FileChooserNative::new(
Some("Choose Background Image"),
Some(parent),
gtk::FileChooserAction::Open,
Some("Open"),
Some("Cancel"),
);
dialog.set_modal(true);
let image_filter = gtk::FileFilter::new();
image_filter.set_name(Some("Images"));
image_filter.add_pixbuf_formats();
dialog.add_filter(&image_filter);
dialog.set_filter(&image_filter);
let on_path = Rc::new(RefCell::new(Some(on_path)));
let retained_dialog = Rc::new(RefCell::new(Some(dialog.clone())));
let destroy_handler = Rc::new(RefCell::new(None));
let handler = parent.connect_destroy({
let retained_dialog = retained_dialog.clone();
move |_| {
if let Some(dialog) = retained_dialog.borrow_mut().take() {
dialog.destroy();
}
}
});
destroy_handler.borrow_mut().replace(handler);
let parent = parent.downgrade();
dialog.connect_response(move |dialog, response| {
if let (Some(parent), Some(handler)) =
(parent.upgrade(), destroy_handler.borrow_mut().take())
{
parent.disconnect(handler);
}
let callback = on_path.borrow_mut().take();
let file = (response == gtk::ResponseType::Accept)
.then(|| dialog.file())
.flatten();
dialog.destroy();
retained_dialog.borrow_mut().take();
let (Some(callback), Some(file)) = (callback, file) else {
return;
};
let Some(path) = file.path().map(expand_user_path) else {
eprintln!("background image must be a local file");
return;
};
if !path.is_file() {
eprintln!("background image is not a file: {}", path.display());
return;
}
callback(path);
});
dialog.show();
}
#[derive(Clone)]
struct TerminalContextMenu {
popover: glib::WeakRef<gtk::PopoverMenu>,
window: glib::WeakRef<gtk::ApplicationWindow>,
link_target: Rc<RefCell<Option<String>>>,
anchor: Rc<Cell<(i32, i32)>>,
keep_open: Rc<Cell<bool>>,
keyboard_modal: Rc<Cell<bool>>,
session_active: Rc<Cell<bool>>,
reopen_pending: Rc<Cell<bool>>,
}
impl TerminalContextMenu {
fn show_at(
&self,
terminal: &vte::Terminal,
x: f64,
y: f64,
resolve_link: bool,
keyboard_modal: bool,
) {
let Some(popover) = self.popover.upgrade() else {
return;
};
if !popover.is_visible() {
popover.set_autohide(keyboard_modal);
}
self.keyboard_modal.set(popover.is_autohide());
let link = resolve_link
.then(|| crate::terminal::terminal_uri_at(terminal, x, y))
.flatten();
*self.link_target.borrow_mut() = link;
self.sync_action_state(terminal);
let anchor = (x.round() as i32, y.round() as i32);
self.anchor.set(anchor);
self.session_active.set(true);
self.reopen_pending.set(false);
popover.set_pointing_to(Some(&gdk::Rectangle::new(anchor.0, anchor.1, 1, 1)));
if popover.is_visible() {
popover.present();
} else {
popover.popup();
}
}
fn show_for_keyboard(&self, terminal: &vte::Terminal) {
self.show_at(
terminal,
f64::from(terminal.width()) / 2.0,
f64::from(terminal.height()) / 2.0,
false,
true,
);
if let Some(popover) = self.popover.upgrade() {
let _ = popover.grab_focus();
}
}
fn sync_action_state(&self, terminal: &vte::Terminal) {
let Some(window) = self.window.upgrade() else {
return;
};
let has_link = self.link_target.borrow().is_some();
set_window_action_enabled(&window, "open-link", has_link);
set_window_action_enabled(&window, "copy-link", has_link);
set_window_action_enabled(&window, "paste-context", true);
let has_selection = terminal.has_selection();
set_window_action_enabled(&window, "copy", has_selection);
set_window_action_enabled(&window, "copy-html", has_selection);
}
fn is_visible(&self) -> bool {
self.popover
.upgrade()
.is_some_and(|popover| popover.is_visible())
}
fn popdown(&self) {
self.deactivate();
if let Some(popover) = self.popover.upgrade() {
popover.popdown();
}
}
fn deactivate(&self) {
self.session_active.set(false);
self.reopen_pending.set(false);
self.keyboard_modal.set(false);
self.link_target.borrow_mut().take();
if let Some(window) = self.window.upgrade() {
set_window_action_enabled(&window, "open-link", false);
set_window_action_enabled(&window, "copy-link", false);
set_window_action_enabled(&window, "paste-context", false);
}
}
fn reopen_after_action(&self) {
if !self.keep_open.get() || !self.session_active.get() {
return;
}
self.reopen_pending.set(true);
}
fn handle_closed(&self) {
let should_reopen =
self.reopen_pending.replace(false) && self.keep_open.get() && self.session_active.get();
if !should_reopen {
self.deactivate();
return;
}
let popover_weak = self.popover.clone();
let anchor = self.anchor.get();
let keep_open = self.keep_open.clone();
let keyboard_modal = self.keyboard_modal.clone();
let session_active = self.session_active.clone();
glib::idle_add_local_once(move || {
if !keep_open.get() || !session_active.get() {
return;
}
let Some(popover) = popover_weak.upgrade() else {
return;
};
popover.set_autohide(keyboard_modal.get());
popover.set_pointing_to(Some(&gdk::Rectangle::new(anchor.0, anchor.1, 1, 1)));
popover.popup();
if keyboard_modal.get() {
let _ = popover.grab_focus();
}
});
}
}
fn set_window_action_enabled(window: >k::ApplicationWindow, name: &str, enabled: bool) {
if let Some(action) = window
.lookup_action(name)
.and_then(|action| action.downcast::<gio::SimpleAction>().ok())
{
action.set_enabled(enabled);
}
}
fn install_window_actions(
window: >k::ApplicationWindow,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
launch: LaunchConfig,
preferences: >k::Revealer,
search: &SearchOverlay,
) -> TerminalContextMenu {
let terminal = pane.terminal().clone();
let paste_controller = PasteController::new(window, &terminal);
let menu = gio::Menu::new();
let link_target = Rc::new(RefCell::new(None::<String>));
let link_menu = gio::Menu::new();
link_menu.append(Some("Open Link"), Some("win.open-link"));
link_menu.append(Some("Copy Link Address"), Some("win.copy-link"));
menu.append_section(Some("Link"), &link_menu);
let terminal_menu = gio::Menu::new();
terminal_menu.append(Some("New Window"), Some("win.new_window"));
terminal_menu.append(Some("Diagnostics"), Some("win.diagnostics"));
terminal_menu.append(Some("Close Window"), Some("win.close_window"));
menu.append_section(None, &terminal_menu);
let clipboard_menu = gio::Menu::new();
clipboard_menu.append(Some("Copy"), Some("win.copy"));
clipboard_menu.append(Some("Copy as HTML"), Some("win.copy-html"));
clipboard_menu.append(
Some("Copy Working Directory"),
Some("win.copy-working-directory"),
);
clipboard_menu.append(
Some("Open Working Directory"),
Some("win.open-working-directory"),
);
clipboard_menu.append(Some("Paste"), Some("win.paste-context"));
clipboard_menu.append(Some("Select All"), Some("win.select-all"));
clipboard_menu.append(Some("Find..."), Some("win.find"));
menu.append_section(None, &clipboard_menu);
let appearance_menu = gio::Menu::new();
appearance_menu.append(Some("Open Preferences..."), Some("win.customize"));
appearance_menu.append(
Some("Reset to Dark Default"),
Some("win.reset_dark_default"),
);
let background_menu = gio::Menu::new();
background_menu.append(Some("Choose Image..."), Some("win.choose_background"));
background_menu.append(Some("Clear Image"), Some("win.clear_background"));
background_menu.append(Some("Brighten Image"), Some("win.image_brighter"));
background_menu.append(Some("Dim Image"), Some("win.image_dimmer"));
background_menu.append(Some("Reset Background"), Some("win.reset_background"));
appearance_menu.append_submenu(Some("Background"), &background_menu);
let opacity_menu = gio::Menu::new();
opacity_menu.append(Some("Full Opacity"), Some("win.full_opacity"));
opacity_menu.append(
Some("More Transparent"),
Some("win.master_more_transparent"),
);
opacity_menu.append(
Some("Less Transparent"),
Some("win.master_less_transparent"),
);
opacity_menu.append(
Some("Reset Master Opacity"),
Some("win.reset_master_opacity"),
);
let glass_menu = gio::Menu::new();
glass_menu.append(Some("More Glass"), Some("win.more_transparent"));
glass_menu.append(Some("Less Glass"), Some("win.less_transparent"));
glass_menu.append(Some("Reset Terminal Shade"), Some("win.reset_transparency"));
opacity_menu.append_submenu(Some("Terminal Shade"), &glass_menu);
appearance_menu.append_submenu(Some("Opacity"), &opacity_menu);
let cursor_menu = gio::Menu::new();
for preset in CURSOR_PRESETS {
cursor_menu.append(
Some(preset.label),
Some(&format!("win.{}", preset.action_name)),
);
}
cursor_menu.append(Some("Unified Accent"), Some("win.cursor_match_accent"));
cursor_menu.append(Some("Inherit Default"), Some("win.cursor_inherit"));
appearance_menu.append_submenu(Some("Cursor"), &cursor_menu);
let prompt_menu = gio::Menu::new();
for profile in PROMPT_PROFILE_NAMES {
let action_name = format!("prompt_{}", profile.replace('-', "_"));
prompt_menu.append(
Some(&choice_label(profile)),
Some(&format!("win.{action_name}")),
);
add_prompt_action(
window,
&action_name,
profile,
pane.clone(),
settings.clone(),
);
}
appearance_menu.append_submenu(Some("Bash Prompt · This Terminal"), &prompt_menu);
let accent_menu = gio::Menu::new();
accent_menu.append(
Some("Toggle Random Accent"),
Some("win.toggle_random_overlay"),
);
for (label, _, action_name) in ACCENT_PRESETS {
accent_menu.append(Some(label), Some(&format!("win.{action_name}")));
add_accent_action(
window,
action_name,
label,
pane.clone(),
settings.clone(),
config_path.clone(),
);
}
accent_menu.append(Some("Stronger Accent"), Some("win.stronger_accent"));
accent_menu.append(Some("Softer Accent"), Some("win.softer_accent"));
accent_menu.append(Some("Clear Accent"), Some("win.clear_accent"));
appearance_menu.append_submenu(Some("Accent"), &accent_menu);
let theme_menu = gio::Menu::new();
for theme in settings.borrow().selectable_theme_names() {
let item = gio::MenuItem::new(Some(&choice_label(&theme)), None);
item.set_action_and_target_value(Some("win.theme"), Some(&theme.to_variant()));
theme_menu.append_item(&item);
}
add_theme_action(window, pane.clone(), settings.clone(), config_path.clone());
appearance_menu.append_submenu(Some("Theme"), &theme_menu);
menu.append_section(Some("Appearance"), &appearance_menu);
let menu_controls = gio::Menu::new();
menu_controls.append(
Some("Keep Open Between Actions"),
Some("win.context-menu-keep-open"),
);
menu_controls.append(Some("Close Menu"), Some("win.close-context-menu"));
menu.append_section(None, &menu_controls);
let open_link = gio::SimpleAction::new("open-link", None);
open_link.set_enabled(false);
open_link.connect_activate({
let link_target = link_target.clone();
move |_, _| {
let uri = link_target.borrow().clone();
if let Some(uri) = uri {
gio::AppInfo::launch_default_for_uri_async(
&uri,
None::<&gio::AppLaunchContext>,
None::<&gio::Cancellable>,
|result| {
if let Err(error) = result {
eprintln!("could not open link: {error}");
}
},
);
}
}
});
window.add_action(&open_link);
let copy_link = gio::SimpleAction::new("copy-link", None);
copy_link.set_enabled(false);
copy_link.connect_activate({
let link_target = link_target.clone();
let terminal = terminal.downgrade();
move |_, _| {
let (Some(terminal), Some(uri)) = (terminal.upgrade(), link_target.borrow().clone())
else {
return;
};
terminal.display().clipboard().set_text(&uri);
}
});
window.add_action(©_link);
let copy = gio::SimpleAction::new("copy", None);
copy.set_enabled(terminal.has_selection());
let terminal_for_copy = terminal.clone();
copy.connect_activate(move |_, _| {
terminal_for_copy.copy_clipboard_format(vte::Format::Text);
});
window.add_action(©);
let copy_html = gio::SimpleAction::new("copy-html", None);
copy_html.set_enabled(terminal.has_selection());
let terminal_for_copy_html = terminal.clone();
copy_html.connect_activate(move |_, _| {
terminal_for_copy_html.copy_clipboard_format(vte::Format::Html);
});
window.add_action(©_html);
let copy_working_directory = gio::SimpleAction::new("copy-working-directory", None);
let terminal_for_copy_directory = terminal.clone();
let launch_directory_for_copy = launch.working_directory.clone();
let process_directory_for_copy = std::env::current_dir().ok();
copy_working_directory.connect_activate(move |_, _| {
let directory = preferred_working_directory(
terminal_current_working_directory(&terminal_for_copy_directory),
launch_directory_for_copy.clone(),
process_directory_for_copy.clone(),
);
if let Some(directory) = directory {
terminal_for_copy_directory
.display()
.clipboard()
.set_text(&directory.to_string_lossy());
}
});
window.add_action(©_working_directory);
let open_working_directory = gio::SimpleAction::new("open-working-directory", None);
let terminal_for_open_directory = terminal.clone();
let launch_directory_for_open = launch.working_directory.clone();
let process_directory_for_open = std::env::current_dir().ok();
open_working_directory.connect_activate(move |_, _| {
let directory = preferred_working_directory(
terminal_current_working_directory(&terminal_for_open_directory),
launch_directory_for_open.clone(),
process_directory_for_open.clone(),
);
let Some(directory) = directory else {
eprintln!("working directory is unavailable");
return;
};
let uri = local_path_uri(&directory);
if let Err(error) =
gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>)
{
eprintln!("could not open working directory: {error}");
}
});
window.add_action(&open_working_directory);
let paste = gio::SimpleAction::new("paste", None);
let paste_controller_for_keyboard = paste_controller.clone();
paste.connect_activate(move |_, _| {
paste_controller_for_keyboard.request();
});
window.add_action(&paste);
let paste_context = gio::SimpleAction::new("paste-context", None);
paste_context.set_enabled(false);
let paste_controller_for_context = paste_controller;
paste_context.connect_activate(move |_, _| {
paste_controller_for_context.request_from_context_menu();
});
window.add_action(&paste_context);
let select_all = gio::SimpleAction::new("select-all", None);
let terminal_for_select_all = terminal.clone();
select_all.connect_activate(move |_, _| {
terminal_for_select_all.select_all();
});
window.add_action(&select_all);
let find = gio::SimpleAction::new("find", None);
let search_for_find = search.downgrade();
let preferences_for_find = preferences.downgrade();
let terminal_for_find = terminal.downgrade();
find.connect_activate(move |_, _| {
let (Some(search), Some(preferences), Some(terminal)) = (
search_for_find.upgrade(),
preferences_for_find.upgrade(),
terminal_for_find.upgrade(),
) else {
return;
};
open_search(&search, &preferences, &terminal);
});
window.add_action(&find);
let new_window = gio::SimpleAction::new("new_window", None);
let window_for_new_window = window.downgrade();
let terminal_for_new_window = terminal.clone();
let launch_for_new_window = launch.clone();
let settings_for_new_window = settings.clone();
let config_path_for_new_window = config_path.clone();
new_window.connect_activate(move |_, _| {
if let Some(window_for_new_window) = window_for_new_window.upgrade() {
open_new_terminal_window(
&window_for_new_window,
&terminal_for_new_window,
&launch_for_new_window,
&settings_for_new_window,
config_path_for_new_window.clone(),
);
}
});
window.add_action(&new_window);
let diagnostics = gio::SimpleAction::new("diagnostics", None);
let window_for_diagnostics = window.downgrade();
let pane_for_diagnostics = pane.clone();
let settings_for_diagnostics = settings.clone();
let config_path_for_diagnostics = config_path.clone();
diagnostics.connect_activate(move |_, _| {
let active_windows = window_for_diagnostics
.upgrade()
.and_then(|window| window.application())
.map(|app| app.windows().len())
.unwrap_or(0);
let settings = settings_for_diagnostics.borrow();
pane_for_diagnostics.feed_text(&diagnostics_text(
&settings,
config_path_for_diagnostics.as_deref(),
active_windows,
pane_for_diagnostics.terminal().enables_sixel(),
pane_for_diagnostics.has_resolved_background_image(),
));
});
window.add_action(&diagnostics);
let close_window = gio::SimpleAction::new("close_window", None);
let window_for_close = window.downgrade();
close_window.connect_activate(move |_, _| {
if let Some(window_for_close) = window_for_close.upgrade() {
window_for_close.close();
}
});
window.add_action(&close_window);
let customize = gio::SimpleAction::new("customize", None);
let preferences_for_customize = preferences.downgrade();
let search_for_customize = search.downgrade();
let terminal_for_customize = terminal.downgrade();
customize.connect_activate(move |_, _| {
let (Some(preferences), Some(search), Some(terminal)) = (
preferences_for_customize.upgrade(),
search_for_customize.upgrade(),
terminal_for_customize.upgrade(),
) else {
return;
};
close_search(&search, &terminal, false);
if !toggle_revealer(&preferences) {
terminal.grab_focus();
}
});
window.add_action(&customize);
add_app_action(
window,
"reset_dark_default",
pane.clone(),
settings.clone(),
config_path.clone(),
reset_dark_default_settings,
);
add_app_action(
window,
"full_opacity",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| {
next.window.opacity = 1.0;
next.terminal.background.terminal_opacity = 1.0;
Ok(())
},
);
add_app_action(
window,
"master_more_transparent",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| {
next.window.opacity = (next.window.opacity - 0.05).clamp(0.2, 1.0);
Ok(())
},
);
add_app_action(
window,
"master_less_transparent",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| {
next.window.opacity = (next.window.opacity + 0.05).clamp(0.2, 1.0);
Ok(())
},
);
add_app_action(
window,
"reset_master_opacity",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| {
next.window.opacity = 1.0;
Ok(())
},
);
let choose_background = gio::SimpleAction::new("choose_background", None);
let window_for_background = window.downgrade();
let pane_for_background = pane.clone();
let settings_for_background = settings.clone();
let config_path_for_background = config_path.clone();
choose_background.connect_activate(move |_, _| {
let Some(window_for_background) = window_for_background.upgrade() else {
return;
};
open_background_image_dialog(&window_for_background, {
let pane_for_background = pane_for_background.clone();
let settings_for_background = settings_for_background.clone();
let config_path_for_background = config_path_for_background.clone();
move |path| {
let result = apply_terminal_settings(
&pane_for_background,
&settings_for_background,
config_path_for_background.as_deref(),
move |next| {
next.terminal.background.image = Some(path);
if next.terminal.background.terminal_opacity
> DEFAULT_IMAGE_TERMINAL_OPACITY
{
next.terminal.background.terminal_opacity =
DEFAULT_IMAGE_TERMINAL_OPACITY;
}
if next.terminal.background.image_opacity <= 0.0 {
next.terminal.background.image_opacity = DEFAULT_IMAGE_OPACITY;
}
Ok(())
},
);
report_action_error(result);
}
});
});
window.add_action(&choose_background);
let clear_background = gio::SimpleAction::new("clear_background", None);
let pane_for_clear = pane.clone();
let settings_for_clear = settings.clone();
let config_path_for_clear = config_path.clone();
clear_background.connect_activate(move |_, _| {
let result = apply_terminal_settings_deferred(
&pane_for_clear,
&settings_for_clear,
config_path_for_clear.as_deref(),
|next| {
next.terminal.background.image = None;
Ok(())
},
);
report_action_error(result);
});
window.add_action(&clear_background);
add_terminal_action(
window,
"image_brighter",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| {
next.terminal.background.image_opacity =
(next.terminal.background.image_opacity + 0.08).clamp(0.0, 1.0);
Ok(())
},
);
add_terminal_action(
window,
"image_dimmer",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| {
next.terminal.background.image_opacity =
(next.terminal.background.image_opacity - 0.08).clamp(0.0, 1.0);
Ok(())
},
);
add_terminal_action(
window,
"reset_background",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| {
next.terminal.background.image = None;
next.terminal.background.image_opacity = DEFAULT_IMAGE_OPACITY;
next.terminal.background.terminal_opacity = DEFAULT_TERMINAL_OPACITY;
next.terminal.background.overlay_color = None;
next.terminal.background.overlay_opacity = DEFAULT_OVERLAY_OPACITY;
next.terminal.background.random_overlay = true;
Ok(())
},
);
let more_transparent = gio::SimpleAction::new("more_transparent", None);
let pane_for_more_transparent = pane.clone();
let settings_for_more_transparent = settings.clone();
let config_path_for_more_transparent = config_path.clone();
more_transparent.connect_activate(move |_, _| {
let result = apply_terminal_settings_deferred(
&pane_for_more_transparent,
&settings_for_more_transparent,
config_path_for_more_transparent.as_deref(),
|next| {
next.terminal.background.terminal_opacity =
(next.terminal.background.terminal_opacity - 0.08).clamp(0.0, 1.0);
Ok(())
},
);
report_action_error(result);
});
window.add_action(&more_transparent);
let less_transparent = gio::SimpleAction::new("less_transparent", None);
let pane_for_less_transparent = pane.clone();
let settings_for_less_transparent = settings.clone();
let config_path_for_less_transparent = config_path.clone();
less_transparent.connect_activate(move |_, _| {
let result = apply_terminal_settings_deferred(
&pane_for_less_transparent,
&settings_for_less_transparent,
config_path_for_less_transparent.as_deref(),
|next| {
next.terminal.background.terminal_opacity =
(next.terminal.background.terminal_opacity + 0.08).clamp(0.0, 1.0);
Ok(())
},
);
report_action_error(result);
});
window.add_action(&less_transparent);
let reset_transparency = gio::SimpleAction::new("reset_transparency", None);
let pane_for_reset_transparency = pane.clone();
let settings_for_reset_transparency = settings.clone();
let config_path_for_reset_transparency = config_path.clone();
reset_transparency.connect_activate(move |_, _| {
let result = apply_terminal_settings_deferred(
&pane_for_reset_transparency,
&settings_for_reset_transparency,
config_path_for_reset_transparency.as_deref(),
|next| {
next.terminal.background.terminal_opacity =
if next.terminal.background.image.is_some() {
DEFAULT_IMAGE_TERMINAL_OPACITY
} else {
DEFAULT_TERMINAL_OPACITY
};
Ok(())
},
);
report_action_error(result);
});
window.add_action(&reset_transparency);
let toggle_random_overlay = gio::SimpleAction::new("toggle_random_overlay", None);
let pane_for_random = pane.clone();
let settings_for_random = settings.clone();
let config_path_for_random = config_path.clone();
toggle_random_overlay.connect_activate(move |_, _| {
let result = apply_terminal_settings_deferred(
&pane_for_random,
&settings_for_random,
config_path_for_random.as_deref(),
|next| {
toggle_random_overlay_settings(next);
Ok(())
},
);
report_action_error(result);
});
window.add_action(&toggle_random_overlay);
add_terminal_action(
window,
"stronger_accent",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| {
if next.terminal.background.overlay_color.is_none()
&& !next.terminal.background.random_overlay
{
next.terminal.background.overlay_color =
Some(TerminalTheme::parse_color("#7c3aed")?);
}
next.terminal.background.overlay_opacity =
(next.terminal.background.overlay_opacity + 0.05).clamp(0.0, 0.65);
Ok(())
},
);
add_terminal_action(
window,
"softer_accent",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| {
soften_accent_settings(next);
Ok(())
},
);
add_terminal_action(
window,
"clear_accent",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| {
clear_accent_settings(next);
Ok(())
},
);
for preset in CURSOR_PRESETS {
let background = preset.background;
let foreground = preset.foreground;
add_terminal_action(
window,
preset.action_name,
pane.clone(),
settings.clone(),
config_path.clone(),
move |next| set_cursor_colors(next, background, foreground),
);
}
add_terminal_action(
window,
"cursor_match_accent",
pane.clone(),
settings.clone(),
config_path.clone(),
match_cursor_to_overlay_settings,
);
add_terminal_action(
window,
"cursor_inherit",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| set_cursor_colors(next, "", ""),
);
let context_menu = install_terminal_context_menu(window, &terminal, &menu, link_target);
let keep_open =
gio::SimpleAction::new_stateful("context-menu-keep-open", None, &true.to_variant());
keep_open.connect_activate({
let context_menu = context_menu.clone();
move |action, _| {
let enabled = !action
.state()
.and_then(|state| state.get::<bool>())
.unwrap_or(true);
action.set_state(&enabled.to_variant());
context_menu.keep_open.set(enabled);
if !enabled {
context_menu.popdown();
}
}
});
window.add_action(&keep_open);
let close_context_menu = gio::SimpleAction::new("close-context-menu", None);
close_context_menu.connect_activate({
let context_menu = context_menu.clone();
move |_, _| context_menu.popdown()
});
window.add_action(&close_context_menu);
install_context_menu_action_policies(window, &context_menu);
context_menu
}
fn install_terminal_context_menu(
window: >k::ApplicationWindow,
terminal: &vte::Terminal,
menu: &gio::Menu,
link_target: Rc<RefCell<Option<String>>>,
) -> TerminalContextMenu {
let popover = gtk::PopoverMenu::from_model(Some(menu));
popover.set_has_arrow(false);
popover.set_autohide(false);
popover.set_cascade_popdown(false);
popover.set_parent(terminal);
let context_menu = TerminalContextMenu {
popover: popover.downgrade(),
window: window.downgrade(),
link_target,
anchor: Rc::new(Cell::new((0, 0))),
keep_open: Rc::new(Cell::new(true)),
keyboard_modal: Rc::new(Cell::new(false)),
session_active: Rc::new(Cell::new(false)),
reopen_pending: Rc::new(Cell::new(false)),
};
popover.connect_closed({
let context_menu = context_menu.clone();
move |_| context_menu.handle_closed()
});
terminal.connect_selection_changed({
let context_menu = context_menu.clone();
move |terminal| {
if let Some(window) = context_menu.window.upgrade() {
let has_selection = terminal.has_selection();
set_window_action_enabled(&window, "copy", has_selection);
set_window_action_enabled(&window, "copy-html", has_selection);
}
if context_menu.session_active.get() {
context_menu.sync_action_state(terminal);
}
}
});
window.connect_notify_local(Some("is-active"), {
let context_menu = context_menu.clone();
move |window, _| {
if !window.is_active() {
context_menu.popdown();
}
}
});
terminal.connect_destroy({
let popover = popover.downgrade();
move |_| {
if let Some(popover) = popover.upgrade() {
popover.unparent();
}
}
});
let click = gtk::GestureClick::new();
click.set_button(gtk::gdk::BUTTON_SECONDARY);
click.set_propagation_phase(gtk::PropagationPhase::Capture);
click.connect_pressed({
let context_menu = context_menu.clone();
let terminal = terminal.downgrade();
move |gesture, _, x, y| {
let Some(terminal) = terminal.upgrade() else {
return;
};
context_menu.show_at(&terminal, x, y, true, false);
gesture.set_state(gtk::EventSequenceState::Claimed);
}
});
terminal.add_controller(click);
context_menu
}
fn install_context_menu_action_policies(
window: >k::ApplicationWindow,
context_menu: &TerminalContextMenu,
) {
for name in context_menu_repeatable_actions() {
let Some(action) = window
.lookup_action(name)
.and_then(|action| action.downcast::<gio::SimpleAction>().ok())
else {
continue;
};
action.connect_activate({
let context_menu = context_menu.clone();
move |_, _| {
if context_menu.keep_open.get() {
context_menu.reopen_after_action();
} else {
context_menu.popdown();
}
}
});
}
for name in context_menu_one_shot_actions() {
let Some(action) = window
.lookup_action(name)
.and_then(|action| action.downcast::<gio::SimpleAction>().ok())
else {
continue;
};
action.connect_activate({
let context_menu = context_menu.clone();
move |_, _| context_menu.popdown()
});
}
}
fn context_menu_repeatable_actions() -> Vec<&'static str> {
let mut actions = vec![
"reset_dark_default",
"full_opacity",
"master_more_transparent",
"master_less_transparent",
"reset_master_opacity",
"clear_background",
"image_brighter",
"image_dimmer",
"reset_background",
"more_transparent",
"less_transparent",
"reset_transparency",
"toggle_random_overlay",
"stronger_accent",
"softer_accent",
"clear_accent",
"cursor_match_accent",
"cursor_inherit",
"theme",
];
actions.extend(CURSOR_PRESETS.iter().map(|preset| preset.action_name));
actions.extend(ACCENT_PRESETS.iter().map(|(_, _, action)| *action));
actions.extend(
PROMPT_PROFILE_NAMES
.iter()
.copied()
.map(|profile| match profile {
"shell-default" => "prompt_shell_default",
"lightbar" => "prompt_lightbar",
"timebar" => "prompt_timebar",
"fibonnaci" => "prompt_fibonnaci",
"fano-plane" => "prompt_fano_plane",
"alice-bob" => "prompt_alice_bob",
"akira" => "prompt_akira",
_ => unreachable!("prompt profile names are exhaustive"),
}),
);
actions
}
fn context_menu_one_shot_actions() -> &'static [&'static str] {
&[
"open-link",
"copy-link",
"new_window",
"diagnostics",
"close_window",
"copy",
"copy-html",
"copy-working-directory",
"open-working-directory",
"paste-context",
"select-all",
"find",
"customize",
"choose_background",
]
}
fn add_terminal_action(
window: >k::ApplicationWindow,
action_name: &str,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
update: impl Fn(&mut AppSettings) -> Result<(), String> + 'static,
) {
let action = gio::SimpleAction::new(action_name, None);
action.connect_activate(move |_, _| {
let result =
apply_terminal_settings_deferred(&pane, &settings, config_path.as_deref(), |next| {
update(next)
});
report_action_error(result);
});
window.add_action(&action);
}
fn add_app_action(
window: >k::ApplicationWindow,
action_name: &str,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
update: impl Fn(&mut AppSettings) -> Result<(), String> + 'static,
) {
let action = gio::SimpleAction::new(action_name, None);
let window_for_action = window.downgrade();
action.connect_activate(move |_, _| {
let Some(window) = window_for_action.upgrade() else {
return;
};
let result = apply_app_settings_deferred(
&window,
&pane,
&settings,
config_path.as_deref(),
|next| update(next),
);
report_action_error(result);
});
window.add_action(&action);
}
fn add_accent_action(
window: >k::ApplicationWindow,
action_name: &str,
label: &str,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
) {
let accent = ACCENT_PRESETS
.iter()
.find(|(preset_label, _, _)| *preset_label == label)
.map(|(_, color, _)| *color)
.unwrap_or("#7c3aed");
let action = gio::SimpleAction::new(action_name, None);
action.connect_activate(move |_, _| {
let result =
apply_terminal_settings_deferred(&pane, &settings, config_path.as_deref(), |next| {
next.terminal.background.overlay_color = Some(TerminalTheme::parse_color(accent)?);
next.terminal.background.random_overlay = false;
if next.terminal.background.overlay_opacity <= 0.0 {
next.terminal.background.overlay_opacity = DEFAULT_OVERLAY_OPACITY;
}
Ok(())
});
report_action_error(result);
});
window.add_action(&action);
}
fn add_theme_action(
window: >k::ApplicationWindow,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
) {
let action = gio::SimpleAction::new("theme", Some(&String::static_variant_type()));
action.connect_activate(move |_, parameter| {
let Some(theme_name) = parameter.and_then(glib::Variant::str) else {
return;
};
let result =
apply_terminal_settings_deferred(&pane, &settings, config_path.as_deref(), |next| {
next.select_theme(theme_name)
});
report_action_error(result);
});
window.add_action(&action);
}
fn add_prompt_action(
window: >k::ApplicationWindow,
action_name: &str,
profile_name: &str,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
) {
let action = gio::SimpleAction::new(action_name, None);
let profile_name = profile_name.to_string();
action.connect_activate(move |_, _| {
let result = apply_prompt_settings(&pane, &settings, &profile_name);
report_action_error(result);
});
window.add_action(&action);
}
fn set_cursor_colors(
next: &mut AppSettings,
background: &str,
foreground: &str,
) -> Result<(), String> {
next.set_theme_cursor_overrides(
cursor_override_value(background),
cursor_override_value(foreground),
)?;
next.terminal.set_unified_accent(false);
Ok(())
}
fn cursor_override_value(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn cursor_selection_changed(
current_background: &str,
current_foreground: &str,
next_background: &str,
next_foreground: &str,
unified_accent: bool,
legacy_cursor_match: bool,
) -> bool {
current_background != next_background
|| current_foreground != next_foreground
|| unified_accent
|| legacy_cursor_match
}
fn apply_unified_accent_preference(
next: &mut AppSettings,
current: &AppSettings,
enabled: bool,
cursor_touched: bool,
) {
if enabled {
next.terminal.set_unified_accent(true);
} else {
next.terminal.unified_accent = false;
if current.terminal.unified_accent || cursor_touched {
next.terminal.cursor_match_overlay = false;
}
}
}
fn toggle_random_overlay_settings(next: &mut AppSettings) {
next.terminal.background.random_overlay = !next.terminal.background.random_overlay;
if next.terminal.background.random_overlay && next.terminal.background.overlay_opacity <= 0.0 {
next.terminal.background.overlay_opacity = DEFAULT_OVERLAY_OPACITY;
}
if !next.terminal.background.random_overlay
&& next.terminal.background.overlay_color.is_none()
&& next.terminal.unified_accent
{
next.terminal.set_unified_accent(false);
}
}
fn soften_accent_settings(next: &mut AppSettings) {
next.terminal.background.overlay_opacity =
(next.terminal.background.overlay_opacity - 0.05).clamp(0.0, 0.65);
if next.terminal.background.overlay_opacity <= 0.0 && next.terminal.unified_accent {
next.terminal.set_unified_accent(false);
}
}
fn clear_accent_settings(next: &mut AppSettings) {
if next.terminal.unified_accent {
next.terminal.set_unified_accent(false);
}
next.terminal.background.overlay_color = None;
next.terminal.background.random_overlay = false;
}
fn match_cursor_to_overlay_settings(next: &mut AppSettings) -> Result<(), String> {
next.terminal.set_unified_accent(true);
Ok(())
}
fn reset_dark_default_settings(next: &mut AppSettings) -> Result<(), String> {
next.window.decorated = false;
next.window.opacity = 1.0;
next.select_theme("default")?;
next.terminal.set_unified_accent(false);
next.terminal.prompt = PromptConfig::default();
next.terminal.background.image = None;
next.terminal.background.image_opacity = DEFAULT_IMAGE_OPACITY;
next.terminal.background.terminal_opacity = DEFAULT_TERMINAL_OPACITY;
next.terminal.background.overlay_color = None;
next.terminal.background.overlay_opacity = 0.0;
next.terminal.background.random_overlay = true;
Ok(())
}
fn reset_original_look_settings(next: &mut AppSettings) -> Result<(), String> {
let prompt = next.terminal.prompt.clone();
reset_dark_default_settings(next)?;
next.terminal.prompt = prompt;
next.terminal.font = crate::terminal::TerminalConfig::default().font;
next.restore_bundled_background()
}
fn apply_terminal_settings(
pane: &TerminalPane,
settings: &Rc<RefCell<AppSettings>>,
config_path: Option<&Path>,
update: impl FnOnce(&mut AppSettings) -> Result<(), String>,
) -> Result<(), String> {
let mut next = {
let current = settings.borrow();
latest_config_settings(config_path, ¤t)
};
let baseline = next.clone();
update(&mut next)?;
if let Some(path) = config_path {
next = persist_config_now(path, &baseline, &next)?;
}
pane.apply_config(&next.terminal)?;
*settings.borrow_mut() = next;
Ok(())
}
fn apply_terminal_settings_deferred(
pane: &TerminalPane,
settings: &Rc<RefCell<AppSettings>>,
config_path: Option<&Path>,
update: impl FnOnce(&mut AppSettings) -> Result<(), String>,
) -> Result<(), String> {
let mut next = {
let current = settings.borrow();
latest_config_settings(config_path, ¤t)
};
let baseline = next.clone();
update(&mut next)?;
pane.apply_config(&next.terminal)?;
*settings.borrow_mut() = next.clone();
if let Some(path) = config_path {
queue_config_persist(path, &baseline, next)?;
}
Ok(())
}
fn apply_prompt_settings(
pane: &TerminalPane,
settings: &Rc<RefCell<AppSettings>>,
profile_name: &str,
) -> Result<(), String> {
let prompt = PromptConfig::named(profile_name)?;
let mut next = settings.borrow().clone();
next.terminal.prompt = prompt.clone();
pane.apply_prompt_profile(&prompt)?;
*settings.borrow_mut() = next;
Ok(())
}
fn apply_app_settings(
window: >k::ApplicationWindow,
pane: &TerminalPane,
settings: &Rc<RefCell<AppSettings>>,
config_path: Option<&Path>,
update: impl FnOnce(&mut AppSettings) -> Result<(), String>,
) -> Result<(), String> {
let mut next = {
let current = settings.borrow();
latest_config_settings(config_path, ¤t)
};
let baseline = next.clone();
update(&mut next)?;
if let Some(path) = config_path {
next = persist_config_now(path, &baseline, &next)?;
}
apply_live_app_settings(window, pane, &next)?;
*settings.borrow_mut() = next;
Ok(())
}
fn apply_app_settings_deferred(
window: >k::ApplicationWindow,
pane: &TerminalPane,
settings: &Rc<RefCell<AppSettings>>,
config_path: Option<&Path>,
update: impl FnOnce(&mut AppSettings) -> Result<(), String>,
) -> Result<(), String> {
let mut next = {
let current = settings.borrow();
latest_config_settings(config_path, ¤t)
};
let baseline = next.clone();
update(&mut next)?;
apply_live_app_settings(window, pane, &next)?;
*settings.borrow_mut() = next.clone();
if let Some(path) = config_path {
queue_config_persist(path, &baseline, next)?;
}
Ok(())
}
fn report_action_error(result: Result<(), String>) {
if let Err(message) = result {
eprintln!("{message}");
}
}
fn diagnostics_text(
settings: &AppSettings,
config_path: Option<&Path>,
active_windows: usize,
sixel_enabled: bool,
has_resolved_background_image: bool,
) -> String {
let image = settings
.terminal
.background
.image
.as_ref()
.map(|path| terminal_safe_diagnostic_value(&path.display().to_string()))
.unwrap_or_else(|| "none".to_string());
let overlay = settings
.terminal
.background
.overlay_color
.as_ref()
.map(color_to_hex)
.unwrap_or_else(|| "none".to_string());
let config = config_path
.map(|path| terminal_safe_diagnostic_value(&path.display().to_string()))
.unwrap_or_else(|| "session-only".to_string());
let rss = process_rss_kib()
.map(|rss| format!("{rss} KiB"))
.unwrap_or_else(|| "unknown".to_string());
format!(
"\r\nLios diagnostics\r\n version: {}\r\n pid: {}\r\n rss: {}\r\n active_windows: {}\r\n config: {}\r\n renderer: {}\r\n terminal_identity: TERM={}, COLORTERM={}, TERM_PROGRAM={}, TERM_PROGRAM_VERSION={}\r\n image_protocol: {}\r\n window_opacity: {:.2}\r\n theme: {}\r\n prompt_profile: {}\r\n scrollback_lines: {} / {} max\r\n background_image: {}\r\n image_opacity: {:.2}\r\n terminal_opacity: {:.2}\r\n effective_terminal_opacity: {:.2}\r\n overlay: {} @ {:.2}\r\n random_overlay: {}\r\n",
env!("CARGO_PKG_VERSION"),
std::process::id(),
rss,
active_windows,
config,
settings.window.renderer.as_config(),
CHILD_TERM,
CHILD_COLORTERM,
CHILD_TERM_PROGRAM,
env!("CARGO_PKG_VERSION"),
if sixel_enabled { "sixel" } else { "none" },
settings.window.opacity,
settings.terminal.theme_name,
settings.terminal.prompt.profile_name,
settings.terminal.scrollback_lines,
MAX_SCROLLBACK_LINES,
image,
settings.terminal.background.image_opacity,
settings.terminal.background.terminal_opacity,
effective_terminal_opacity(&settings.terminal.background, has_resolved_background_image,),
overlay,
settings.terminal.background.overlay_opacity,
settings.terminal.background.random_overlay,
)
}
fn terminal_safe_diagnostic_value(value: &str) -> String {
let mut sanitized = String::new();
for character in value.chars().take(1_024) {
if character.is_control() {
sanitized.extend(character.escape_default());
} else {
sanitized.push(character);
}
}
sanitized
}
fn process_rss_kib() -> Option<u64> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
status.lines().find_map(|line| {
let value = line.strip_prefix("VmRSS:")?.trim();
value.split_whitespace().next()?.parse::<u64>().ok()
})
}
struct KeyboardShortcutContext {
launch: LaunchConfig,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
context_menu: TerminalContextMenu,
}
fn install_keyboard_shortcuts(
window: >k::ApplicationWindow,
terminal: &vte::Terminal,
preferences: >k::Revealer,
search: &SearchOverlay,
context: KeyboardShortcutContext,
) {
let zoom = Rc::new(Cell::new(1.0));
let controller = gtk::EventControllerKey::new();
controller.set_propagation_phase(gtk::PropagationPhase::Capture);
let terminal_for_keys = terminal.downgrade();
let zoom_for_keys = zoom.clone();
let preferences_for_keys = preferences.downgrade();
let search_for_keys = search.downgrade();
let window_for_keys = window.downgrade();
let settings_for_keys = context.settings;
let config_path_for_keys = context.config_path;
let launch_for_keys = context.launch;
let context_menu_for_keys = context.context_menu;
controller.connect_key_pressed(move |_, key, _, state| {
let Some(terminal_for_keys) = terminal_for_keys.upgrade() else {
return glib::Propagation::Proceed;
};
let search_for_keys = search_for_keys.upgrade();
let preferences_for_keys = preferences_for_keys.upgrade();
let modifiers = shortcut_modifiers(state);
if terminal_for_keys.has_focus() && is_context_menu_shortcut(key, state) {
if context_menu_for_keys.is_visible() {
context_menu_for_keys.popdown();
} else {
context_menu_for_keys.show_for_keyboard(&terminal_for_keys);
}
return glib::Propagation::Stop;
}
if modifiers == gdk::ModifierType::SHIFT_MASK
&& matches!(key, gdk::Key::Insert | gdk::Key::KP_Insert)
&& terminal_for_keys.has_focus()
{
if let Some(window) = window_for_keys.upgrade() {
gio::prelude::ActionGroupExt::activate_action(&window, "paste", None);
}
return glib::Propagation::Stop;
}
if key == gdk::Key::Escape && modifiers.is_empty() {
if context_menu_for_keys.is_visible() {
let keyboard_modal = context_menu_for_keys
.popover
.upgrade()
.is_some_and(|popover| popover.is_autohide());
if keyboard_modal {
return glib::Propagation::Proceed;
}
context_menu_for_keys.popdown();
terminal_for_keys.grab_focus();
return glib::Propagation::Stop;
}
match overlay_dismissal(
search_for_keys
.as_ref()
.is_some_and(|search| search.revealer.reveals_child()),
preferences_for_keys
.as_ref()
.is_some_and(gtk::Revealer::reveals_child),
) {
OverlayDismissal::Search => {
if let Some(search) = search_for_keys.as_ref() {
close_search(search, &terminal_for_keys, true);
}
return glib::Propagation::Stop;
}
OverlayDismissal::Preferences => {
if let Some(preferences) = preferences_for_keys.as_ref() {
preferences.set_reveal_child(false);
}
terminal_for_keys.grab_focus();
return glib::Propagation::Stop;
}
OverlayDismissal::None => {}
}
}
let Some(character) = key.to_unicode().map(|ch| ch.to_ascii_lowercase()) else {
return glib::Propagation::Proceed;
};
let control = gdk::ModifierType::CONTROL_MASK;
let control_shift = control | gdk::ModifierType::SHIFT_MASK;
let search_entry_focused = search_for_keys
.as_ref()
.is_some_and(SearchOverlay::entry_has_focus);
if modifiers == control_shift {
match character {
'c' => {
if search_entry_focused || !terminal_for_keys.has_focus() {
return glib::Propagation::Proceed;
}
terminal_for_keys.copy_clipboard_format(vte::Format::Text);
return glib::Propagation::Stop;
}
'v' => {
if !terminal_for_keys.has_focus() {
return glib::Propagation::Proceed;
}
if let Some(window) = window_for_keys.upgrade() {
gio::prelude::ActionGroupExt::activate_action(&window, "paste", None);
}
return glib::Propagation::Stop;
}
'a' => {
if search_entry_focused || !terminal_for_keys.has_focus() {
return glib::Propagation::Proceed;
}
terminal_for_keys.select_all();
return glib::Propagation::Stop;
}
'f' => {
context_menu_for_keys.popdown();
if let (Some(search), Some(preferences)) =
(search_for_keys.as_ref(), preferences_for_keys.as_ref())
{
open_search(search, preferences, &terminal_for_keys);
}
return glib::Propagation::Stop;
}
'n' => {
if let Some(window_for_keys) = window_for_keys.upgrade() {
open_new_terminal_window(
&window_for_keys,
&terminal_for_keys,
&launch_for_keys,
&settings_for_keys,
config_path_for_keys.clone(),
);
}
return glib::Propagation::Stop;
}
'q' => {
if let Some(window_for_keys) = window_for_keys.upgrade() {
window_for_keys.close();
}
return glib::Propagation::Stop;
}
',' | '<' => {
context_menu_for_keys.popdown();
if let Some(search) = search_for_keys.as_ref() {
close_search(search, &terminal_for_keys, false);
}
if let Some(preferences_for_keys) = preferences_for_keys.as_ref() {
if !toggle_revealer(preferences_for_keys) {
terminal_for_keys.grab_focus();
}
}
return glib::Propagation::Stop;
}
_ => {}
}
}
if terminal_for_keys.has_focus()
&& (modifiers == control || (modifiers == control_shift && character == '+'))
{
match character {
'+' | '=' => {
update_zoom(&terminal_for_keys, &zoom_for_keys, 1.1);
return glib::Propagation::Stop;
}
'-' => {
update_zoom(&terminal_for_keys, &zoom_for_keys, 1.0 / 1.1);
return glib::Propagation::Stop;
}
'0' => {
zoom_for_keys.set(1.0);
terminal_for_keys.set_font_scale(1.0);
return glib::Propagation::Stop;
}
_ => {}
}
}
glib::Propagation::Proceed
});
window.add_controller(controller);
}
fn is_context_menu_shortcut(key: gdk::Key, state: gdk::ModifierType) -> bool {
let state = shortcut_modifiers(state);
match key {
gdk::Key::Menu => state.is_empty(),
gdk::Key::F10 => state == gdk::ModifierType::SHIFT_MASK,
_ => false,
}
}
fn shortcut_modifiers(state: gdk::ModifierType) -> gdk::ModifierType {
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)
}
fn open_new_terminal_window(
parent: >k::ApplicationWindow,
terminal: &vte::Terminal,
launch: &LaunchConfig,
settings: &Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
) {
let Some(app) = parent.application() else {
return;
};
let next_launch = next_window_launch(launch, terminal_current_working_directory(terminal));
let next_settings = {
let current = settings.borrow();
next_window_settings(&latest_config_settings(config_path.as_deref(), ¤t))
};
build_window(&app, next_launch, next_settings, config_path);
}
fn terminal_current_working_directory(terminal: &vte::Terminal) -> Option<PathBuf> {
terminal
.current_directory_uri()
.and_then(|uri| path_from_file_uri(uri.as_str()))
.filter(|path| path.is_dir())
}
fn preferred_working_directory(
active: Option<PathBuf>,
launch: Option<PathBuf>,
process: Option<PathBuf>,
) -> Option<PathBuf> {
active.or(launch).or(process)
}
fn path_from_file_uri(uri: &str) -> Option<PathBuf> {
gio::File::for_uri(uri).path()
}
fn local_path_uri(path: &Path) -> String {
gio::File::for_path(path).uri().to_string()
}
fn next_window_launch(launch: &LaunchConfig, current_directory: Option<PathBuf>) -> LaunchConfig {
LaunchConfig {
command: LaunchCommand::DefaultShell,
working_directory: current_directory.or_else(|| launch.working_directory.clone()),
}
}
fn next_window_settings(settings: &AppSettings) -> AppSettings {
let mut next = settings.clone();
next.window.decorated = false;
next
}
fn update_zoom(terminal: &vte::Terminal, zoom: &Cell<f64>, multiplier: f64) {
let next = (zoom.get() * multiplier).clamp(0.5, 2.5);
zoom.set(next);
terminal.set_font_scale(next);
}
fn title_control_or_invisible(character: char) -> bool {
matches!(
character,
'\u{061c}'
| '\u{200b}'
| '\u{200e}'
| '\u{200f}'
| '\u{202a}'..='\u{202e}'
| '\u{2060}'
| '\u{2066}'..='\u{2069}'
| '\u{feff}'
)
}
fn sanitize_title_component(raw: &str, maximum_characters: usize) -> Option<String> {
let mut output = String::new();
let mut output_characters = 0usize;
let mut pending_space = false;
for (input_index, character) in raw.chars().enumerate() {
if input_index >= MAX_TITLE_INPUT_SCALARS {
break;
}
if character.is_whitespace() {
pending_space = !output.is_empty();
continue;
}
if character.is_control() || title_control_or_invisible(character) {
continue;
}
if pending_space {
if output_characters + 1 >= maximum_characters {
break;
}
output.push(' ');
output_characters += 1;
pending_space = false;
}
if output_characters >= maximum_characters {
break;
}
output.push(character);
output_characters += 1;
}
while output.ends_with(' ') {
output.pop();
}
(!output.is_empty()).then_some(output)
}
fn sanitized_config_title(raw: &str) -> String {
sanitize_title_component(raw, MAX_CONFIG_TITLE_CHARS).unwrap_or_else(|| "Lios".to_string())
}
fn sanitized_terminal_title(fallback: &str, raw: &str) -> String {
sanitize_title_component(raw, MAX_TERMINAL_TITLE_CHARS)
.map(|title| format!("{fallback} — {title}"))
.unwrap_or_else(|| fallback.to_string())
}
fn keep_window_title_in_sync(
window: >k::ApplicationWindow,
terminal: &vte::Terminal,
fallback_title: String,
) {
let window_for_title = window.downgrade();
let pending_title = Rc::new(RefCell::new(None::<String>));
let update_scheduled = Rc::new(Cell::new(false));
terminal.connect_window_title_changed(move |terminal| {
let title = terminal_window_title(terminal)
.map(|title| sanitized_terminal_title(&fallback_title, &title))
.unwrap_or_else(|| fallback_title.clone());
*pending_title.borrow_mut() = Some(title);
if update_scheduled.replace(true) {
return;
}
let window_for_title = window_for_title.clone();
let pending_title = pending_title.clone();
let update_scheduled = update_scheduled.clone();
glib::timeout_add_local_once(TITLE_UPDATE_DEBOUNCE, move || {
update_scheduled.set(false);
let Some(window) = window_for_title.upgrade() else {
pending_title.borrow_mut().take();
return;
};
if let Some(title) = pending_title.borrow_mut().take() {
window.set_title(Some(&title));
}
});
});
}
#[allow(deprecated)]
fn terminal_window_title(terminal: &vte::Terminal) -> Option<String> {
terminal.window_title().map(|title| title.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CloseDecision {
Allow,
Prompt,
WaitForDialog,
}
#[derive(Debug, Default)]
struct CloseGuardState {
confirmed: bool,
dialog_open: bool,
}
impl CloseGuardState {
fn request(&mut self, running_process: bool) -> CloseDecision {
if self.confirmed {
self.confirmed = false;
return CloseDecision::Allow;
}
if !running_process {
return CloseDecision::Allow;
}
if self.dialog_open {
return CloseDecision::WaitForDialog;
}
self.dialog_open = true;
CloseDecision::Prompt
}
fn cancel(&mut self) {
self.dialog_open = false;
}
fn confirm(&mut self) {
self.dialog_open = false;
self.confirmed = true;
}
fn child_exited(&mut self) {
self.dialog_open = false;
self.confirmed = true;
}
}
fn install_close_guard(window: >k::ApplicationWindow, pane: &Rc<TerminalPane>) {
let state = Rc::new(RefCell::new(CloseGuardState::default()));
let active_dialog = Rc::new(RefCell::new(None::<glib::WeakRef<gtk::MessageDialog>>));
window.connect_close_request({
let window = window.downgrade();
let pane = Rc::downgrade(pane);
let state = state.clone();
let active_dialog = active_dialog.clone();
move |_| {
let pane = pane.upgrade();
let running_process = pane
.as_ref()
.is_some_and(|pane| pane.has_running_process());
let decision = state.borrow_mut().request(running_process);
match decision {
CloseDecision::Allow => {
if let Some(pane) = pane {
pane.cancel_pending_spawn();
}
glib::Propagation::Proceed
}
CloseDecision::WaitForDialog => glib::Propagation::Stop,
CloseDecision::Prompt => {
let Some(window) = window.upgrade() else {
state.borrow_mut().cancel();
return glib::Propagation::Proceed;
};
let dialog = gtk::MessageDialog::builder()
.transient_for(&window)
.modal(true)
.destroy_with_parent(true)
.message_type(gtk::MessageType::Warning)
.buttons(gtk::ButtonsType::None)
.text("A process is still running")
.secondary_text(
"Closing this terminal will stop its active command and may lose unsaved work.",
)
.build();
dialog.add_button("Keep Running", gtk::ResponseType::Cancel);
dialog.add_button("Close Terminal", gtk::ResponseType::Accept);
dialog.set_default_response(gtk::ResponseType::Cancel);
dialog.connect_response({
let window = window.downgrade();
let state = state.clone();
let active_dialog = active_dialog.clone();
move |dialog, response| {
active_dialog.borrow_mut().take();
dialog.destroy();
if response == gtk::ResponseType::Accept {
state.borrow_mut().confirm();
if let Some(window) = window.upgrade() {
window.close();
}
} else {
state.borrow_mut().cancel();
}
}
});
active_dialog.borrow_mut().replace(dialog.downgrade());
dialog.present();
glib::Propagation::Stop
}
}
}
});
pane.terminal().connect_child_exited({
let window = window.downgrade();
let pane = Rc::downgrade(pane);
let state = state.clone();
let active_dialog = active_dialog.clone();
move |_, _| {
if let Some(pane) = pane.upgrade() {
pane.mark_child_exited();
}
if let Some(dialog) = active_dialog
.borrow_mut()
.take()
.and_then(|dialog| dialog.upgrade())
{
dialog.destroy();
}
state.borrow_mut().child_exited();
if let Some(window) = window.upgrade() {
window.close();
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
// Run explicitly under Xvfb, with a child process that has isolated config
// and data directories. Exercise the real bundled startup appearance.
#[test]
#[ignore = "requires a display; run under xvfb-run with --ignored --test-threads=1"]
fn gui_preferences_layout_and_save() {
if std::env::var_os("LIOS_GUI_TEST_CHILD").as_deref() != Some(std::ffi::OsStr::new("1")) {
let root =
std::env::temp_dir().join(format!("lios-gui-environment-{}", std::process::id()));
std::fs::create_dir(&root).unwrap();
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"app::tests::gui_preferences_layout_and_save",
"--ignored",
"--nocapture",
"--test-threads=1",
])
.env("LIOS_GUI_TEST_CHILD", "1")
.env("HOME", &root)
.env("XDG_CONFIG_HOME", root.join("config"))
.env("XDG_DATA_HOME", root.join("data"))
.env("XDG_CACHE_HOME", root.join("cache"))
.output()
.unwrap();
std::fs::remove_dir_all(root).unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
gtk::init().expect("GTK display for integration test");
install_app_css();
let app = gtk::Application::builder()
.application_id("dev.lios.PreferencesTest")
.flags(gio::ApplicationFlags::NON_UNIQUE)
.build();
app.register(None::<&gio::Cancellable>).unwrap();
let directory =
std::env::temp_dir().join(format!("lios-preferences-test-{}", std::process::id()));
std::fs::create_dir(&directory).unwrap();
let path = directory.join("config.toml");
let initial = AppSettings::load(None, crate::config::ConfigOverrides::default()).unwrap();
assert!(
initial
.terminal
.background
.image
.as_ref()
.is_some_and(|path| path.is_file())
);
initial.persist(&path).unwrap();
let settings = Rc::new(RefCell::new(initial));
let pane = Rc::new(TerminalPane::new(&settings.borrow().terminal));
let window = gtk::ApplicationWindow::builder()
.application(&app)
.default_width(960)
.default_height(700)
.decorated(false)
.build();
let root = gtk::Overlay::new();
root.set_child(Some(pane.widget()));
let preferences =
build_preferences_revealer(&window, pane.clone(), settings.clone(), Some(path.clone()));
preferences.set_halign(gtk::Align::Center);
preferences.set_valign(gtk::Align::Start);
preferences.set_margin_top(10);
root.add_overlay(&preferences);
root.set_clip_overlay(&preferences, true);
window.set_child(Some(&root));
window.present();
wait_gui_until(|| pane.has_resolved_background_image());
spin_gui_for(Duration::from_millis(200));
save_gui_screenshot(&window, "original-startup.png");
preferences.set_reveal_child(true);
spin_gui_for(Duration::from_millis(450));
let drawer = named_widget(&preferences, "preferences-drawer").unwrap();
assert!(
GtkWindowExt::focus(&window).is_some_and(|focus| focus.is_ancestor(&drawer)),
"opening preferences should move focus out of the terminal"
);
let pages = named_widget(&preferences, "preferences-pages")
.unwrap()
.downcast::<gtk::Stack>()
.unwrap();
let apply = named_widget(&preferences, "preferences-apply")
.unwrap()
.downcast::<gtk::Button>()
.unwrap();
let status = named_widget(&preferences, "preferences-status")
.unwrap()
.downcast::<gtk::Label>()
.unwrap();
for (width, height) in [(960, 700), (480, 420), (360, 360)] {
window.set_default_size(width, height);
spin_gui_for(Duration::from_millis(350));
assert_eq!(
(window.width(), window.height()),
(width, height),
"use Xvfb with a screen of at least 960x700"
);
for name in [
"appearance",
"background",
"terminal",
"profiles",
"plugins",
] {
pages.set_visible_child_name(name);
spin_gui_for(Duration::from_millis(180));
let bounds = drawer.compute_bounds(&window).unwrap();
assert!(
bounds.x() >= 0.0 && bounds.y() >= 0.0,
"drawer escapes {width}x{height}: {bounds:?}"
);
assert!(
bounds.x() + bounds.width() <= window.width() as f32,
"drawer too wide at {width}x{height}: {bounds:?}"
);
assert!(
bounds.y() + bounds.height() <= window.height() as f32,
"drawer too tall at {width}x{height}: {bounds:?}"
);
let bounds = apply.compute_bounds(&window).unwrap();
assert!(
bounds.y() + bounds.height() <= window.height() as f32,
"save action clipped"
);
save_gui_screenshot(&window, &format!("preferences-{name}-{width}.png"));
}
}
pages.set_visible_child_name("appearance");
let font = find_entry(&preferences, "Monospace 12").unwrap();
// Simulate another process changing an unrelated setting after the
// preferences controls were populated.
let mut external = settings.borrow().clone();
external.window.opacity = 0.66;
external.persist(&path).unwrap();
font.set_text("Monospace 14");
assert!(apply.is_sensitive());
spin_gui_for(Duration::from_millis(100));
let dirty_bounds = drawer.compute_bounds(&window).unwrap();
assert!(
dirty_bounds.width() <= window.width() as f32,
"unsaved badge must not widen the drawer beyond the window"
);
apply.emit_clicked();
assert_eq!(apply.label().as_deref(), Some("Saving…"));
assert!(!apply.is_sensitive());
wait_gui_until(|| apply.label().as_deref() != Some("Saving…"));
assert_eq!(status.text(), "Preferences saved.");
assert_eq!(settings.borrow().window.opacity, 0.66);
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains("Monospace 14")
);
// The file is replaced with a directory to make a real write fail.
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();
font.set_text("Monospace 15");
apply.emit_clicked();
wait_gui_until(|| apply.label().as_deref() != Some("Saving…"));
assert!(status.text().starts_with("Not saved:"), "{}", status.text());
assert!(apply.is_sensitive(), "failed saves must remain retryable");
assert_eq!(font.text(), "Monospace 15");
let revert = named_widget(&preferences, "preferences-revert")
.unwrap()
.downcast::<gtk::Button>()
.unwrap();
revert.emit_clicked();
assert_eq!(font.text(), "Monospace 14");
assert_eq!(settings.borrow().terminal.font, "Monospace 14");
assert!(!apply.is_sensitive());
assert!(!status.has_css_class("lios-error"));
std::fs::remove_dir(&path).unwrap();
settings.borrow().persist(&path).unwrap();
let live_edit = named_widget(&preferences, "preferences-live-edit")
.unwrap()
.downcast::<gtk::ToggleButton>()
.unwrap();
live_edit.set_active(true);
font.set_text("Monospace 16");
spin_gui_for(Duration::from_millis(100));
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains("Monospace 14"),
"live edits must be debounced"
);
wait_gui_until(|| status.text() == "Preferences saved.");
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains("Monospace 16")
);
assert!(!apply.is_sensitive());
live_edit.set_active(false);
font.set_text("");
let original = named_widget(&preferences, "preferences-original-look")
.unwrap()
.downcast::<gtk::Button>()
.unwrap();
original.emit_clicked();
assert_eq!(
font.text(),
"Monospace 12",
"recovery must also fix an invalid appearance field"
);
assert_eq!(
settings.borrow().terminal.font,
"Monospace 16",
"Original Look must wait for Apply"
);
assert!(apply.is_sensitive());
apply.emit_clicked();
wait_gui_until(|| status.text() == "Preferences saved.");
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains("image = \"@default\"")
);
assert_eq!(settings.borrow().terminal.background.terminal_opacity, 0.5);
assert_eq!(settings.borrow().terminal.background.overlay_opacity, 0.18);
assert!(settings.borrow().terminal.background.random_overlay);
window.destroy();
spin_gui_for(Duration::from_millis(100));
std::fs::remove_dir_all(directory).unwrap();
}
fn save_gui_screenshot(window: >k::ApplicationWindow, filename: &str) {
if let Some(output) = std::env::var_os("LIOS_TEST_SCREENSHOTS") {
let output = PathBuf::from(output);
std::fs::create_dir_all(&output).unwrap();
let paintable = gtk::WidgetPaintable::new(Some(window));
let snapshot = gtk::Snapshot::new();
paintable.snapshot(&snapshot, window.width() as f64, window.height() as f64);
let node = snapshot.to_node().unwrap();
let texture = window.renderer().unwrap().render_texture(&node, None);
texture.save_to_png(output.join(filename)).unwrap();
}
}
fn spin_gui_for(duration: Duration) {
let context = glib::MainContext::default();
let until = std::time::Instant::now() + duration;
while std::time::Instant::now() < until {
for _ in 0..100 {
if !context.pending() {
break;
}
context.iteration(false);
}
std::thread::sleep(Duration::from_millis(5));
}
}
fn wait_gui_until(done: impl Fn() -> bool) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !done() && std::time::Instant::now() < deadline {
spin_gui_for(Duration::from_millis(10));
}
assert!(done(), "timed out waiting for the GTK operation");
}
fn named_widget(root: &impl IsA<gtk::Widget>, name: &str) -> Option<gtk::Widget> {
let root = root.as_ref();
if root.widget_name() == name {
return Some(root.clone());
}
let mut child = root.first_child();
while let Some(widget) = child {
if let Some(found) = named_widget(&widget, name) {
return Some(found);
}
child = widget.next_sibling();
}
None
}
fn find_entry(root: &impl IsA<gtk::Widget>, text: &str) -> Option<gtk::Entry> {
let root = root.as_ref();
if let Some(entry) = root.downcast_ref::<gtk::Entry>() {
if entry.text() == text {
return Some(entry.clone());
}
}
let mut child = root.first_child();
while let Some(widget) = child {
if let Some(found) = find_entry(&widget, text) {
return Some(found);
}
child = widget.next_sibling();
}
None
}
#[test]
fn close_guard_prompts_once_and_confirmation_is_one_shot() {
let mut state = CloseGuardState::default();
assert_eq!(state.request(true), CloseDecision::Prompt);
assert_eq!(state.request(true), CloseDecision::WaitForDialog);
state.confirm();
assert_eq!(state.request(true), CloseDecision::Allow);
assert_eq!(state.request(true), CloseDecision::Prompt);
}
#[test]
fn close_guard_cancel_and_natural_exit_reset_dialog_state() {
let mut state = CloseGuardState::default();
assert_eq!(state.request(false), CloseDecision::Allow);
assert_eq!(state.request(true), CloseDecision::Prompt);
state.cancel();
assert_eq!(state.request(true), CloseDecision::Prompt);
state.child_exited();
assert_eq!(state.request(false), CloseDecision::Allow);
}
#[test]
fn config_cache_ignores_out_of_order_completions() {
let path = Path::new("/tmp/lios-cache-test.toml");
let baseline = AppSettings::default();
let mut first = baseline.clone();
first.window.opacity = 0.5;
let mut second = first.clone();
second.window.opacity = 0.8;
let mut cache = ConfigCache::default();
let old = cache.begin(path, &baseline);
let new = cache.begin(path, &baseline);
cache.complete(path, new, &second);
cache.complete(path, old, &first);
assert_eq!(cache.values[path].1.window.opacity, 0.8);
let next = cache.begin(path, &second);
let _failed = cache.begin(path, &second);
cache.complete(path, next, &first);
assert_eq!(
cache.values[path].1.window.opacity, 0.5,
"a later failed save must not suppress an earlier success"
);
}
#[test]
fn immediate_save_merges_stale_form_with_pending_context_edits() {
let directory =
std::env::temp_dir().join(format!("lios-pending-merge-{}", std::process::id()));
std::fs::create_dir(&directory).unwrap();
let path = directory.join("config.toml");
let baseline = AppSettings::default();
baseline.persist(&path).unwrap();
let mut queued = baseline.clone();
queued.window.opacity = 0.7;
let mut form = baseline.clone();
form.terminal.font = "Monospace 17".into();
let mut pending = HashMap::from([(
path.clone(),
PendingConfig {
baseline: baseline.clone(),
settings: queued,
},
)]);
let (reply, result) = mpsc::sync_channel(1);
process_config_write_message(
Ok(ConfigWriteMessage::Immediate {
path: path.clone(),
baseline,
settings: form,
reply,
}),
&mut pending,
);
let saved = result.recv().unwrap().unwrap();
assert_eq!(saved.window.opacity, 0.7);
assert_eq!(saved.terminal.font, "Monospace 17");
assert!(pending.is_empty());
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn config_writer_coalesces_to_the_latest_snapshot_per_path() {
let path = PathBuf::from("/tmp/lios-coalescing-test.toml");
let mut first = AppSettings::default();
first.window.opacity = 0.8;
let mut latest = first.clone();
latest.window.opacity = 0.4;
let mut pending = HashMap::new();
assert!(matches!(
process_config_write_message(
Ok(ConfigWriteMessage::Debounced {
path: path.clone(),
baseline: AppSettings::default(),
settings: first.clone(),
}),
&mut pending,
),
ConfigWriterState::Continue
));
assert!(matches!(
process_config_write_message(
Ok(ConfigWriteMessage::Debounced {
path: path.clone(),
baseline: first,
settings: latest,
}),
&mut pending,
),
ConfigWriterState::Continue
));
assert_eq!(pending.len(), 1);
assert_eq!(pending[&path].settings.window.opacity, 0.4);
assert_eq!(pending[&path].baseline.window.opacity, 1.0);
}
#[test]
fn rejected_immediate_save_preserves_the_last_committed_snapshot() {
let path = PathBuf::from("/");
let mut committed = AppSettings::default();
committed.window.opacity = 0.8;
let mut rejected = committed.clone();
rejected.window.opacity = 0.2;
let mut pending = HashMap::from([(
path.clone(),
PendingConfig {
baseline: AppSettings::default(),
settings: committed,
},
)]);
let (reply, result) = mpsc::sync_channel(1);
assert!(matches!(
process_config_write_message(
Ok(ConfigWriteMessage::Immediate {
path: path.clone(),
baseline: AppSettings::default(),
settings: rejected,
reply,
}),
&mut pending,
),
ConfigWriterState::PendingAfterImmediateError
));
assert!(result.recv().unwrap().is_err());
assert_eq!(pending[&path].settings.window.opacity, 0.8);
}
#[test]
fn next_window_prefers_active_terminal_directory() {
let launch = LaunchConfig {
command: LaunchCommand::Shell("pwd".to_string()),
working_directory: Some(PathBuf::from("/fallback")),
};
let next = next_window_launch(&launch, Some(PathBuf::from("/active")));
assert!(matches!(next.command, LaunchCommand::DefaultShell));
assert_eq!(next.working_directory, Some(PathBuf::from("/active")));
}
#[test]
fn next_window_falls_back_to_launch_directory() {
let launch = LaunchConfig {
command: LaunchCommand::DefaultShell,
working_directory: Some(PathBuf::from("/fallback")),
};
let next = next_window_launch(&launch, None);
assert_eq!(next.working_directory, Some(PathBuf::from("/fallback")));
}
#[test]
fn working_directory_copy_prefers_live_then_launch_then_process_path() {
let active = PathBuf::from("/active");
let launch = PathBuf::from("/launch");
let process = PathBuf::from("/process");
assert_eq!(
preferred_working_directory(
Some(active.clone()),
Some(launch.clone()),
Some(process.clone())
),
Some(active)
);
assert_eq!(
preferred_working_directory(None, Some(launch.clone()), Some(process.clone())),
Some(launch)
);
assert_eq!(
preferred_working_directory(None, None, Some(process.clone())),
Some(process)
);
assert_eq!(preferred_working_directory(None, None, None), None);
}
#[test]
fn next_window_is_borderless() {
let mut settings = AppSettings::default();
settings.window.decorated = true;
let next = next_window_settings(&settings);
assert!(!next.window.decorated);
}
#[test]
fn diagnostics_include_terminal_image_capability() {
let diagnostics = diagnostics_text(&AppSettings::default(), None, 1, false, false);
assert!(diagnostics.contains("terminal_identity: TERM=xterm-256color"));
assert!(diagnostics.contains("COLORTERM=truecolor"));
assert!(diagnostics.contains("TERM_PROGRAM=lios"));
assert!(diagnostics.contains(concat!("TERM_PROGRAM_VERSION=", env!("CARGO_PKG_VERSION"))));
assert!(!diagnostics.contains("VTE_VERSION="));
assert!(diagnostics.contains("image_protocol: none"));
assert!(
diagnostics_text(&AppSettings::default(), None, 1, true, false)
.contains("image_protocol: sixel")
);
}
#[test]
fn diagnostics_escape_terminal_controls_in_paths() {
let mut settings = AppSettings::default();
settings.terminal.background.image = Some(PathBuf::from("wall\u{1b}[31m\nname.png"));
let diagnostics = diagnostics_text(
&settings,
Some(Path::new("config\u{9b}31m\rname.toml")),
1,
false,
false,
);
assert!(!diagnostics.contains('\u{1b}'));
assert!(!diagnostics.contains('\u{9b}'));
assert!(diagnostics.contains(r"wall\u{1b}[31m\nname.png"));
assert!(diagnostics.contains(r"config\u{9b}31m\rname.toml"));
}
#[test]
fn reset_dark_default_restores_borderless_plain_theme() {
let mut settings = AppSettings::default();
settings.window.decorated = true;
settings.window.opacity = 0.5;
settings.terminal.theme_name = "solarized-dark".to_string();
settings.terminal.theme = TerminalTheme::named("solarized-dark").unwrap();
settings.terminal.cursor_background = Some("#112233".to_string());
settings.terminal.cursor_foreground = Some("#445566".to_string());
settings.terminal.cursor_match_overlay = true;
settings.terminal.prompt = PromptConfig::named("lightbar").unwrap();
settings.terminal.background.image = Some(PathBuf::from("background.png"));
settings.terminal.background.overlay_opacity = 0.3;
settings.terminal.background.random_overlay = false;
reset_dark_default_settings(&mut settings).unwrap();
assert!(!settings.window.decorated);
assert_eq!(settings.window.opacity, 1.0);
assert_eq!(settings.terminal.theme_name, "default");
assert!(settings.terminal.cursor_background.is_none());
assert!(settings.terminal.cursor_foreground.is_none());
assert!(!settings.terminal.cursor_match_overlay);
assert!(!settings.terminal.unified_accent);
assert_eq!(settings.terminal.prompt, PromptConfig::default());
assert!(settings.terminal.background.image.is_none());
assert_eq!(settings.terminal.background.overlay_opacity, 0.0);
assert!(settings.terminal.background.random_overlay);
}
#[test]
fn cursor_preset_updates_theme_and_persisted_values() {
let mut settings = AppSettings::default();
settings.terminal.cursor_match_overlay = true;
set_cursor_colors(
&mut settings,
XFCE_CURSOR_BACKGROUND,
XFCE_CURSOR_FOREGROUND,
)
.unwrap();
assert_eq!(
settings.terminal.cursor_background.as_deref(),
Some(XFCE_CURSOR_BACKGROUND)
);
assert_eq!(
settings.terminal.cursor_foreground.as_deref(),
Some(XFCE_CURSOR_FOREGROUND)
);
assert_eq!(
color_to_hex(&settings.terminal.theme.cursor_background_color().unwrap()),
XFCE_CURSOR_BACKGROUND
);
assert_eq!(
color_to_hex(&settings.terminal.theme.cursor_foreground_color().unwrap()),
XFCE_CURSOR_FOREGROUND
);
assert!(!settings.terminal.cursor_match_overlay);
}
#[test]
fn cursor_inherit_removes_overrides_and_restores_theme_cursor() {
let mut settings = AppSettings::default();
settings.select_theme("solarized-dark").unwrap();
set_cursor_colors(&mut settings, "#112233", "#445566").unwrap();
settings.terminal.cursor_match_overlay = true;
set_cursor_colors(&mut settings, "", "").unwrap();
assert!(settings.terminal.cursor_background.is_none());
assert!(settings.terminal.cursor_foreground.is_none());
assert_eq!(
color_to_hex(&settings.terminal.theme.cursor_background_color().unwrap()),
"#93a1a1"
);
assert!(settings.terminal.theme.cursor_foreground_color().is_none());
assert!(!settings.terminal.cursor_match_overlay);
}
#[test]
fn unified_accent_enables_effective_random_overlay() {
let mut settings = AppSettings::default();
settings.terminal.cursor_background = Some("#112233".to_string());
settings.terminal.cursor_foreground = Some("#445566".to_string());
settings.terminal.background.overlay_color = None;
settings.terminal.background.overlay_opacity = 0.0;
settings.terminal.background.random_overlay = false;
match_cursor_to_overlay_settings(&mut settings).unwrap();
assert!(settings.terminal.unified_accent);
assert!(settings.terminal.cursor_match_overlay);
assert!(settings.terminal.background.overlay_color.is_none());
assert_eq!(
settings.terminal.cursor_background.as_deref(),
Some("#112233")
);
assert_eq!(
settings.terminal.cursor_foreground.as_deref(),
Some("#445566")
);
assert!(settings.terminal.background.random_overlay);
assert_eq!(
settings.terminal.background.overlay_opacity,
DEFAULT_OVERLAY_OPACITY
);
}
#[test]
fn unified_accent_preserves_fixed_overlay() {
let mut settings = AppSettings::default();
settings.terminal.background.overlay_color =
Some(TerminalTheme::parse_color("#7c3aed").unwrap());
settings.terminal.background.overlay_opacity = 0.32;
settings.terminal.background.random_overlay = true;
match_cursor_to_overlay_settings(&mut settings).unwrap();
assert!(settings.terminal.unified_accent);
assert!(settings.terminal.cursor_match_overlay);
assert!(settings.terminal.background.random_overlay);
assert_eq!(
color_to_hex(&settings.terminal.background.overlay_color.unwrap()),
"#7c3aed"
);
assert_eq!(settings.terminal.background.overlay_opacity, 0.32);
}
#[test]
fn accent_actions_disable_unified_accent_when_overlay_disappears() {
let mut settings = AppSettings::default();
settings.terminal.background.overlay_color = None;
settings.terminal.background.random_overlay = true;
settings.terminal.background.overlay_opacity = 0.04;
settings.terminal.set_unified_accent(true);
toggle_random_overlay_settings(&mut settings);
assert!(!settings.terminal.unified_accent);
assert!(!settings.terminal.cursor_match_overlay);
settings.terminal.set_unified_accent(true);
settings.terminal.background.overlay_opacity = 0.04;
soften_accent_settings(&mut settings);
assert!(!settings.terminal.unified_accent);
assert!(!settings.terminal.cursor_match_overlay);
settings.terminal.set_unified_accent(true);
clear_accent_settings(&mut settings);
assert!(!settings.terminal.unified_accent);
assert!(!settings.terminal.cursor_match_overlay);
assert!(settings.terminal.background.overlay_color.is_none());
assert!(!settings.terminal.background.random_overlay);
}
#[test]
fn clear_accent_preserves_legacy_cursor_only_matching() {
let mut settings = AppSettings::default();
settings.terminal.cursor_match_overlay = true;
settings.terminal.unified_accent = false;
clear_accent_settings(&mut settings);
assert!(settings.terminal.cursor_match_overlay);
assert!(!settings.terminal.unified_accent);
}
#[test]
fn unrelated_preferences_preserve_legacy_cursor_only_matching() {
let mut current = AppSettings::default();
current.terminal.cursor_match_overlay = true;
current.terminal.unified_accent = false;
let mut next = current.clone();
apply_unified_accent_preference(&mut next, ¤t, false, false);
assert!(next.terminal.cursor_match_overlay);
assert!(!next.terminal.unified_accent);
apply_unified_accent_preference(&mut next, ¤t, false, true);
assert!(!next.terminal.cursor_match_overlay);
}
#[test]
fn matching_cursor_preset_still_disables_legacy_matching() {
assert!(cursor_selection_changed(
"#112233", "#445566", "#112233", "#445566", false, true,
));
assert!(!cursor_selection_changed(
"#112233", "#445566", "#112233", "#445566", false, false,
));
}
#[test]
fn dropdown_options_include_loaded_legacy_value() {
let names = AppSettings::default().selectable_theme_names();
let name_refs = names.iter().map(String::as_str).collect::<Vec<_>>();
let options = dropdown_option_values(&name_refs, "dracula");
assert_eq!(options.last().map(String::as_str), Some("dracula"));
assert_eq!(
options.iter().filter(|option| *option == "dracula").count(),
1
);
}
#[test]
fn file_uri_decodes_to_path() {
assert_eq!(
path_from_file_uri("file:///tmp"),
Some(PathBuf::from("/tmp"))
);
}
#[test]
fn file_uri_decodes_percent_encoding() {
assert_eq!(
path_from_file_uri("file:///tmp/My%20Docs"),
Some(PathBuf::from("/tmp/My Docs"))
);
}
#[test]
fn local_path_uri_escapes_spaces() {
assert_eq!(
local_path_uri(Path::new("/tmp/Lios Config")),
"file:///tmp/Lios%20Config"
);
}
#[test]
fn non_file_uri_does_not_decode_to_path() {
assert_eq!(path_from_file_uri("https://example.com/tmp"), None);
}
#[test]
fn preferences_shortcuts_are_scoped_to_unmodified_control_keys() {
let control = gdk::ModifierType::CONTROL_MASK;
let alt = gdk::ModifierType::ALT_MASK;
let lock = gdk::ModifierType::LOCK_MASK;
assert_eq!(
preferences_shortcut(gdk::Key::s, control),
Some(PreferencesShortcut::Apply)
);
assert_eq!(
preferences_shortcut(gdk::Key::Return, control),
Some(PreferencesShortcut::Apply)
);
assert_eq!(
preferences_shortcut(gdk::Key::r, control),
Some(PreferencesShortcut::Revert)
);
assert_eq!(
preferences_shortcut(gdk::Key::s, gdk::ModifierType::empty()),
None
);
assert_eq!(
preferences_shortcut(gdk::Key::r, control | gdk::ModifierType::SHIFT_MASK),
None
);
assert_eq!(preferences_shortcut(gdk::Key::s, control | alt), None);
assert_eq!(
preferences_shortcut(gdk::Key::s, control | lock),
Some(PreferencesShortcut::Apply)
);
assert_eq!(preferences_shortcut(gdk::Key::c, control), None);
}
#[test]
fn context_menu_shortcuts_are_exact_and_do_not_steal_modified_keys() {
let shift = gdk::ModifierType::SHIFT_MASK;
let control = gdk::ModifierType::CONTROL_MASK;
let lock = gdk::ModifierType::LOCK_MASK;
assert!(is_context_menu_shortcut(
gdk::Key::Menu,
gdk::ModifierType::empty()
));
assert!(is_context_menu_shortcut(gdk::Key::F10, shift));
assert!(is_context_menu_shortcut(gdk::Key::F10, shift | lock));
assert!(is_context_menu_shortcut(gdk::Key::Menu, lock));
assert!(!is_context_menu_shortcut(
gdk::Key::F10,
gdk::ModifierType::empty()
));
assert!(!is_context_menu_shortcut(gdk::Key::F10, shift | control));
assert!(!is_context_menu_shortcut(gdk::Key::Menu, control));
assert!(!is_context_menu_shortcut(gdk::Key::Menu, shift));
assert!(!is_context_menu_shortcut(
gdk::Key::Menu,
gdk::ModifierType::HYPER_MASK
));
}
#[test]
fn context_menu_action_policies_are_disjoint() {
let repeatable = context_menu_repeatable_actions();
let one_shot = context_menu_one_shot_actions();
for action in [
"more_transparent",
"stronger_accent",
"theme",
"cursor_match_accent",
] {
assert!(repeatable.contains(&action));
assert!(!one_shot.contains(&action));
}
for action in ["open-link", "copy-link", "paste-context", "find"] {
assert!(one_shot.contains(&action));
assert!(!repeatable.contains(&action));
}
assert_eq!(
repeatable
.iter()
.collect::<std::collections::HashSet<_>>()
.len(),
repeatable.len()
);
}
#[test]
fn preferences_config_destination_describes_saved_and_session_modes() {
assert_eq!(
preferences_config_destination(Some(Path::new("/tmp/lios.toml"))),
"Config: /tmp/lios.toml"
);
assert_eq!(preferences_config_destination(None), "Config: session only");
}
#[test]
fn preferences_config_directory_uses_config_parent() {
assert_eq!(
preferences_config_directory(Some(Path::new("/tmp/lios/config.toml"))),
Some(PathBuf::from("/tmp/lios"))
);
assert_eq!(preferences_config_directory(None), None);
assert_eq!(preferences_config_directory(Some(Path::new("/"))), None);
}
#[test]
fn terminal_titles_are_bounded_and_safe_for_window_chrome() {
let raw = " build\n\tfinished\u{202e}gpj.exe\u{202c} \u{1f680} ";
assert_eq!(
sanitize_title_component(raw, MAX_TERMINAL_TITLE_CHARS),
Some("build finishedgpj.exe 🚀".to_string())
);
assert_eq!(
sanitized_terminal_title("Lios", raw),
"Lios — build finishedgpj.exe 🚀"
);
assert_eq!(sanitized_config_title("\u{001b}\u{202e}\n"), "Lios");
assert_eq!(sanitized_terminal_title("Lios", "\n\t"), "Lios");
}
#[test]
fn terminal_titles_preserve_unicode_and_bound_input_work() {
let emoji = "👩💻";
assert_eq!(
sanitize_title_component(emoji, MAX_TERMINAL_TITLE_CHARS),
Some(emoji.to_string())
);
let long = "x".repeat(MAX_TITLE_INPUT_SCALARS + 100);
let bounded = sanitize_title_component(&long, MAX_TERMINAL_TITLE_CHARS).unwrap();
assert_eq!(bounded.chars().count(), MAX_TERMINAL_TITLE_CHARS);
assert!(sanitized_config_title(&long).chars().count() <= MAX_CONFIG_TITLE_CHARS);
}
}