use gtk::prelude::*;
use gtk::{gdk, gio, glib, pango};
use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use vte::prelude::*;
use crate::cli::{Cli, CliAction};
use crate::config::{
AppSettings, RendererPreference, config_path_for_write, expand_user_path, run_config_command,
};
use crate::desktop::{
ensure_user_icon, icon_name, icon_search_path, install_desktop_entry, refresh_user_icon_cache,
uninstall_desktop_entry,
};
use crate::terminal::{
CHILD_COLORTERM, CHILD_IMAGE_PROTOCOL, CHILD_TERM, CHILD_TERM_PROGRAM, CHILD_VTE_VERSION,
DEFAULT_IMAGE_OPACITY, DEFAULT_IMAGE_TERMINAL_OPACITY, DEFAULT_TERMINAL_OPACITY, LaunchCommand,
LaunchConfig, MAX_SCROLLBACK_LINES, TerminalPane, effective_terminal_opacity,
};
use crate::theme::{THEME_NAMES, TerminalTheme};
const APP_ID: &str = "dev.lios.Terminal";
const ACCENT_PRESETS: &[(&str, &str, &str)] = &[
("Violet", "#7c3aed", "accent_violet"),
("Cyan", "#0ea5e9", "accent_cyan"),
("Emerald", "#10b981", "accent_emerald"),
("Amber", "#f59e0b", "accent_amber"),
("Rose", "#f43f5e", "accent_rose"),
];
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::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_activate(move |app| {
build_window(app, launch.clone(), settings.clone(), config_path.clone())
});
application.run_with_args(&[env!("CARGO_PKG_NAME")])
}
fn apply_renderer(renderer: RendererPreference) {
if let Some(value) = renderer.gsk_renderer() {
unsafe { std::env::set_var("GSK_RENDERER", value) };
}
}
fn apply_window_opacity(window: >k::ApplicationWindow, opacity: f64) {
window.set_opacity(opacity.clamp(0.0, 1.0));
}
fn apply_live_app_settings(
window: >k::ApplicationWindow,
pane: &TerminalPane,
settings: &AppSettings,
) {
apply_window_opacity(window, settings.window.opacity);
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>);
}
pane.apply_config(&settings.terminal);
}
fn build_window(
app: >k::Application,
launch: LaunchConfig,
settings: AppSettings,
config_path: Option<PathBuf>,
) {
install_app_icon();
install_app_css();
let window = gtk::ApplicationWindow::builder()
.application(app)
.title(&settings.window.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();
let preferences =
build_preferences_revealer(&window, pane.clone(), state.clone(), config_path.clone());
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);
if state.borrow().window.decorated {
let header = build_headerbar();
window.set_titlebar(Some(&header));
}
install_window_actions(
&window,
&terminal,
pane.clone(),
state.clone(),
config_path.clone(),
launch.clone(),
&preferences,
);
install_keyboard_shortcuts(
&window,
&terminal,
&preferences,
launch.clone(),
state.clone(),
config_path.clone(),
);
keep_window_title_in_sync(&window, &terminal, state.borrow().window.title.clone());
close_window_when_shell_exits(&window, &terminal);
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(user_icon) = ensure_user_icon() {
if let Ok(path) = icon_search_path() {
gtk::IconTheme::for_display(&display).add_search_path(path);
}
if user_icon.changed {
refresh_user_icon_cache(&user_icon.file);
}
}
}
fn install_app_css() {
let Some(display) = gdk::Display::default() else {
return;
};
let provider = gtk::CssProvider::new();
provider.load_from_string(
"
.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-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-drawer {
background: linear-gradient(135deg, #040506, #090b0d 48%, #101316);
color: #e7e5de;
border: 1px solid rgba(188, 185, 170, 0.18);
border-radius: 18px;
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.04), 0 30px 110px rgba(0, 0, 0, 0.58);
padding: 12px;
}
.lios-drawer-scroll,
.lios-drawer-scroll viewport {
background: #040506;
border: none;
}
.lios-drawer-body {
background: #040506;
}
.lios-drawer-header {
border-radius: 16px;
padding: 2px 4px 8px 4px;
border-bottom: 1px solid rgba(188, 185, 170, 0.12);
}
.lios-drawer-footer {
padding: 8px 4px 0 4px;
border-top: 1px solid rgba(188, 185, 170, 0.12);
}
.lios-eyebrow {
color: #a8a497;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.18em;
}
.lios-drawer-title {
font-size: 19px;
font-weight: 800;
letter-spacing: -0.02em;
color: #f2f0e8;
}
.lios-muted {
color: #8e918a;
font-size: 12px;
}
.lios-hint {
color: #6f756e;
font-size: 11px;
}
.lios-card {
background: #080a0b;
border: 1px solid rgba(188, 185, 170, 0.16);
border-radius: 16px;
padding: 10px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
.lios-row-label {
color: #d7d5ca;
font-weight: 750;
}
.lios-choice {
border-radius: 999px;
padding: 4px 10px;
background: #050607;
color: #c9c7bd;
border: 1px solid rgba(188, 185, 170, 0.16);
box-shadow: none;
}
.lios-choice:hover {
background: #111518;
color: #f2f0e8;
border-color: rgba(16, 185, 129, 0.42);
}
.lios-choice.lios-selected {
background: linear-gradient(135deg, #d8d3c3, #10b981);
color: #050607;
border-color: rgba(238, 235, 221, 0.40);
box-shadow: 0 10px 34px rgba(16, 185, 129, 0.16);
}
.lios-field {
border-radius: 12px;
background: #020303;
color: #e7e5de;
border: 1px solid rgba(188, 185, 170, 0.16);
padding: 5px 10px;
}
.lios-slider {
color: #e7e5de;
padding: 4px 0;
}
.lios-slider trough {
min-height: 8px;
border-radius: 999px;
background: #050607;
border: 1px solid rgba(188, 185, 170, 0.16);
}
.lios-slider highlight {
border-radius: 999px;
background: linear-gradient(90deg, #d8d3c3, #10b981);
}
.lios-slider slider {
min-width: 16px;
min-height: 16px;
border-radius: 999px;
background: #f2f0e8;
border: 1px solid rgba(16, 185, 129, 0.62);
}
.lios-collapse {
border-radius: 999px;
padding: 5px 12px;
color: #c9c7bd;
background: #050607;
border: 1px solid rgba(188, 185, 170, 0.16);
}
.lios-apply {
border-radius: 999px;
padding: 7px 16px;
background: linear-gradient(135deg, #d8d3c3, #10b981);
color: #050607;
border: 1px solid rgba(238, 235, 221, 0.36);
font-weight: 700;
box-shadow: 0 16px 48px rgba(16, 185, 129, 0.16);
}
.lios-apply:disabled,
.lios-collapse: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 snapshot = settings.borrow().clone();
let status = gtk::Label::new(None);
status.add_css_class("lios-muted");
status.set_ellipsize(pango::EllipsizeMode::Middle);
status.set_max_width_chars(72);
status.set_xalign(0.0);
status.set_hexpand(true);
let dirty = Rc::new(Cell::new(false));
let updating_controls = Rc::new(Cell::new(false));
let dirty_actions = Rc::new(RefCell::new(Vec::<glib::WeakRef<gtk::Button>>::new()));
let update_dirty_actions: Rc<dyn Fn(bool)> = {
let dirty = dirty.clone();
let dirty_actions = dirty_actions.clone();
Rc::new(move |is_dirty| {
dirty.set(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();
Rc::new(move || {
if updating_controls.get() {
return;
}
update_dirty_actions(true);
status.set_text("Unsaved changes. Apply to save.");
})
};
let drawer = gtk::Box::new(gtk::Orientation::Vertical, 8);
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 eyebrow = gtk::Label::new(Some("KNOTT DYNAMICS"));
eyebrow.add_css_class("lios-eyebrow");
eyebrow.set_xalign(0.0);
let title = gtk::Label::new(Some("Preferences"));
title.add_css_class("lios-drawer-title");
title.set_xalign(0.0);
let subtitle = gtk::Label::new(Some(
"Apply updates the session and saves preferences. GPU renderer changes apply next launch.",
));
subtitle.add_css_class("lios-muted");
subtitle.set_xalign(0.0);
title_block.append(&eyebrow);
title_block.append(&title);
title_block.append(&subtitle);
title_block.set_hexpand(true);
let collapse = gtk::Button::with_label("Collapse");
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 cards = gtk::FlowBox::new();
cards.add_css_class("lios-drawer-body");
cards.set_column_spacing(8);
cards.set_row_spacing(8);
cards.set_homogeneous(true);
cards.set_min_children_per_line(1);
cards.set_max_children_per_line(2);
cards.set_selection_mode(gtk::SelectionMode::None);
cards.set_valign(gtk::Align::Start);
let display_card = preference_card("Display", "GPU acceleration, font, and terminal colors");
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 theme_choice = choice_grid(THEME_NAMES, &snapshot.terminal.theme_name, 2);
theme_choice.connect_changed(mark_dirty.clone());
display_card.append(&preference_column("Theme", &theme_choice.widget));
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()
});
display_card.append(&preference_row("Font", &font_entry));
let decorated = bool_choice(snapshot.window.decorated);
decorated.connect_changed(mark_dirty.clone());
display_card.append(&preference_column("Topbar", &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");
let parent_for_picker = parent.downgrade();
let pane_for_picker = pane.clone();
let settings_for_picker = settings.clone();
let config_path_for_picker = config_path.clone();
let image_entry_for_picker = image_entry.clone();
let image_opacity_for_picker = image_opacity.clone();
let terminal_opacity_for_picker = terminal_opacity.clone();
let status_for_picker = status.clone();
let dirty_for_picker = dirty.clone();
let update_dirty_for_picker = update_dirty_actions.clone();
let updating_controls_for_picker = updating_controls.clone();
choose_image.connect_clicked(move |_| {
let had_unsaved_changes = dirty_for_picker.get();
let Some(parent_for_picker) = parent_for_picker.upgrade() else {
return;
};
open_background_image_dialog(&parent_for_picker, {
let pane_for_picker = pane_for_picker.clone();
let settings_for_picker = settings_for_picker.clone();
let config_path_for_picker = config_path_for_picker.clone();
let image_entry_for_picker = image_entry_for_picker.clone();
let image_opacity_for_picker = image_opacity_for_picker.clone();
let terminal_opacity_for_picker = terminal_opacity_for_picker.clone();
let status_for_picker = status_for_picker.clone();
let update_dirty_for_picker = update_dirty_for_picker.clone();
let updating_controls_for_picker = updating_controls_for_picker.clone();
move |path| {
let path_for_settings = path.clone();
let result = apply_terminal_settings(
&pane_for_picker,
&settings_for_picker,
config_path_for_picker.as_deref(),
move |next| {
next.terminal.background.image = Some(path_for_settings);
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(())
},
);
match result {
Ok(()) => {
updating_controls_for_picker.set(true);
image_entry_for_picker.set_text(&path.to_string_lossy());
if terminal_opacity_for_picker.value() > DEFAULT_IMAGE_TERMINAL_OPACITY {
terminal_opacity_for_picker.set_value(DEFAULT_IMAGE_TERMINAL_OPACITY);
}
if image_opacity_for_picker.value() <= 0.0 {
image_opacity_for_picker.set_value(DEFAULT_IMAGE_OPACITY);
}
updating_controls_for_picker.set(false);
if had_unsaved_changes {
if config_path_for_picker.is_some() {
status_for_picker.set_text(
"Background image saved. Other changes still need Apply.",
);
} else {
status_for_picker.set_text(
"Background image applied. Other changes still need Apply.",
);
}
} else {
update_dirty_for_picker(false);
if config_path_for_picker.is_some() {
status_for_picker.set_text("Background image applied and saved.");
} else {
status_for_picker
.set_text("Background image applied for this session.");
}
}
}
Err(message) => status_for_picker.set_text(&message),
}
}
});
});
let clear_image = gtk::Button::with_label("Clear");
clear_image.add_css_class("lios-choice");
let pane_for_clear_image = pane.clone();
let settings_for_clear_image = settings.clone();
let config_path_for_clear_image = config_path.clone();
let image_entry_for_clear = image_entry.clone();
let status_for_clear_image = status.clone();
let dirty_for_clear_image = dirty.clone();
let update_dirty_for_clear_image = update_dirty_actions.clone();
let updating_controls_for_clear_image = updating_controls.clone();
clear_image.connect_clicked(move |_| {
let had_unsaved_changes = dirty_for_clear_image.get();
let result = apply_terminal_settings(
&pane_for_clear_image,
&settings_for_clear_image,
config_path_for_clear_image.as_deref(),
|next| {
next.terminal.background.image = None;
Ok(())
},
);
match result {
Ok(()) => {
updating_controls_for_clear_image.set(true);
image_entry_for_clear.set_text("");
updating_controls_for_clear_image.set(false);
if had_unsaved_changes {
if config_path_for_clear_image.is_some() {
status_for_clear_image
.set_text("Background image cleared. Other changes still need Apply.");
} else {
status_for_clear_image.set_text(
"Background image cleared for this session. Other changes still need Apply.",
);
}
} else {
update_dirty_for_clear_image(false);
if config_path_for_clear_image.is_some() {
status_for_clear_image.set_text("Background image cleared and saved.");
} else {
status_for_clear_image.set_text("Background image cleared for this session.");
}
}
}
Err(message) => status_for_clear_image.set_text(&message),
}
});
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 mark_dirty = mark_dirty.clone();
move |_| 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 accent_touched = Rc::new(Cell::new(false));
{
let accent_touched_for_change = accent_touched.clone();
let overlay_opacity_for_change = overlay_opacity.clone();
let random_overlay_for_change = random_overlay.clone();
let mark_dirty = mark_dirty.clone();
let updating_controls = updating_controls.clone();
overlay_entry.connect_changed(move |entry| {
if updating_controls.get() {
return;
}
accent_touched_for_change.set(true);
if !entry.text().trim().is_empty() {
random_overlay_for_change.set(false);
if overlay_opacity_for_change.value() <= 0.0 {
overlay_opacity_for_change.set_value(0.18);
}
}
mark_dirty();
});
}
overlay_card.append(&preference_row("Overlay color", &overlay_entry));
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 update_dirty_for_preset = update_dirty_actions.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;
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(0.18);
}
if changed {
update_dirty_for_preset(true);
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 status_for_clear_accent = status.clone();
let update_dirty_for_clear_accent = update_dirty_actions.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();
overlay_entry_for_clear.set_text("");
overlay_opacity_for_clear.set_value(0.0);
random_overlay_for_clear.set(false);
if changed {
update_dirty_for_clear_accent(true);
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 mark_dirty = mark_dirty.clone();
Rc::new(move || {
accent_touched.set(false);
mark_dirty();
})
});
let controls = PreferenceFormControls {
gpu_acceleration: gpu_acceleration.clone(),
gpu_mode_choice: gpu_mode_choice.clone(),
theme_choice: theme_choice.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(),
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(),
};
cards.insert(&display_card, -1);
cards.insert(&background_card, -1);
cards.insert(&opacity_card, -1);
cards.insert(&overlay_card, -1);
let scroll = gtk::ScrolledWindow::new();
scroll.add_css_class("lios-drawer-scroll");
scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
scroll.set_propagate_natural_height(true);
scroll.set_min_content_height(160);
scroll.set_max_content_height(360);
scroll.set_child(Some(&cards));
drawer.append(&scroll);
let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
footer.add_css_class("lios-drawer-footer");
let revert = gtk::Button::with_label("Revert");
revert.add_css_class("lios-collapse");
revert.set_sensitive(false);
let settings_for_revert = settings.clone();
let controls_for_revert = controls.clone();
revert.connect_clicked(move |_| {
let current = settings_for_revert.borrow();
controls_for_revert.sync_from(¤t, Some("Reverted unsaved changes."));
});
let apply = gtk::Button::with_label("Apply and Save");
apply.add_css_class("lios-apply");
apply.set_sensitive(false);
dirty_actions.borrow_mut().push(revert.downgrade());
dirty_actions.borrow_mut().push(apply.downgrade());
let parent_for_apply = parent.downgrade();
let settings_for_apply = settings.clone();
let status_for_apply = status.clone();
let accent_touched_for_apply = accent_touched.clone();
let update_dirty_for_apply = update_dirty_actions.clone();
apply.connect_clicked(move |_| {
let mut next = settings_for_apply.borrow().clone();
let theme_name = theme_choice.value.borrow().clone();
let gpu_mode = gpu_mode_choice.value.borrow().clone();
let theme = match TerminalTheme::named(&theme_name) {
Ok(theme) => theme,
Err(message) => {
status_for_apply.set_text(&message);
return;
}
};
let renderer =
match RendererPreference::from_gpu_settings(gpu_acceleration.value.get(), &gpu_mode) {
Ok(renderer) => renderer,
Err(message) => {
status_for_apply.set_text(&message);
return;
}
};
let overlay_text = overlay_entry.text().trim().to_string();
let overlay_color = if overlay_text.is_empty() {
None
} else {
match TerminalTheme::parse_color(&overlay_text) {
Ok(color) => Some(color),
Err(message) => {
status_for_apply.set_text(&message);
return;
}
}
};
let image_path = match image_path_from_text(image_entry.text().trim()) {
Ok(path) => path,
Err(message) => {
status_for_apply.set_text(&message);
return;
}
};
if accent_touched_for_apply.get() && overlay_color.is_some() && random_overlay.value.get() {
random_overlay.set(false);
}
next.window.renderer = renderer;
next.window.opacity = master_opacity.value();
next.terminal.theme_name = theme_name;
next.terminal.theme = theme;
next.terminal.font = font_entry.text().to_string();
next.terminal.background.image = image_path;
next.terminal.background.image_opacity = image_opacity.value();
next.terminal.background.terminal_opacity = terminal_opacity.value();
next.terminal.background.overlay_color = overlay_color;
next.terminal.background.overlay_opacity = overlay_opacity.value();
next.terminal.background.random_overlay = random_overlay.value.get();
next.window.decorated = decorated.value.get();
let renderer_changed = renderer != settings_for_apply.borrow().window.renderer;
if let Some(path) = &config_path {
if let Err(message) = next.persist(path) {
status_for_apply.set_text(&message);
return;
}
if renderer_changed {
status_for_apply.set_text(&format!(
"Saved {}. Renderer applies on next launch.",
path.display()
));
} else {
status_for_apply.set_text(&format!("Saved {}", path.display()));
}
} else {
status_for_apply.set_text(
"Applied for this session. Set HOME or XDG_CONFIG_HOME to save preferences.",
);
}
if let Some(parent_for_apply) = parent_for_apply.upgrade() {
apply_live_app_settings(&parent_for_apply, &pane, &next);
} else {
pane.apply_config(&next.terminal);
}
*settings_for_apply.borrow_mut() = next;
accent_touched_for_apply.set(false);
update_dirty_for_apply(false);
});
let hint = gtk::Label::new(Some("Esc closes"));
hint.add_css_class("lios-hint");
footer.append(&status);
footer.append(&hint);
footer.append(&revert);
footer.append(&apply);
drawer.append(&footer);
let settings_for_reveal = settings.clone();
let controls_for_reveal = controls.clone();
revealer.connect_notify_local(Some("reveal-child"), move |revealer, _| {
if revealer.reveals_child() && !controls_for_reveal.is_dirty() {
let current = settings_for_reveal.borrow();
controls_for_reveal.sync_from(¤t, None);
}
});
revealer.set_child(Some(&drawer));
revealer
}
fn toggle_revealer(revealer: >k::Revealer) -> bool {
let reveal = !revealer.reveals_child();
revealer.set_reveal_child(reveal);
reveal
}
#[derive(Clone)]
struct PreferenceFormControls {
gpu_acceleration: BoolChoice,
gpu_mode_choice: ChoiceGroup,
theme_choice: ChoiceGroup,
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,
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.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.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("");
}
self.random_overlay
.set(current.terminal.background.random_overlay);
self.updating_controls.set(false);
self.accent_touched.set(false);
(self.update_dirty_actions)(false);
self.status.set_text(status_text.unwrap_or(""));
}
}
#[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 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 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(),
"xfce" => "Xfce".to_string(),
"xterm" => "XTerm".to_string(),
"solarized-dark" => "Solarized Dark".to_string(),
"solarized-light" => "Solarized Light".to_string(),
"dark-pastels" => "Dark Pastels".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, 6);
card.add_css_class("lios-card");
card.set_hexpand(true);
card.set_vexpand(false);
card.set_size_request(250, -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, 4);
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, 8);
let row_label = gtk::Label::new(Some(label));
row_label.add_css_class("lios-row-label");
row_label.set_width_chars(11);
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 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 = 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::FileDialog::builder()
.title("Choose Background Image")
.modal(true)
.build();
let image_filter = gtk::FileFilter::new();
image_filter.set_name(Some("Images"));
image_filter.add_pixbuf_formats();
let filters = gio::ListStore::new::<gtk::FileFilter>();
filters.append(&image_filter);
dialog.set_filters(Some(&filters));
dialog.set_default_filter(Some(&image_filter));
dialog.open(Some(parent), gio::Cancellable::NONE, move |result| {
let Ok(file) = result 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;
}
on_path(path);
});
}
fn install_window_actions(
window: >k::ApplicationWindow,
terminal: &vte::Terminal,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
launch: LaunchConfig,
preferences: >k::Revealer,
) {
let menu = gio::Menu::new();
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("Paste"), Some("win.paste"));
clipboard_menu.append(Some("Select All"), Some("win.select-all"));
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 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 THEME_NAMES {
let action_name = format!("theme_{}", theme.replace('-', "_"));
theme_menu.append(
Some(&choice_label(theme)),
Some(&format!("win.{action_name}")),
);
add_theme_action(
window,
&action_name,
theme,
pane.clone(),
settings.clone(),
config_path.clone(),
);
}
appearance_menu.append_submenu(Some("Theme"), &theme_menu);
menu.append_section(Some("Appearance"), &appearance_menu);
let copy = gio::SimpleAction::new("copy", None);
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);
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 paste = gio::SimpleAction::new("paste", None);
let terminal_for_paste = terminal.clone();
paste.connect_activate(move |_, _| {
terminal_for_paste.paste_clipboard();
});
window.add_action(&paste);
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 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,
));
});
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 terminal_for_customize = terminal.clone();
customize.connect_activate(move |_, _| {
if let Some(preferences) = preferences_for_customize.upgrade() {
if !toggle_revealer(&preferences) {
terminal_for_customize.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(
&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 = 0.18;
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(
&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(
&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(
&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(
&pane_for_random,
&settings_for_random,
config_path_for_random.as_deref(),
|next| {
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 = 0.18;
}
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| {
next.terminal.background.overlay_opacity =
(next.terminal.background.overlay_opacity - 0.05).clamp(0.0, 0.65);
Ok(())
},
);
add_terminal_action(
window,
"clear_accent",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| {
next.terminal.background.overlay_color = None;
next.terminal.background.random_overlay = false;
Ok(())
},
);
terminal.set_context_menu_model(Some(&menu));
}
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(&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(&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(&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 = 0.18;
}
Ok(())
});
report_action_error(result);
});
window.add_action(&action);
}
fn add_theme_action(
window: >k::ApplicationWindow,
action_name: &str,
theme_name: &str,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
) {
let action = gio::SimpleAction::new(action_name, None);
let theme_name = theme_name.to_string();
action.connect_activate(move |_, _| {
let result = apply_terminal_settings(&pane, &settings, config_path.as_deref(), |next| {
let canonical_name = TerminalTheme::canonical_name(&theme_name)?;
next.terminal.theme_name = canonical_name.to_string();
next.terminal.theme = TerminalTheme::named(canonical_name)?;
Ok(())
});
report_action_error(result);
});
window.add_action(&action);
}
fn reset_dark_default_settings(next: &mut AppSettings) -> Result<(), String> {
next.window.decorated = false;
next.window.opacity = 1.0;
next.terminal.theme_name = "xfce".to_string();
next.terminal.theme = TerminalTheme::named("xfce")?;
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 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 = settings.borrow().clone();
update(&mut next)?;
if let Some(path) = config_path {
next.persist(path)?;
}
pane.apply_config(&next.terminal);
*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 = settings.borrow().clone();
update(&mut next)?;
if let Some(path) = config_path {
next.persist(path)?;
}
apply_live_app_settings(window, pane, &next);
*settings.borrow_mut() = 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,
) -> String {
let image = settings
.terminal
.background
.image
.as_ref()
.map(|path| 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| 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={}, VTE_VERSION={}\r\n image_protocol: {}\r\n window_opacity: {:.2}\r\n theme: {}\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"),
CHILD_VTE_VERSION,
CHILD_IMAGE_PROTOCOL,
settings.window.opacity,
settings.terminal.theme_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),
overlay,
settings.terminal.background.overlay_opacity,
settings.terminal.background.random_overlay,
)
}
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()
})
}
fn install_keyboard_shortcuts(
window: >k::ApplicationWindow,
terminal: &vte::Terminal,
preferences: >k::Revealer,
launch: LaunchConfig,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
) {
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.clone();
let zoom_for_keys = zoom.clone();
let preferences_for_keys = preferences.downgrade();
let window_for_keys = window.downgrade();
let settings_for_keys = settings.clone();
let config_path_for_keys = config_path.clone();
let launch_for_keys = launch.clone();
controller.connect_key_pressed(move |_, key, _, state| {
if key == gdk::Key::Escape {
if let Some(preferences_for_keys) = preferences_for_keys.upgrade() {
if preferences_for_keys.reveals_child() {
preferences_for_keys.set_reveal_child(false);
terminal_for_keys.grab_focus();
return glib::Propagation::Stop;
}
}
}
let Some(character) = key.to_unicode().map(|ch| ch.to_ascii_lowercase()) else {
return glib::Propagation::Proceed;
};
let has_control = state.contains(gdk::ModifierType::CONTROL_MASK);
let has_shift = state.contains(gdk::ModifierType::SHIFT_MASK);
if has_control && has_shift {
match character {
'c' => {
terminal_for_keys.copy_clipboard_format(vte::Format::Text);
return glib::Propagation::Stop;
}
'v' => {
terminal_for_keys.paste_clipboard();
return glib::Propagation::Stop;
}
'a' => {
terminal_for_keys.select_all();
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;
}
',' | '<' => {
if let Some(preferences_for_keys) = preferences_for_keys.upgrade() {
if !toggle_revealer(&preferences_for_keys) {
terminal_for_keys.grab_focus();
}
}
return glib::Propagation::Stop;
}
_ => {}
}
}
if has_control {
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 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 = next_window_settings(&settings.borrow());
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 path_from_file_uri(uri: &str) -> Option<PathBuf> {
gio::File::for_uri(uri).path()
}
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 keep_window_title_in_sync(
window: >k::ApplicationWindow,
terminal: &vte::Terminal,
fallback_title: String,
) {
let window_for_title = window.downgrade();
terminal.connect_window_title_changed(move |terminal| {
let Some(window_for_title) = window_for_title.upgrade() else {
return;
};
let title = terminal_window_title(terminal)
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| fallback_title.clone());
window_for_title.set_title(Some(&title));
});
}
#[allow(deprecated)]
fn terminal_window_title(terminal: &vte::Terminal) -> Option<String> {
terminal.window_title().map(|title| title.to_string())
}
fn close_window_when_shell_exits(window: >k::ApplicationWindow, terminal: &vte::Terminal) {
let window_for_exit = window.downgrade();
terminal.connect_child_exited(move |_, _| {
if let Some(window_for_exit) = window_for_exit.upgrade() {
window_for_exit.close();
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[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 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);
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=7800"));
assert!(diagnostics.contains("image_protocol: sixel"));
}
#[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.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, "xfce");
assert!(settings.terminal.background.image.is_none());
assert_eq!(settings.terminal.background.overlay_opacity, 0.0);
assert!(settings.terminal.background.random_overlay);
}
#[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 non_file_uri_does_not_decode_to_path() {
assert_eq!(path_from_file_uri("https://example.com/tmp"), None);
}
}