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;
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 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::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: 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.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.add_css_class("lios-terminal");
terminal.set_cursor_from_name(Some("default"));
terminal.set_clear_background(false);
keep_terminal_pointer_visible(&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("COLORTERM=lios".to_string());
envv
}
fn should_strip_child_env(key: &str) -> bool {
matches!(key, "COLUMNS" | "LINES" | "WINDOWID" | "COLORTERM" | "TERM")
}
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 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)
}