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;
const URL_MATCH_PATTERN: &str = r#"(?i)\b(?:https?://|file://)[^\s<>\"']+"#;
pub const CHILD_TERM: &str = "xterm-256color";
pub const CHILD_COLORTERM: &str = "truecolor";
pub const CHILD_TERM_PROGRAM: &str = "lios";
pub const CHILD_VTE_VERSION: &str = "7800";
pub const CHILD_IMAGE_PROTOCOL: &str = "sixel";
pub const MAX_SCROLLBACK_LINES: i64 = 100_000;
pub const DEFAULT_IMAGE_OPACITY: f64 = 1.0;
pub const DEFAULT_TERMINAL_OPACITY: f64 = 1.0;
pub const DEFAULT_IMAGE_TERMINAL_OPACITY: f64 = 0.50;
#[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 cursor_background: Option<String>,
pub cursor_foreground: Option<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(),
cursor_background: None,
cursor_foreground: None,
font: "Monospace 12".to_string(),
scrollback_lines: 1_000,
theme: TerminalTheme::named("xfce").expect("built-in theme exists"),
background: BackgroundConfig::default(),
}
}
}
impl Default for BackgroundConfig {
fn default() -> Self {
Self {
image: None,
image_opacity: DEFAULT_IMAGE_OPACITY,
terminal_opacity: DEFAULT_TERMINAL_OPACITY,
overlay_color: None,
overlay_opacity: 0.18,
random_overlay: true,
}
}
}
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.add_css_class("lios-terminal-root");
root.set_cursor_from_name(Some("default"));
root.set_child(Some(&background_widget(&config.theme, &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);
terminal.set_allow_hyperlink(true);
terminal.add_css_class("lios-terminal");
terminal.set_cursor_from_name(Some("default"));
terminal.set_clear_background(false);
keep_terminal_pointer_visible(&terminal);
install_link_opening(&terminal);
config.theme.with_background_alpha(0.0).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) -> >k::Overlay {
&self.root
}
pub fn terminal(&self) -> &vte::Terminal {
&self.terminal
}
pub fn focus(&self) {
self.terminal.grab_focus();
}
pub fn feed_text(&self, text: &str) {
self.terminal.feed(text.as_bytes());
}
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 _);
self.terminal.set_clear_background(false);
self.terminal.set_cursor_from_name(Some("default"));
config
.theme
.with_background_alpha(0.0)
.apply_to(&self.terminal);
self.root
.set_child(Some(&background_widget(&config.theme, &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(format!("TERM={CHILD_TERM}"));
envv.push(format!("COLORTERM={CHILD_COLORTERM}"));
envv.push(format!("TERM_PROGRAM={CHILD_TERM_PROGRAM}"));
envv.push(format!(
"TERM_PROGRAM_VERSION={}",
env!("CARGO_PKG_VERSION")
));
envv.push(format!("VTE_VERSION={CHILD_VTE_VERSION}"));
envv.push(format!("LIOS_IMAGE_PROTOCOL={CHILD_IMAGE_PROTOCOL}"));
envv
}
fn should_strip_child_env(key: &str) -> bool {
matches!(
key,
"COLUMNS"
| "LINES"
| "WINDOWID"
| "COLORTERM"
| "TERM"
| "TERM_PROGRAM"
| "TERM_PROGRAM_VERSION"
| "VTE_VERSION"
| "LIOS_IMAGE_PROTOCOL"
| "ITERM_SESSION_ID"
| "KITTY_WINDOW_ID"
| "KITTY_PID"
| "KITTY_LISTEN_ON"
| "LC_TERMINAL"
| "LC_TERMINAL_VERSION"
| "WEZTERM_EXECUTABLE"
| "WEZTERM_PANE"
| "WEZTERM_UNIX_SOCKET"
)
}
fn background_widget(theme: &TerminalTheme, config: &BackgroundConfig) -> gtk::Widget {
if let Some(path) = config.image.as_ref().filter(|path| path.is_file()) {
let background = gtk::Overlay::new();
background.set_hexpand(true);
background.set_vexpand(true);
background.set_can_target(false);
background.add_css_class("lios-terminal-root");
background.set_child(Some(&background_base_widget(theme.background_color())));
let picture = gtk::Picture::for_filename(path);
picture.set_hexpand(true);
picture.set_vexpand(true);
picture.set_can_shrink(true);
picture.set_can_target(false);
picture.set_content_fit(gtk::ContentFit::Cover);
picture.set_opacity(config.image_opacity);
background.add_overlay(&picture);
background.set_clip_overlay(&picture, true);
let shade =
terminal_shade_widget(theme.background_color(), effective_terminal_opacity(config));
background.add_overlay(&shade);
background.set_clip_overlay(&shade, true);
background.upcast()
} else {
let background = gtk::Overlay::new();
background.set_hexpand(true);
background.set_vexpand(true);
background.set_can_target(false);
background.add_css_class("lios-terminal-root");
if config.terminal_opacity >= 0.999 {
background.set_child(Some(&background_base_widget(theme.background_color())));
} else {
background.set_child(Some(&transparent_background_widget()));
}
let shade = terminal_shade_widget(theme.background_color(), config.terminal_opacity);
background.add_overlay(&shade);
background.set_clip_overlay(&shade, true);
background.upcast()
}
}
fn background_base_widget(color: gdk::RGBA) -> gtk::DrawingArea {
let base = gtk::DrawingArea::new();
base.set_hexpand(true);
base.set_vexpand(true);
base.set_can_target(false);
base.set_draw_func(move |_, cr, width, height| {
cr.rectangle(0.0, 0.0, f64::from(width), f64::from(height));
cr.set_source_rgb(
f64::from(color.red()),
f64::from(color.green()),
f64::from(color.blue()),
);
let _ = cr.fill();
});
base
}
fn transparent_background_widget() -> gtk::DrawingArea {
let base = gtk::DrawingArea::new();
base.set_hexpand(true);
base.set_vexpand(true);
base.set_can_target(false);
base.set_draw_func(|_, cr, width, height| {
cr.save().ok();
cr.set_operator(gtk::cairo::Operator::Clear);
cr.rectangle(0.0, 0.0, f64::from(width), f64::from(height));
let _ = cr.fill();
cr.restore().ok();
});
base
}
fn terminal_shade_widget(color: gdk::RGBA, opacity: f64) -> gtk::DrawingArea {
let shade = gtk::DrawingArea::new();
shade.set_hexpand(true);
shade.set_vexpand(true);
shade.set_can_target(false);
shade.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();
});
shade
}
pub fn effective_terminal_opacity(config: &BackgroundConfig) -> f64 {
if config.image.is_some() && config.terminal_opacity >= 0.999 {
DEFAULT_IMAGE_TERMINAL_OPACITY
} else {
config.terminal_opacity
}
}
fn keep_terminal_pointer_visible(terminal: &vte::Terminal) {
let motion = gtk::EventControllerMotion::new();
let terminal_for_enter = terminal.downgrade();
motion.connect_enter(move |_, _, _| {
if let Some(terminal) = terminal_for_enter.upgrade() {
terminal.set_mouse_autohide(false);
terminal.set_cursor_from_name(Some("default"));
}
});
terminal.add_controller(motion);
}
fn install_link_opening(terminal: &vte::Terminal) {
match vte::Regex::for_match(URL_MATCH_PATTERN, 0) {
Ok(regex) => {
let _ = regex.jit(0);
let tag = terminal.match_add_regex(®ex, 0);
terminal.match_set_cursor_name(tag, "pointer");
}
Err(error) => eprintln!("failed to compile terminal URL matcher: {error}"),
}
let click = gtk::GestureClick::new();
click.set_button(1);
let terminal_for_click = terminal.downgrade();
click.connect_released(move |gesture, n_press, x, y| {
if n_press != 1 {
return;
}
let Some(terminal) = terminal_for_click.upgrade() else {
return;
};
let Some(uri) = terminal_uri_at(&terminal, x, y) else {
return;
};
gesture.set_state(gtk::EventSequenceState::Claimed);
open_uri(&uri);
});
terminal.add_controller(click);
}
fn terminal_uri_at(terminal: &vte::Terminal, x: f64, y: f64) -> Option<String> {
terminal
.check_hyperlink_at(x, y)
.and_then(|uri| launchable_uri(uri.as_str()))
.or_else(|| {
let (uri, _) = terminal.check_match_at(x, y);
uri.and_then(|uri| launchable_uri(uri.as_str()))
})
}
fn launchable_uri(value: &str) -> Option<String> {
let uri = trim_matched_uri(value);
if is_supported_launch_uri(uri) {
Some(uri.to_string())
} else {
None
}
}
fn is_supported_launch_uri(uri: &str) -> bool {
let uri = uri.to_ascii_lowercase();
uri.starts_with("http://") || uri.starts_with("https://") || uri.starts_with("file://")
}
fn trim_matched_uri(value: &str) -> &str {
value.trim().trim_end_matches(|ch| {
matches!(
ch,
'.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '>' | '"' | '\''
)
})
}
fn open_uri(uri: &str) {
let launcher = gtk::UriLauncher::new(uri);
launcher.launch(None::<>k::Window>, gio::Cancellable::NONE, |result| {
if let Err(error) = result {
eprintln!("failed to open link: {error}");
}
});
}
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)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn child_environment_exports_terminal_identity() {
let envv = child_environment(Some("/tmp"));
assert_eq!(env_values(&envv, "TERM"), vec![CHILD_TERM]);
assert_eq!(env_values(&envv, "COLORTERM"), vec![CHILD_COLORTERM]);
assert_eq!(env_values(&envv, "TERM_PROGRAM"), vec![CHILD_TERM_PROGRAM]);
assert_eq!(env_values(&envv, "TERM_PROGRAM_VERSION").len(), 1);
assert_eq!(env_values(&envv, "VTE_VERSION"), vec![CHILD_VTE_VERSION]);
assert_eq!(env_values(&envv, "LIOS_IMAGE_PROTOCOL"), vec!["sixel"]);
assert_eq!(env_values(&envv, "PWD"), vec!["/tmp"]);
}
#[test]
fn child_environment_strips_protocol_spoofing_keys() {
for key in [
"KITTY_WINDOW_ID",
"KITTY_PID",
"KITTY_LISTEN_ON",
"WEZTERM_EXECUTABLE",
"WEZTERM_PANE",
"WEZTERM_UNIX_SOCKET",
"ITERM_SESSION_ID",
"LC_TERMINAL",
"LC_TERMINAL_VERSION",
"TERM_PROGRAM",
"TERM_PROGRAM_VERSION",
] {
assert!(should_strip_child_env(key), "{key} should be stripped");
}
assert!(!should_strip_child_env("PATH"));
assert!(!should_strip_child_env("SHELL"));
}
#[test]
fn launchable_uri_accepts_local_http_url() {
assert_eq!(
launchable_uri("http://localhost:3000").as_deref(),
Some("http://localhost:3000")
);
}
#[test]
fn launchable_uri_trims_terminal_punctuation() {
assert_eq!(
launchable_uri(" https://example.test/path). ").as_deref(),
Some("https://example.test/path")
);
}
#[test]
fn launchable_uri_rejects_unsupported_scheme() {
assert!(launchable_uri("javascript:alert(1)").is_none());
}
#[test]
fn effective_terminal_opacity_dims_opaque_image_backgrounds() {
let mut config = BackgroundConfig::default();
config.image = Some(PathBuf::from("background.png"));
config.terminal_opacity = 1.0;
assert_eq!(
effective_terminal_opacity(&config),
DEFAULT_IMAGE_TERMINAL_OPACITY
);
}
#[test]
fn effective_terminal_opacity_preserves_explicit_image_shade() {
let mut config = BackgroundConfig::default();
config.image = Some(PathBuf::from("background.png"));
config.terminal_opacity = 0.42;
assert_eq!(effective_terminal_opacity(&config), 0.42);
}
fn env_values<'a>(envv: &'a [String], key: &str) -> Vec<&'a str> {
let prefix = format!("{key}=");
envv.iter()
.filter_map(|entry| entry.strip_prefix(&prefix))
.collect()
}
}