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, 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 {
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 build_window(
app: >k::Application,
launch: LaunchConfig,
settings: AppSettings,
config_path: Option<PathBuf>,
) {
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();
install_window_actions(&window, &terminal);
install_keyboard_shortcuts(&window, &terminal);
install_topbar(&window, pane.clone(), state.clone(), config_path);
keep_window_title_in_sync(&window, &terminal, state.borrow().window.title.clone());
close_window_when_shell_exits(&window, &terminal);
window.set_child(Some(pane.widget()));
window.present();
pane.spawn(launch);
pane.focus();
}
fn install_topbar(
window: >k::ApplicationWindow,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
) {
if !settings.borrow().window.decorated {
return;
}
let header = gtk::HeaderBar::new();
header.set_show_title_buttons(true);
let title = gtk::Label::new(Some("Lios"));
title.add_css_class("heading");
header.set_title_widget(Some(&title));
let preferences = gtk::Button::with_label("Preferences");
let window_for_prefs = window.clone();
preferences.connect_clicked(move |_| {
show_preferences_window(
&window_for_prefs,
pane.clone(),
settings.clone(),
config_path.clone(),
);
});
header.pack_end(&preferences);
window.set_titlebar(Some(&header));
}
#[allow(deprecated)]
fn show_preferences_window(
parent: >k::ApplicationWindow,
pane: Rc<TerminalPane>,
settings: Rc<RefCell<AppSettings>>,
config_path: Option<PathBuf>,
) {
let snapshot = settings.borrow().clone();
let dialog = gtk::Window::builder()
.title("Lios Preferences")
.transient_for(parent)
.default_width(460)
.default_height(420)
.build();
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
content.set_margin_top(16);
content.set_margin_bottom(16);
content.set_margin_start(16);
content.set_margin_end(16);
let theme_combo = gtk::ComboBoxText::new();
for theme in THEME_NAMES {
theme_combo.append(Some(theme), theme);
}
theme_combo.set_active_id(Some(&snapshot.terminal.theme_name));
content.append(&preference_row("Theme", &theme_combo));
let image_entry = gtk::Entry::new();
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());
}
content.append(&preference_row("Background image", &image_entry));
let image_opacity = opacity_spin(snapshot.terminal.background.image_opacity);
content.append(&preference_row("Image opacity", &image_opacity));
let terminal_opacity = opacity_spin(snapshot.terminal.background.terminal_opacity);
content.append(&preference_row("Terminal opacity", &terminal_opacity));
let overlay_entry = gtk::Entry::new();
overlay_entry.set_placeholder_text(Some("#7c3aed"));
if let Some(color) = &snapshot.terminal.background.overlay_color {
overlay_entry.set_text(&color_to_hex(color));
}
content.append(&preference_row("Overlay color", &overlay_entry));
let overlay_opacity = opacity_spin(snapshot.terminal.background.overlay_opacity);
content.append(&preference_row("Overlay opacity", &overlay_opacity));
let random_overlay = gtk::Switch::new();
random_overlay.set_active(snapshot.terminal.background.random_overlay);
content.append(&preference_row("Random overlay", &random_overlay));
let decorated = gtk::Switch::new();
decorated.set_active(snapshot.window.decorated);
content.append(&preference_row("Show topbar", &decorated));
let status = gtk::Label::new(None);
status.set_wrap(true);
status.set_xalign(0.0);
content.append(&status);
let apply = gtk::Button::with_label("Apply and Save");
let parent_for_apply = parent.clone();
apply.connect_clicked(move |_| {
let mut next = settings.borrow().clone();
let theme_name = theme_combo
.active_id()
.map(|value| value.to_string())
.unwrap_or_else(|| next.terminal.theme_name.clone());
let theme = match TerminalTheme::named(&theme_name) {
Ok(theme) => theme,
Err(message) => {
status.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.set_text(&message);
return;
}
}
};
let image_text = image_entry.text().trim().to_string();
next.terminal.theme_name = theme_name;
next.terminal.theme = theme;
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.is_active();
next.window.decorated = decorated.is_active();
pane.apply_config(&next.terminal);
parent_for_apply.set_decorated(next.window.decorated);
if !next.window.decorated {
parent_for_apply.set_titlebar(None::<>k::Widget>);
}
if let Some(path) = &config_path {
if let Err(message) = next.persist(path) {
status.set_text(&message);
return;
}
status.set_text(&format!("Saved {}", path.display()));
} else {
status.set_text(
"Applied for this session. Set HOME or XDG_CONFIG_HOME to save preferences.",
);
}
*settings.borrow_mut() = next;
});
content.append(&apply);
dialog.set_child(Some(&content));
dialog.present();
}
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.set_width_chars(18);
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.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) {
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();
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;
}
_ => {}
}
}
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();
});
}