use gtk::prelude::*;
use gtk::{gdk, gio, glib};
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::{install_desktop_entry, uninstall_desktop_entry};
use crate::terminal::{
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 build_window(
app: >k::Application,
launch: LaunchConfig,
settings: AppSettings,
config_path: Option<PathBuf>,
) {
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)
.build();
let state = Rc::new(RefCell::new(settings));
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::Box::new(gtk::Orientation::Vertical, 0);
root.append(&preferences);
root.append(pane.widget());
if state.borrow().window.decorated {
let header = build_headerbar(&preferences);
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_css() {
let Some(display) = gdk::Display::default() else {
return;
};
let provider = gtk::CssProvider::new();
provider.load_from_string(
"
.lios-topbar {
background: linear-gradient(90deg, #050816, #101626 55%, #151127);
color: #e5e7eb;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
min-height: 36px;
}
.lios-terminal {
background: transparent;
}
.lios-brand {
font-weight: 800;
letter-spacing: 0.08em;
color: #f8fafc;
}
.lios-prefs-button {
border-radius: 999px;
padding: 5px 16px;
background: linear-gradient(135deg, #1d2540, #31204a);
color: #e5e7eb;
border: 1px solid rgba(167, 139, 250, 0.32);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.24);
}
.lios-drawer {
background: linear-gradient(135deg, #080b17, #101827 58%, #171025);
color: #e5e7eb;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
padding: 18px;
}
.lios-drawer-title {
font-size: 18px;
font-weight: 800;
letter-spacing: 0.04em;
}
.lios-muted {
color: #9ca3af;
font-size: 12px;
}
.lios-card {
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 18px;
padding: 14px;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.22);
}
.lios-row-label {
color: #cbd5e1;
font-weight: 600;
}
.lios-choice {
border-radius: 999px;
padding: 6px 12px;
background: rgba(15, 23, 42, 0.82);
color: #cbd5e1;
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: none;
}
.lios-choice:hover {
background: rgba(30, 41, 59, 0.92);
color: #f8fafc;
border-color: rgba(167, 139, 250, 0.35);
}
.lios-choice.lios-selected {
background: linear-gradient(135deg, #7c3aed, #0ea5e9);
color: #ffffff;
border-color: rgba(255, 255, 255, 0.36);
box-shadow: 0 8px 24px rgba(14, 165, 233, 0.20);
}
.lios-field {
border-radius: 12px;
background: rgba(2, 6, 23, 0.72);
color: #e5e7eb;
border: 1px solid rgba(148, 163, 184, 0.16);
padding: 7px 10px;
}
.lios-collapse {
border-radius: 999px;
padding: 5px 12px;
color: #cbd5e1;
background: rgba(15, 23, 42, 0.75);
border: 1px solid rgba(148, 163, 184, 0.14);
}
.lios-apply {
border-radius: 999px;
padding: 7px 16px;
background: linear-gradient(135deg, #0ea5e9, #7c3aed);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.24);
font-weight: 700;
}
",
);
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
fn build_headerbar(preferences: >k::Revealer) -> gtk::HeaderBar {
let header = gtk::HeaderBar::new();
header.add_css_class("lios-topbar");
header.set_show_title_buttons(true);
let title = gtk::Label::new(Some("LIOS"));
title.add_css_class("lios-brand");
header.set_title_widget(Some(&title));
let preferences_button = gtk::Button::with_label("Customize");
preferences_button.add_css_class("lios-prefs-button");
let preferences_for_button = preferences.downgrade();
preferences_button.connect_clicked(move |_| {
if let Some(preferences) = preferences_for_button.upgrade() {
toggle_revealer(&preferences);
}
});
header.pack_end(&preferences_button);
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_wrap(true);
status.set_xalign(0.0);
status.set_hexpand(true);
let drawer = gtk::Box::new(gtk::Orientation::Vertical, 14);
drawer.add_css_class("lios-drawer");
let header = gtk::Box::new(gtk::Orientation::Horizontal, 12);
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 subtitle = gtk::Label::new(Some(
"Live theme changes. Renderer changes apply on next launch.",
));
subtitle.add_css_class("lios-muted");
subtitle.set_xalign(0.0);
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();
collapse.connect_clicked(move |_| {
if let Some(revealer) = revealer_for_collapse.upgrade() {
revealer.set_reveal_child(false);
}
});
header.append(&title_block);
header.append(&collapse);
drawer.append(&header);
let grid = gtk::Grid::new();
grid.set_column_spacing(12);
grid.set_row_spacing(12);
grid.set_column_homogeneous(true);
let display_card = preference_card("Display", "Renderer, font, and terminal colors");
let renderer_choice = choice_grid(
&RendererPreference::NAMES,
snapshot.window.renderer.as_config(),
4,
);
display_card.append(&preference_column("Renderer", &renderer_choice.widget));
let theme_choice = choice_grid(THEME_NAMES, &snapshot.terminal.theme_name, 2);
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"));
display_card.append(&preference_row("Font", &font_entry));
let background_card = preference_card("Background", "Image, transparency, and color wash");
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());
}
let image_opacity = opacity_spin(snapshot.terminal.background.image_opacity);
let terminal_opacity = opacity_spin(snapshot.terminal.background.terminal_opacity);
let image_picker = gtk::Box::new(gtk::Orientation::Horizontal, 8);
image_entry.set_hexpand(true);
let choose_image = gtk::Button::with_label("Choose Image");
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();
choose_image.connect_clicked(move |_| {
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();
move |path| {
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);
}
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);
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(()) => status_for_picker.set_text("Background image applied and saved."),
Err(message) => status_for_picker.set_text(&message),
}
}
});
});
image_picker.append(&image_entry);
image_picker.append(&choose_image);
background_card.append(&preference_row("Image", &image_picker));
background_card.append(&preference_row("Image opacity", &image_opacity));
background_card.append(&preference_row("Terminal opacity", &terminal_opacity));
let overlay_card = preference_card("Overlay", "Kitty-like accent tint per window");
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));
}
overlay_card.append(&preference_row("Overlay color", &overlay_entry));
let overlay_opacity = opacity_spin(snapshot.terminal.background.overlay_opacity);
overlay_card.append(&preference_row("Overlay opacity", &overlay_opacity));
let random_overlay = bool_choice(snapshot.terminal.background.random_overlay);
overlay_card.append(&preference_column("Random overlay", &random_overlay.widget));
let window_card = preference_card("Window", "Chrome and persistence");
let decorated = bool_choice(snapshot.window.decorated);
window_card.append(&preference_column("Show topbar", &decorated.widget));
grid.attach(&display_card, 0, 0, 1, 1);
grid.attach(&background_card, 1, 0, 1, 1);
grid.attach(&overlay_card, 0, 1, 1, 1);
grid.attach(&window_card, 1, 1, 1, 1);
drawer.append(&grid);
let footer = gtk::Box::new(gtk::Orientation::Horizontal, 12);
let apply = gtk::Button::with_label("Apply and Save");
apply.add_css_class("lios-apply");
let parent_for_apply = parent.downgrade();
let revealer_for_apply = revealer.downgrade();
let status_for_apply = status.clone();
apply.connect_clicked(move |_| {
let mut next = settings.borrow().clone();
let theme_name = theme_choice.value.borrow().clone();
let renderer_name = renderer_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::parse(&renderer_name) {
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;
}
};
next.window.renderer = renderer;
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();
pane.apply_config(&next.terminal);
if let Some(parent_for_apply) = parent_for_apply.upgrade() {
parent_for_apply.set_decorated(next.window.decorated);
if next.window.decorated {
if let Some(revealer_for_apply) = revealer_for_apply.upgrade() {
let header = build_headerbar(&revealer_for_apply);
parent_for_apply.set_titlebar(Some(&header));
}
} else {
parent_for_apply.set_titlebar(None::<>k::Widget>);
}
}
if let Some(path) = &config_path {
if let Err(message) = next.persist(path) {
status_for_apply.set_text(&message);
return;
}
if renderer != settings.borrow().window.renderer {
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.",
);
}
*settings.borrow_mut() = next;
});
footer.append(&status);
footer.append(&apply);
drawer.append(&footer);
revealer.set_child(Some(&drawer));
revealer
}
fn toggle_revealer(revealer: >k::Revealer) {
revealer.set_reveal_child(!revealer.reveals_child());
}
struct ChoiceGroup {
widget: gtk::Grid,
value: Rc<RefCell<String>>,
}
struct BoolChoice {
widget: gtk::Grid,
value: Rc<Cell<bool>>,
}
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 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,
}
}
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 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,
}
}
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, 10);
card.add_css_class("lios-card");
card.set_hexpand(true);
card.set_vexpand(false);
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, 12);
let row_label = gtk::Label::new(Some(label));
row_label.add_css_class("lios-row-label");
row_label.set_width_chars(15);
row_label.set_xalign(0.0);
control.as_ref().set_hexpand(true);
row.append(&row_label);
row.append(control);
row
}
fn opacity_spin(value: f64) -> gtk::SpinButton {
let spin = gtk::SpinButton::with_range(0.0, 1.0, 0.05);
spin.add_css_class("lios-field");
spin.set_digits(2);
spin.set_value(value.clamp(0.0, 1.0));
spin
}
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 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 Transparency"), Some("win.reset_transparency"));
appearance_menu.append_submenu(Some("Terminal Glass"), &glass_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 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,
&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();
customize.connect_activate(move |_, _| {
if let Some(preferences) = preferences_for_customize.upgrade() {
toggle_revealer(&preferences);
}
});
window.add_action(&customize);
add_terminal_action(
window,
"reset_dark_default",
pane.clone(),
settings.clone(),
config_path.clone(),
|next| {
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 = false;
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 = false;
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.2, 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.2, 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_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 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)?;
pane.apply_config(&next.terminal);
*settings.borrow_mut() = next.clone();
if let Some(path) = config_path {
next.persist(path)?;
}
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 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(),
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| {
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,
&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() {
toggle_revealer(&preferences_for_keys);
}
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,
launch: &LaunchConfig,
settings: &Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
) {
let Some(app) = parent.application() else {
return;
};
let next_launch = LaunchConfig {
command: LaunchCommand::DefaultShell,
working_directory: launch.working_directory.clone(),
};
let next_settings = settings.borrow().clone();
build_window(&app, next_launch, next_settings, config_path);
}
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();
}
});
}