use gtk::prelude::*;
use gtk::{gdk, gio, glib};
use std::cell::{Cell, RefCell};
use std::path::PathBuf;
use std::rc::Rc;
use vte::prelude::*;
use crate::cli::{Cli, CliAction};
use crate::config::{AppSettings, RendererPreference, config_path_for_write, run_config_command};
use crate::terminal::{LaunchConfig, TerminalPane};
use crate::theme::{THEME_NAMES, TerminalTheme};
const APP_ID: &str = "dev.lios.Terminal";
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::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);
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);
install_keyboard_shortcuts(&window, &terminal, &preferences);
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-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.clone();
preferences_button.connect_clicked(move |_| toggle_revealer(&preferences_for_button));
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 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.clone();
collapse.connect_clicked(move |_| revealer_for_collapse.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());
}
background_card.append(&preference_row("Image", &image_entry));
let image_opacity = opacity_spin(snapshot.terminal.background.image_opacity);
background_card.append(&preference_row("Image opacity", &image_opacity));
let terminal_opacity = opacity_spin(snapshot.terminal.background.terminal_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 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 apply = gtk::Button::with_label("Apply and Save");
apply.add_css_class("lios-apply");
let parent_for_apply = parent.clone();
let revealer_for_apply = revealer.clone();
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_text = image_entry.text().trim().to_string();
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 = if image_text.is_empty() {
None
} else {
Some(PathBuf::from(image_text))
};
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);
parent_for_apply.set_decorated(next.window.decorated);
if next.window.decorated {
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::<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() {
button.remove_css_class("lios-selected");
}
clicked.add_css_class("lios-selected");
});
buttons.borrow_mut().push(button.clone());
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::<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() {
button.remove_css_class("lios-selected");
}
clicked.add_css_class("lios-selected");
});
buttons.borrow_mut().push(button.clone());
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 install_window_actions(window: >k::ApplicationWindow, terminal: &vte::Terminal) {
let menu = gio::Menu::new();
menu.append(Some("Copy"), Some("win.copy"));
menu.append(Some("Copy as HTML"), Some("win.copy-html"));
menu.append(Some("Paste"), Some("win.paste"));
menu.append(Some("Select All"), Some("win.select-all"));
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);
terminal.set_context_menu_model(Some(&menu));
}
fn install_keyboard_shortcuts(
window: >k::ApplicationWindow,
terminal: &vte::Terminal,
preferences: >k::Revealer,
) {
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.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;
}
',' | '<' => {
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 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.clone();
terminal.connect_window_title_changed(move |terminal| {
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.clone();
terminal.connect_child_exited(move |_, _| {
window_for_exit.close();
});
}