lios 0.1.1

A customizable GTK/VTE Linux terminal emulator written in Rust.
use gtk::prelude::*;
use gtk::{gdk, gio, glib, pango};
use std::cell::RefCell;
use std::env;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use vte::prelude::*;

use crate::theme::TerminalTheme;

const SPAWN_TIMEOUT_MS: i32 = 30_000;

#[derive(Debug, Clone)]
pub struct LaunchConfig {
    pub command: LaunchCommand,
    pub working_directory: Option<PathBuf>,
}

#[derive(Debug, Clone)]
pub enum LaunchCommand {
    DefaultShell,
    Shell(String),
    Argv(Vec<String>),
}

#[derive(Debug, Clone)]
pub struct TerminalConfig {
    pub theme_name: String,
    pub font: String,
    pub scrollback_lines: i64,
    pub theme: TerminalTheme,
    pub background: BackgroundConfig,
}

#[derive(Debug, Clone)]
pub struct BackgroundConfig {
    pub image: Option<PathBuf>,
    pub image_opacity: f64,
    pub terminal_opacity: f64,
    pub overlay_color: Option<gdk::RGBA>,
    pub overlay_opacity: f64,
    pub random_overlay: bool,
}

impl Default for TerminalConfig {
    fn default() -> Self {
        Self {
            theme_name: "xfce".to_string(),
            font: "Monospace 12".to_string(),
            scrollback_lines: 1_000,
            theme: TerminalTheme::xfce_default(),
            background: BackgroundConfig::default(),
        }
    }
}

impl Default for BackgroundConfig {
    fn default() -> Self {
        Self {
            image: None,
            image_opacity: 0.75,
            terminal_opacity: 1.0,
            overlay_color: None,
            overlay_opacity: 0.18,
            random_overlay: false,
        }
    }
}

pub struct TerminalPane {
    root: gtk::Overlay,
    terminal: vte::Terminal,
    tint: RefCell<Option<gtk::DrawingArea>>,
}

impl TerminalPane {
    pub fn new(config: &TerminalConfig) -> Self {
        let root = gtk::Overlay::new();
        root.set_hexpand(true);
        root.set_vexpand(true);
        root.set_child(Some(&background_widget(&config.background)));

        let terminal = vte::Terminal::new();
        terminal.set_hexpand(true);
        terminal.set_vexpand(true);
        terminal.set_font(Some(&pango::FontDescription::from_string(&config.font)));
        terminal.set_scrollback_lines(config.scrollback_lines as _);
        terminal.set_scroll_on_keystroke(true);
        terminal.set_scroll_on_output(false);
        terminal.set_audible_bell(false);
        terminal.set_cursor_blink_mode(vte::CursorBlinkMode::Off);
        terminal.set_cursor_shape(vte::CursorShape::Block);
        terminal.set_mouse_autohide(false);
        terminal.set_bold_is_bright(true);
        terminal.set_enable_sixel(true);
        config
            .theme
            .with_background_alpha(config.background.terminal_opacity)
            .apply_to(&terminal);

        let tint = overlay_color(&config.background)
            .map(|color| color_overlay_widget(color, config.background.overlay_opacity));
        if let Some(tint_widget) = &tint {
            root.add_overlay(tint_widget);
            root.set_clip_overlay(tint_widget, true);
        }

        root.add_overlay(&terminal);
        root.set_clip_overlay(&terminal, true);
        root.set_measure_overlay(&terminal, true);

        Self {
            root,
            terminal,
            tint: RefCell::new(tint),
        }
    }

    pub fn widget(&self) -> &gtk::Overlay {
        &self.root
    }

    pub fn terminal(&self) -> &vte::Terminal {
        &self.terminal
    }

    pub fn focus(&self) {
        self.terminal.grab_focus();
    }

    pub fn apply_config(&self, config: &TerminalConfig) {
        self.terminal
            .set_font(Some(&pango::FontDescription::from_string(&config.font)));
        self.terminal
            .set_scrollback_lines(config.scrollback_lines as _);
        config
            .theme
            .with_background_alpha(config.background.terminal_opacity)
            .apply_to(&self.terminal);

        self.root
            .set_child(Some(&background_widget(&config.background)));
        self.root.remove_overlay(&self.terminal);
        if let Some(old_tint) = self.tint.borrow_mut().take() {
            self.root.remove_overlay(&old_tint);
        }
        if let Some(color) = overlay_color(&config.background) {
            let tint = color_overlay_widget(color, config.background.overlay_opacity);
            self.root.add_overlay(&tint);
            self.root.set_clip_overlay(&tint, true);
            *self.tint.borrow_mut() = Some(tint);
        }
        self.root.add_overlay(&self.terminal);
        self.root.set_clip_overlay(&self.terminal, true);
        self.root.set_measure_overlay(&self.terminal, true);
    }

    pub fn spawn(&self, launch: LaunchConfig) {
        let argv = match launch.command.to_argv() {
            Ok(argv) => argv,
            Err(message) => {
                self.report_spawn_error(&message);
                return;
            }
        };

        let working_directory = working_directory(launch.working_directory.as_deref());
        let envv = child_environment(working_directory.as_deref());
        let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
        let env_refs: Vec<&str> = envv.iter().map(String::as_str).collect();
        let terminal_for_error = self.terminal.clone();

        self.terminal.spawn_async(
            vte::PtyFlags::DEFAULT,
            working_directory.as_deref(),
            &argv_refs,
            &env_refs,
            glib::SpawnFlags::SEARCH_PATH,
            || {},
            SPAWN_TIMEOUT_MS,
            gio::Cancellable::NONE,
            move |result| {
                if let Err(error) = result {
                    terminal_for_error
                        .feed(format!("\r\nFailed to execute child: {error}\r\n").as_bytes());
                }
            },
        );
    }

    fn report_spawn_error(&self, message: &str) {
        self.terminal
            .feed(format!("Failed to start terminal shell: {message}\r\n").as_bytes());
    }
}

impl LaunchCommand {
    fn to_argv(&self) -> Result<Vec<String>, String> {
        match self {
            Self::DefaultShell => Ok(vec![default_shell()?]),
            Self::Shell(command) => Ok(vec![
                "/bin/sh".to_string(),
                "-lc".to_string(),
                command.clone(),
            ]),
            Self::Argv(argv) if argv.is_empty() => Err("empty command".to_string()),
            Self::Argv(argv) => Ok(argv.clone()),
        }
    }
}

fn default_shell() -> Result<String, String> {
    if let Ok(shell) = env::var("SHELL") {
        if is_executable(Path::new(&shell)) {
            return Ok(shell);
        }
    }

    for shell in [
        "/bin/sh",
        "/bin/bash",
        "/usr/bin/bash",
        "/bin/dash",
        "/usr/bin/dash",
        "/bin/zsh",
        "/usr/bin/zsh",
        "/bin/fish",
        "/usr/bin/fish",
        "/bin/tcsh",
        "/usr/bin/tcsh",
        "/bin/csh",
        "/usr/bin/csh",
        "/bin/ksh",
        "/usr/bin/ksh",
    ] {
        if is_executable(Path::new(shell)) {
            return Ok(shell.to_string());
        }
    }

    Err("unable to determine a usable shell".to_string())
}

fn is_executable(path: &Path) -> bool {
    let Ok(metadata) = fs::metadata(path) else {
        return false;
    };

    metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
}

fn working_directory(requested: Option<&Path>) -> Option<String> {
    requested
        .map(Path::to_path_buf)
        .or_else(|| env::current_dir().ok())
        .and_then(|path| path.to_str().map(ToOwned::to_owned))
}

fn child_environment(working_directory: Option<&str>) -> Vec<String> {
    let mut envv = Vec::new();
    let mut has_pwd = false;

    for (key, value) in env::vars() {
        if should_strip_child_env(&key) {
            continue;
        }

        if key == "PWD" {
            if let Some(cwd) = working_directory {
                envv.push(format!("PWD={cwd}"));
                has_pwd = true;
            }
            continue;
        }

        envv.push(format!("{key}={value}"));
    }

    if !has_pwd {
        if let Some(cwd) = working_directory {
            envv.push(format!("PWD={cwd}"));
        }
    }

    envv.push("COLORTERM=lios".to_string());
    envv
}

fn should_strip_child_env(key: &str) -> bool {
    matches!(key, "COLUMNS" | "LINES" | "WINDOWID" | "COLORTERM" | "TERM")
}

fn background_widget(config: &BackgroundConfig) -> gtk::Widget {
    if let Some(path) = &config.image {
        let picture = gtk::Picture::for_filename(path);
        picture.set_hexpand(true);
        picture.set_vexpand(true);
        picture.set_can_shrink(true);
        picture.set_content_fit(gtk::ContentFit::Cover);
        picture.set_opacity(config.image_opacity);
        picture.upcast()
    } else {
        let base = gtk::Box::new(gtk::Orientation::Vertical, 0);
        base.set_hexpand(true);
        base.set_vexpand(true);
        base.upcast()
    }
}

fn overlay_color(config: &BackgroundConfig) -> Option<gdk::RGBA> {
    if config.overlay_opacity <= 0.0 {
        None
    } else if config.random_overlay {
        Some(random_accent_color())
    } else {
        config.overlay_color
    }
}

fn color_overlay_widget(color: gdk::RGBA, opacity: f64) -> gtk::DrawingArea {
    let area = gtk::DrawingArea::new();
    area.set_hexpand(true);
    area.set_vexpand(true);
    area.set_can_target(false);
    area.set_draw_func(move |_, cr, width, height| {
        cr.rectangle(0.0, 0.0, f64::from(width), f64::from(height));
        cr.set_source_rgba(
            f64::from(color.red()),
            f64::from(color.green()),
            f64::from(color.blue()),
            opacity,
        );
        let _ = cr.fill();
    });
    area
}

fn random_accent_color() -> gdk::RGBA {
    let hue = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_nanos() % 360)
        .unwrap_or(210) as f64
        / 360.0;

    let (red, green, blue) = hsv_to_rgb(hue, 0.62, 0.95);
    gdk::RGBA::new(red, green, blue, 1.0)
}

fn hsv_to_rgb(hue: f64, saturation: f64, value: f64) -> (f32, f32, f32) {
    let scaled = hue * 6.0;
    let sector = scaled.floor();
    let fraction = scaled - sector;
    let p = value * (1.0 - saturation);
    let q = value * (1.0 - fraction * saturation);
    let t = value * (1.0 - (1.0 - fraction) * saturation);

    let (red, green, blue) = match sector as u8 % 6 {
        0 => (value, t, p),
        1 => (q, value, p),
        2 => (p, value, t),
        3 => (p, q, value),
        4 => (t, p, value),
        _ => (value, p, q),
    };

    (red as f32, green as f32, blue as f32)
}