init
This commit is contained in:
+606
@@ -0,0 +1,606 @@
|
||||
use crate::{
|
||||
config::{self, Action, Config},
|
||||
input,
|
||||
layout::{Axis, Direction, Layout, PaneId, Tab},
|
||||
pane::{self, Pane},
|
||||
render::color,
|
||||
};
|
||||
use anyhow::{Result, ensure};
|
||||
use egui::{Color32, Rect, Stroke, vec2};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
pub struct App {
|
||||
config: Config,
|
||||
config_path: PathBuf,
|
||||
panes: BTreeMap<PaneId, Pane>,
|
||||
tabs: Vec<Tab>,
|
||||
current: usize,
|
||||
next_id: u64,
|
||||
error: Option<String>,
|
||||
suppressed: HashSet<egui::Key>,
|
||||
}
|
||||
|
||||
pub fn fonts(config: &Config) -> Result<egui::FontDefinitions> {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
if let Some(path) = &config.font_path {
|
||||
let data = std::fs::read(path)?;
|
||||
fonts
|
||||
.font_data
|
||||
.insert("terminal".into(), egui::FontData::from_owned(data).into());
|
||||
fonts
|
||||
.families
|
||||
.get_mut(&egui::FontFamily::Monospace)
|
||||
.unwrap()
|
||||
.insert(0, "terminal".into());
|
||||
}
|
||||
Ok(fonts)
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(
|
||||
cc: &eframe::CreationContext<'_>,
|
||||
config: Config,
|
||||
config_path: PathBuf,
|
||||
) -> Result<Self> {
|
||||
cc.egui_ctx.set_visuals(egui::Visuals::dark());
|
||||
cc.egui_ctx.set_fonts(fonts(&config)?);
|
||||
let pane = Pane::new(&config, &cc.egui_ctx)?;
|
||||
Ok(Self {
|
||||
config,
|
||||
config_path,
|
||||
panes: BTreeMap::from([(1, pane)]),
|
||||
tabs: vec![Tab::new(1, 1)],
|
||||
current: 0,
|
||||
next_id: 2,
|
||||
error: None,
|
||||
suppressed: HashSet::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn(&mut self, ctx: &egui::Context) -> Result<PaneId> {
|
||||
ensure!(
|
||||
self.panes.len() < self.config.multiplexer.max_panes,
|
||||
"Pane limit reached"
|
||||
);
|
||||
let pane = Pane::new(&self.config, ctx)?;
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
self.panes.insert(id, pane);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn action(&mut self, action: Action, ctx: &egui::Context) -> Result<()> {
|
||||
let active = self.tabs[self.current].active;
|
||||
match action {
|
||||
Action::NewPane => {
|
||||
let action = if self.tabs[self.current].floats_visible {
|
||||
Action::NewFloating
|
||||
} else {
|
||||
let rect = self.panes[&active].last_rect;
|
||||
if rect.is_positive() && rect.height() > rect.width() {
|
||||
Action::SplitDown
|
||||
} else {
|
||||
Action::SplitRight
|
||||
}
|
||||
};
|
||||
self.action(action, ctx)?;
|
||||
}
|
||||
Action::SplitRight | Action::SplitDown => {
|
||||
let id = self.spawn(ctx)?;
|
||||
let tab = &mut self.tabs[self.current];
|
||||
let axis = if action == Action::SplitRight {
|
||||
Axis::Horizontal
|
||||
} else {
|
||||
Axis::Vertical
|
||||
};
|
||||
if let Some(root) = &mut tab.root {
|
||||
root.split(
|
||||
if tab.floats.contains(&active) {
|
||||
root.first()
|
||||
} else {
|
||||
active
|
||||
},
|
||||
id,
|
||||
axis,
|
||||
);
|
||||
} else {
|
||||
tab.root = Some(Layout::Pane(id));
|
||||
}
|
||||
tab.active = id;
|
||||
tab.zoomed = false;
|
||||
}
|
||||
Action::NewFloating => {
|
||||
let id = self.spawn(ctx)?;
|
||||
let tab = &mut self.tabs[self.current];
|
||||
tab.floats.push(id);
|
||||
tab.active = id;
|
||||
tab.floats_visible = true;
|
||||
tab.zoomed = false;
|
||||
}
|
||||
Action::ToggleFloating => {
|
||||
let tab = &mut self.tabs[self.current];
|
||||
let was_floating = tab.floats.contains(&active);
|
||||
tab.detach(active);
|
||||
if was_floating {
|
||||
if let Some(root) = &mut tab.root {
|
||||
root.split(root.first(), active, Axis::Horizontal);
|
||||
} else {
|
||||
tab.root = Some(Layout::Pane(active));
|
||||
}
|
||||
} else {
|
||||
tab.floats.push(active);
|
||||
tab.floats_visible = true;
|
||||
}
|
||||
}
|
||||
Action::ToggleFloats => {
|
||||
if self.tabs[self.current].floats.is_empty() {
|
||||
self.action(Action::NewFloating, ctx)?;
|
||||
} else {
|
||||
self.tabs[self.current].toggle_floats();
|
||||
}
|
||||
}
|
||||
Action::FocusLeft | Action::FocusDown | Action::FocusUp | Action::FocusRight => {
|
||||
let direction = match action {
|
||||
Action::FocusLeft => Direction::Left,
|
||||
Action::FocusDown => Direction::Down,
|
||||
Action::FocusUp => Direction::Up,
|
||||
_ => Direction::Right,
|
||||
};
|
||||
let rects = self
|
||||
.panes
|
||||
.iter()
|
||||
.map(|(id, pane)| (*id, pane.last_rect))
|
||||
.collect::<Vec<_>>();
|
||||
self.tabs[self.current].focus_direction(direction, &rects);
|
||||
}
|
||||
Action::NextPane | Action::PreviousPane => {
|
||||
self.tabs[self.current].cycle(action == Action::PreviousPane)
|
||||
}
|
||||
Action::ClosePane => self.close(active, ctx),
|
||||
Action::ZoomPane => {
|
||||
let tab = &mut self.tabs[self.current];
|
||||
if tab.is_visible(tab.active) {
|
||||
tab.zoomed = !tab.zoomed;
|
||||
}
|
||||
}
|
||||
Action::NewTab => {
|
||||
let id = self.spawn(ctx)?;
|
||||
self.tabs.push(Tab::new(id, id));
|
||||
self.current = self.tabs.len() - 1;
|
||||
}
|
||||
Action::NextTab => self.current = (self.current + 1) % self.tabs.len(),
|
||||
Action::PreviousTab => {
|
||||
self.current = (self.current + self.tabs.len() - 1) % self.tabs.len()
|
||||
}
|
||||
Action::ReloadConfig => {
|
||||
let config = Config::load(&self.config_path)?;
|
||||
let fonts = fonts(&config)?;
|
||||
for pane in self.panes.values_mut() {
|
||||
pane::apply_config(&mut pane.terminal, &config)?;
|
||||
}
|
||||
ctx.set_fonts(fonts);
|
||||
self.config = config;
|
||||
}
|
||||
Action::Copy => {
|
||||
if let Some(text) = self.panes[&active].copy()? {
|
||||
ctx.copy_text(text);
|
||||
}
|
||||
}
|
||||
Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close(&mut self, id: PaneId, ctx: &egui::Context) {
|
||||
self.panes.remove(&id);
|
||||
for tab in &mut self.tabs {
|
||||
if !tab.ids().contains(&id) {
|
||||
continue;
|
||||
}
|
||||
tab.detach(id);
|
||||
if tab.active == id
|
||||
&& let Some(next) = tab.ids().first()
|
||||
{
|
||||
tab.active = *next;
|
||||
}
|
||||
if tab.root.is_none() {
|
||||
tab.floats_visible = true;
|
||||
}
|
||||
}
|
||||
self.tabs.retain(|t| !t.ids().is_empty());
|
||||
self.current = self.current.min(self.tabs.len().saturating_sub(1));
|
||||
if self.tabs.is_empty() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
|
||||
fn keyboard(&mut self, ctx: &egui::Context) -> Result<()> {
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
let modifiers = ctx.input(|i| i.modifiers);
|
||||
let mut terminal_events = vec![];
|
||||
let mut suppress_text = false;
|
||||
for event in events {
|
||||
if let egui::Event::Key {
|
||||
key,
|
||||
pressed,
|
||||
repeat,
|
||||
modifiers,
|
||||
..
|
||||
} = &event
|
||||
{
|
||||
suppress_text = false;
|
||||
if !pressed && self.suppressed.remove(key) {
|
||||
continue;
|
||||
}
|
||||
let action = self.config.keys.iter().find_map(|(s, a)| {
|
||||
let shortcut = config::shortcut(s).ok()?;
|
||||
let m = shortcut.modifiers;
|
||||
(shortcut.logical_key == *key
|
||||
&& m.ctrl == modifiers.ctrl
|
||||
&& m.shift == modifiers.shift
|
||||
&& m.alt == modifiers.alt
|
||||
&& m.mac_cmd == modifiers.mac_cmd)
|
||||
.then_some(*a)
|
||||
});
|
||||
if *pressed && let Some(action) = action {
|
||||
self.flush_keys(&mut terminal_events)?;
|
||||
self.suppressed.insert(*key);
|
||||
suppress_text = true;
|
||||
if !repeat {
|
||||
self.action(action, ctx)?;
|
||||
}
|
||||
if self.tabs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if suppress_text && matches!(event, egui::Event::Text(_)) {
|
||||
continue;
|
||||
}
|
||||
let active = self.tabs[self.current].active;
|
||||
if !self.tabs[self.current].is_visible(active) {
|
||||
continue;
|
||||
}
|
||||
match event {
|
||||
egui::Event::Paste(text) => {
|
||||
self.flush_keys(&mut terminal_events)?;
|
||||
self.panes.get_mut(&active).unwrap().paste(&text)?;
|
||||
}
|
||||
egui::Event::Copy => {
|
||||
self.flush_keys(&mut terminal_events)?;
|
||||
if modifiers.shift || modifiers.mac_cmd {
|
||||
self.action(Action::Copy, ctx)?;
|
||||
} else {
|
||||
self.panes.get_mut(&active).unwrap().send(vec![3])?;
|
||||
}
|
||||
}
|
||||
egui::Event::Cut => {
|
||||
self.flush_keys(&mut terminal_events)?;
|
||||
self.panes.get_mut(&active).unwrap().send(vec![24])?;
|
||||
}
|
||||
other => terminal_events.push(other),
|
||||
}
|
||||
}
|
||||
self.flush_keys(&mut terminal_events)
|
||||
}
|
||||
|
||||
fn flush_keys(&mut self, events: &mut Vec<egui::Event>) -> Result<()> {
|
||||
let active = self.tabs[self.current].active;
|
||||
if !self.tabs[self.current].is_visible(active) {
|
||||
events.clear();
|
||||
return Ok(());
|
||||
}
|
||||
let pane = self.panes.get_mut(&active).unwrap();
|
||||
let encoded = input::encode(&pane.terminal, events)?;
|
||||
pane.send(encoded)?;
|
||||
events.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if self.tabs.is_empty() {
|
||||
return;
|
||||
}
|
||||
for pane in self.panes.values_mut() {
|
||||
pane.drain(ctx);
|
||||
}
|
||||
if let Err(e) = self.keyboard(ctx) {
|
||||
self.error = Some(e.to_string());
|
||||
}
|
||||
if self.tabs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut action = None;
|
||||
egui::TopBottomPanel::top("tabs").show(ctx, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (index, tab) in self.tabs.iter().enumerate() {
|
||||
let title = self.panes[&tab.active].terminal.title().unwrap_or("");
|
||||
let label = if title.is_empty() {
|
||||
format!("{}: shell", index + 1)
|
||||
} else {
|
||||
format!(
|
||||
"{}: {}",
|
||||
index + 1,
|
||||
title.chars().take(28).collect::<String>()
|
||||
)
|
||||
};
|
||||
if ui.selectable_label(self.current == index, label).clicked() {
|
||||
self.current = index;
|
||||
}
|
||||
}
|
||||
if ui.button("+").on_hover_text("New tab").clicked() {
|
||||
action = Some(Action::NewTab);
|
||||
}
|
||||
ui.menu_button("Pane", |ui| {
|
||||
for (label, a) in [
|
||||
("New pane", Action::NewPane),
|
||||
("Split right", Action::SplitRight),
|
||||
("Split down", Action::SplitDown),
|
||||
("New floating pane", Action::NewFloating),
|
||||
("Float / dock", Action::ToggleFloating),
|
||||
("Show / hide floating panes", Action::ToggleFloats),
|
||||
("Zoom / restore", Action::ZoomPane),
|
||||
("Next pane", Action::NextPane),
|
||||
("Close pane", Action::ClosePane),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
action = Some(a);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.menu_button("Settings", |ui| {
|
||||
if ui.button("Reload config").clicked() {
|
||||
action = Some(Action::ReloadConfig);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(error) = self.error.clone() {
|
||||
egui::TopBottomPanel::bottom("error").show(ctx, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.colored_label(Color32::LIGHT_RED, error);
|
||||
if ui.button("Dismiss").clicked() {
|
||||
self.error = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
let bg = color(config::rgb(&self.config.background).unwrap());
|
||||
let tab = &mut self.tabs[self.current];
|
||||
let mut tiled = vec![];
|
||||
let mut workspace = Rect::NOTHING;
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame::NONE.fill(bg))
|
||||
.show(ctx, |ui| {
|
||||
workspace = ui.max_rect();
|
||||
if tab.zoomed {
|
||||
tiled.push((tab.active, workspace));
|
||||
} else if let Some(root) = &mut tab.root {
|
||||
root.arrange(ui, workspace, &mut tiled, 1);
|
||||
}
|
||||
for (id, rect) in &tiled {
|
||||
let pane = self.panes.get_mut(id).unwrap();
|
||||
let active = tab.active == *id;
|
||||
if !tab.zoomed {
|
||||
pane.last_rect = *rect;
|
||||
}
|
||||
if show_pane(ui, *id, pane, *rect, &self.config, active) {
|
||||
tab.active = *id;
|
||||
}
|
||||
}
|
||||
});
|
||||
if tab.floats_visible && !tab.zoomed {
|
||||
for id in tab.floats.clone() {
|
||||
let pane = self.panes.get_mut(&id).unwrap();
|
||||
let window_id = egui::Id::new(("float", tab.id, id));
|
||||
let active = tab.active == id;
|
||||
let result = egui::Window::new(pane_title(id, pane))
|
||||
.id(window_id)
|
||||
.title_bar(false)
|
||||
.constrain_to(workspace)
|
||||
.default_pos(
|
||||
workspace.min
|
||||
+ vec2(40.0 + (id % 5) as f32 * 22.0, 40.0 + (id % 5) as f32 * 22.0),
|
||||
)
|
||||
.default_size(
|
||||
workspace.size()
|
||||
* vec2(
|
||||
self.config.multiplexer.floating_width,
|
||||
self.config.multiplexer.floating_height,
|
||||
),
|
||||
)
|
||||
.min_size(vec2(160.0, 100.0))
|
||||
.frame(egui::Frame::NONE.fill(bg))
|
||||
.show(ctx, |ui| {
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
ui.allocate_rect(rect, egui::Sense::hover());
|
||||
pane.last_rect = rect;
|
||||
if show_pane(ui, id, pane, rect, &self.config, active) {
|
||||
tab.active = id;
|
||||
}
|
||||
});
|
||||
if let Some(result) = result
|
||||
&& result.response.contains_pointer()
|
||||
&& ctx.input(|i| i.pointer.any_pressed())
|
||||
{
|
||||
tab.active = id;
|
||||
}
|
||||
if active {
|
||||
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, window_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
let active = tab.active;
|
||||
for (id, pane) in &mut self.panes {
|
||||
if let Err(e) =
|
||||
pane.focus(*id == active && tab.is_visible(*id) && ctx.input(|i| i.focused))
|
||||
{
|
||||
pane.error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
if !self.tabs.is_empty()
|
||||
&& let Some(action) = action
|
||||
&& let Err(e) = self.action(action, ctx)
|
||||
{
|
||||
self.error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_pane(
|
||||
ui: &mut egui::Ui,
|
||||
id: PaneId,
|
||||
pane: &mut Pane,
|
||||
rect: Rect,
|
||||
config: &Config,
|
||||
active: bool,
|
||||
) -> bool {
|
||||
let accent = color(config::rgb(&config.accent).unwrap());
|
||||
let title_rect = Rect::from_min_size(rect.min, vec2(rect.width(), 25.0_f32.min(rect.height())));
|
||||
ui.painter().rect_filled(
|
||||
title_rect,
|
||||
0,
|
||||
Color32::from_gray(if active { 43 } else { 31 }),
|
||||
);
|
||||
ui.painter()
|
||||
.with_clip_rect(title_rect.shrink(3.0).intersect(ui.clip_rect()))
|
||||
.text(
|
||||
title_rect.min + vec2(7.0, 4.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
pane_title(id, pane),
|
||||
egui::FontId::proportional(13.0),
|
||||
if active { accent } else { Color32::GRAY },
|
||||
);
|
||||
// Click-only leaves dragging to the floating window's existing move handler.
|
||||
let header = ui.interact(
|
||||
title_rect,
|
||||
ui.id().with(("title", id)),
|
||||
egui::Sense::click(),
|
||||
);
|
||||
let mut focused =
|
||||
header.clicked() || (header.contains_pointer() && ui.input(|i| i.pointer.any_pressed()));
|
||||
let content = Rect::from_min_max(egui::pos2(rect.left(), title_rect.bottom()), rect.max);
|
||||
// The pane rectangle is already allocated; a layout scope would grow its parent.
|
||||
let mut content_ui = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(content));
|
||||
content_ui.set_clip_rect(content.intersect(ui.clip_rect()));
|
||||
match pane.show(&mut content_ui, content, config, active) {
|
||||
Ok(clicked) => focused |= clicked,
|
||||
Err(e) => pane.error = Some(e.to_string()),
|
||||
}
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
0,
|
||||
Stroke::new(
|
||||
1.0,
|
||||
if active {
|
||||
accent
|
||||
} else {
|
||||
Color32::from_gray(54)
|
||||
},
|
||||
),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
focused
|
||||
}
|
||||
|
||||
fn pane_title(id: PaneId, pane: &Pane) -> String {
|
||||
let title = pane.terminal.title().unwrap_or("");
|
||||
format!(
|
||||
"{id} {}{}",
|
||||
if title.is_empty() { "shell" } else { title },
|
||||
if pane.dead { " [exited]" } else { "" }
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_app(ctx: &egui::Context) -> App {
|
||||
let config = Config {
|
||||
shell: vec!["/bin/sh".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let pane = Pane::new(&config, ctx).unwrap();
|
||||
App {
|
||||
config,
|
||||
config_path: PathBuf::new(),
|
||||
panes: BTreeMap::from([(1, pane)]),
|
||||
tabs: vec![Tab::new(1, 1)],
|
||||
current: 0,
|
||||
next_id: 2,
|
||||
error: None,
|
||||
suppressed: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_pane_size_stays_constant_across_frames() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = test_app(&ctx);
|
||||
let mut sizes = vec![];
|
||||
for frame in 0..20 {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))),
|
||||
time: Some(frame as f64 / 60.0),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = ctx.run(input, |ctx| {
|
||||
let window = egui::Window::new("float")
|
||||
.title_bar(false)
|
||||
.default_size(vec2(400.0, 240.0))
|
||||
.frame(egui::Frame::NONE)
|
||||
.show(ctx, |ui| {
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
ui.allocate_rect(rect, egui::Sense::hover());
|
||||
show_pane(
|
||||
ui,
|
||||
1,
|
||||
app.panes.get_mut(&1).unwrap(),
|
||||
rect,
|
||||
&app.config,
|
||||
true,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
if frame >= 2 {
|
||||
sizes.push(window.response.rect.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
assert!(
|
||||
sizes.iter().all(|size| *size == sizes[0]),
|
||||
"floating pane grew: {sizes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_creates_first_float_then_reuses_it_and_close_removes_focus() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = test_app(&ctx);
|
||||
app.action(Action::ToggleFloats, &ctx).unwrap();
|
||||
assert_eq!(app.tabs[0].floats, vec![2]);
|
||||
assert_eq!(app.tabs[0].active, 2);
|
||||
assert!(app.tabs[0].floats_visible);
|
||||
app.action(Action::ToggleFloats, &ctx).unwrap();
|
||||
assert!(!app.tabs[0].floats_visible);
|
||||
assert_eq!(app.tabs[0].active, 1);
|
||||
app.action(Action::ToggleFloats, &ctx).unwrap();
|
||||
assert_eq!(app.panes.len(), 2);
|
||||
assert_eq!(app.tabs[0].active, 2);
|
||||
app.action(Action::ClosePane, &ctx).unwrap();
|
||||
assert_eq!(app.tabs[0].active, 1);
|
||||
assert!(!app.panes.contains_key(&2));
|
||||
app.action(Action::ToggleFloats, &ctx).unwrap();
|
||||
assert_eq!(app.tabs[0].floats, vec![3]);
|
||||
assert!(app.tabs[0].floats_visible);
|
||||
}
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub const EXAMPLE: &str = include_str!("../config.example.toml");
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub shell: Vec<String>,
|
||||
pub working_directory: Option<PathBuf>,
|
||||
pub font_size: f32,
|
||||
pub font_path: Option<PathBuf>,
|
||||
pub scrollback: usize,
|
||||
pub window_width: f32,
|
||||
pub window_height: f32,
|
||||
pub background: String,
|
||||
pub foreground: String,
|
||||
pub accent: String,
|
||||
pub kitty: Kitty,
|
||||
pub multiplexer: Multiplexer,
|
||||
pub keys: BTreeMap<String, Action>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Kitty {
|
||||
pub image_storage_mb: u64,
|
||||
pub allow_file: bool,
|
||||
pub allow_temp_file: bool,
|
||||
pub allow_shared_memory: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Multiplexer {
|
||||
pub max_panes: usize,
|
||||
pub floating_width: f32,
|
||||
pub floating_height: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Action {
|
||||
SplitRight,
|
||||
SplitDown,
|
||||
NewFloating,
|
||||
NewPane,
|
||||
ToggleFloating,
|
||||
ToggleFloats,
|
||||
NextPane,
|
||||
PreviousPane,
|
||||
FocusLeft,
|
||||
FocusDown,
|
||||
FocusUp,
|
||||
FocusRight,
|
||||
ClosePane,
|
||||
ZoomPane,
|
||||
NewTab,
|
||||
NextTab,
|
||||
PreviousTab,
|
||||
ReloadConfig,
|
||||
Copy,
|
||||
Paste,
|
||||
}
|
||||
|
||||
impl Default for Kitty {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
image_storage_mb: 64,
|
||||
allow_file: true,
|
||||
allow_temp_file: true,
|
||||
allow_shared_memory: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Default for Multiplexer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_panes: 32,
|
||||
floating_width: 0.65,
|
||||
floating_height: 0.65,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
shell: vec![std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())],
|
||||
working_directory: None,
|
||||
font_size: 16.0,
|
||||
font_path: None,
|
||||
scrollback: 10000,
|
||||
window_width: 1100.0,
|
||||
window_height: 720.0,
|
||||
background: "#17191c".into(),
|
||||
foreground: "#e2e4e8".into(),
|
||||
accent: "#58c4ad".into(),
|
||||
kitty: Kitty::default(),
|
||||
multiplexer: Multiplexer::default(),
|
||||
keys: [
|
||||
("Alt+F", Action::ToggleFloats),
|
||||
("Alt+M", Action::NewPane),
|
||||
("Alt+H", Action::FocusLeft),
|
||||
("Alt+J", Action::FocusDown),
|
||||
("Alt+K", Action::FocusUp),
|
||||
("Alt+L", Action::FocusRight),
|
||||
("Alt+A", Action::NewTab),
|
||||
("Alt+Shift+H", Action::PreviousTab),
|
||||
("Alt+Shift+L", Action::NextTab),
|
||||
("Alt+X", Action::ClosePane),
|
||||
("Ctrl+Shift+Z", Action::ZoomPane),
|
||||
("Ctrl+Shift+R", Action::ReloadConfig),
|
||||
("Ctrl+Shift+C", Action::Copy),
|
||||
("Ctrl+Shift+V", Action::Paste),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.into(), v))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn path() -> Result<PathBuf> {
|
||||
if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME").filter(|p| !p.is_empty()) {
|
||||
return Ok(PathBuf::from(dir).join("rust-term/config.toml"));
|
||||
}
|
||||
Ok(
|
||||
PathBuf::from(std::env::var_os("HOME").context("HOME is not set")?)
|
||||
.join(".config/rust-term/config.toml"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => {
|
||||
Self::parse(&text).with_context(|| format!("Invalid config: {}", path.display()))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
|
||||
Err(e) => Err(e).with_context(|| format!("Cannot read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
let config: Self = toml::from_str(text)?;
|
||||
ensure!(
|
||||
!config.shell.is_empty() && !config.shell[0].is_empty(),
|
||||
"shell must contain an executable"
|
||||
);
|
||||
ensure!(
|
||||
(8.0..=48.0).contains(&config.font_size),
|
||||
"font_size must be 8..48"
|
||||
);
|
||||
ensure!(
|
||||
(320.0..=16384.0).contains(&config.window_width)
|
||||
&& (200.0..=16384.0).contains(&config.window_height),
|
||||
"invalid window dimensions"
|
||||
);
|
||||
ensure!(
|
||||
config.scrollback <= 1_000_000,
|
||||
"scrollback must be <= 1000000"
|
||||
);
|
||||
ensure!(
|
||||
(1..=1024).contains(&config.kitty.image_storage_mb),
|
||||
"image_storage_mb must be 1..1024"
|
||||
);
|
||||
ensure!(
|
||||
(1..=128).contains(&config.multiplexer.max_panes),
|
||||
"max_panes must be 1..128"
|
||||
);
|
||||
for fraction in [
|
||||
config.multiplexer.floating_width,
|
||||
config.multiplexer.floating_height,
|
||||
] {
|
||||
ensure!(
|
||||
(0.2..=0.95).contains(&fraction),
|
||||
"floating dimensions must be fractions in 0.2..0.95"
|
||||
);
|
||||
}
|
||||
for color in [&config.background, &config.foreground, &config.accent] {
|
||||
rgb(color)?;
|
||||
}
|
||||
for key in config.keys.keys() {
|
||||
shortcut(key)?;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rgb(text: &str) -> Result<libghostty_vt::style::RgbColor> {
|
||||
ensure!(
|
||||
text.len() == 7
|
||||
&& text.starts_with('#')
|
||||
&& text[1..].bytes().all(|c| c.is_ascii_hexdigit()),
|
||||
"color must be #RRGGBB: {text}"
|
||||
);
|
||||
let value = u32::from_str_radix(&text[1..], 16)?;
|
||||
Ok(libghostty_vt::style::RgbColor {
|
||||
r: (value >> 16) as u8,
|
||||
g: (value >> 8) as u8,
|
||||
b: value as u8,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn shortcut(text: &str) -> Result<egui::KeyboardShortcut> {
|
||||
let mut mods = egui::Modifiers::NONE;
|
||||
let parts: Vec<_> = text.split('+').collect();
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
match *part {
|
||||
"Ctrl" => mods.ctrl = true,
|
||||
"Shift" => mods.shift = true,
|
||||
"Alt" => mods.alt = true,
|
||||
"Super" => {
|
||||
mods.mac_cmd = true;
|
||||
mods.command = true;
|
||||
}
|
||||
_ => bail!("unknown modifier in {text}"),
|
||||
}
|
||||
}
|
||||
let key = egui::Key::from_name(parts[parts.len() - 1])
|
||||
.with_context(|| format!("unknown key in {text}"))?;
|
||||
Ok(egui::KeyboardShortcut::new(mods, key))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_example_and_rejects_bad_values() {
|
||||
let example = Config::parse(EXAMPLE).unwrap();
|
||||
assert_eq!(example.keys, Config::default().keys);
|
||||
for text in [
|
||||
"font_size = nan",
|
||||
"shell = []",
|
||||
"background = '#xx0000'",
|
||||
"font_sze = 16",
|
||||
"[keys]\n'Hyper+Q' = 'close_pane'",
|
||||
"[multiplexer]\nfloating_width = 2",
|
||||
] {
|
||||
assert!(Config::parse(text).is_err(), "accepted {text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_shortcuts_match_pane_and_tab_actions() {
|
||||
let config = Config::default();
|
||||
for (binding, action) in [
|
||||
("Alt+F", Action::ToggleFloats),
|
||||
("Alt+M", Action::NewPane),
|
||||
("Alt+H", Action::FocusLeft),
|
||||
("Alt+J", Action::FocusDown),
|
||||
("Alt+K", Action::FocusUp),
|
||||
("Alt+L", Action::FocusRight),
|
||||
("Alt+A", Action::NewTab),
|
||||
("Alt+Shift+H", Action::PreviousTab),
|
||||
("Alt+Shift+L", Action::NextTab),
|
||||
("Alt+X", Action::ClosePane),
|
||||
] {
|
||||
assert_eq!(config.keys.get(binding), Some(&action));
|
||||
assert!(shortcut(binding).unwrap().modifiers.alt);
|
||||
}
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
use anyhow::Result;
|
||||
use libghostty_vt::{Terminal, key};
|
||||
|
||||
pub fn mods(m: egui::Modifiers) -> key::Mods {
|
||||
let mut out = key::Mods::empty();
|
||||
if m.ctrl {
|
||||
out |= key::Mods::CTRL;
|
||||
}
|
||||
if m.shift {
|
||||
out |= key::Mods::SHIFT;
|
||||
}
|
||||
if m.alt {
|
||||
out |= key::Mods::ALT;
|
||||
}
|
||||
if m.mac_cmd {
|
||||
out |= key::Mods::SUPER;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result<Vec<u8>> {
|
||||
let mut encoder = key::Encoder::new()?;
|
||||
encoder.set_options_from_terminal(terminal);
|
||||
let mut event = key::Event::new()?;
|
||||
let mut out = vec![];
|
||||
let mut used_text = std::collections::HashSet::new();
|
||||
for (index, e) in events.iter().enumerate() {
|
||||
match e {
|
||||
egui::Event::Key {
|
||||
key: k,
|
||||
physical_key,
|
||||
pressed,
|
||||
repeat,
|
||||
modifiers,
|
||||
} => {
|
||||
let (logical, ucp) = map(*k);
|
||||
let (physical, _) = map(physical_key.unwrap_or(*k));
|
||||
let mut text = None;
|
||||
if *pressed && printable(*k) {
|
||||
// winit emits text immediately after its key event. Stop at the next key
|
||||
// so a burst of distinct keys cannot consume each other's text.
|
||||
for (i, next) in events.iter().enumerate().skip(index + 1) {
|
||||
if matches!(next, egui::Event::Key { .. }) {
|
||||
break;
|
||||
}
|
||||
if let egui::Event::Text(value) = next {
|
||||
text = Some(value.as_str());
|
||||
used_text.insert(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
event
|
||||
.set_action(if !pressed {
|
||||
key::Action::Release
|
||||
} else if *repeat {
|
||||
key::Action::Repeat
|
||||
} else {
|
||||
key::Action::Press
|
||||
})
|
||||
.set_key(if physical == key::Key::Unidentified {
|
||||
logical
|
||||
} else {
|
||||
physical
|
||||
})
|
||||
.set_mods(mods(*modifiers))
|
||||
.set_consumed_mods(if text.is_some() {
|
||||
key::Mods::SHIFT
|
||||
} else {
|
||||
key::Mods::empty()
|
||||
})
|
||||
.set_unshifted_codepoint(ucp)
|
||||
.set_utf8(text);
|
||||
encoder.encode_to_vec(&event, &mut out)?;
|
||||
}
|
||||
egui::Event::Text(text) if !used_text.contains(&index) => {
|
||||
event
|
||||
.set_action(key::Action::Press)
|
||||
.set_key(key::Key::Unidentified)
|
||||
.set_mods(key::Mods::empty())
|
||||
.set_consumed_mods(key::Mods::empty())
|
||||
.set_unshifted_codepoint('\0')
|
||||
.set_utf8(Some(text.as_str()));
|
||||
encoder.encode_to_vec(&event, &mut out)?;
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(text)) => {
|
||||
out.extend_from_slice(text.as_bytes())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn printable(k: egui::Key) -> bool {
|
||||
let (_, c) = map(k);
|
||||
c != '\0'
|
||||
}
|
||||
|
||||
fn map(k: egui::Key) -> (key::Key, char) {
|
||||
use egui::Key as E;
|
||||
use key::Key as G;
|
||||
match k {
|
||||
E::A => (G::A, 'a'),
|
||||
E::B => (G::B, 'b'),
|
||||
E::C => (G::C, 'c'),
|
||||
E::D => (G::D, 'd'),
|
||||
E::E => (G::E, 'e'),
|
||||
E::F => (G::F, 'f'),
|
||||
E::G => (G::G, 'g'),
|
||||
E::H => (G::H, 'h'),
|
||||
E::I => (G::I, 'i'),
|
||||
E::J => (G::J, 'j'),
|
||||
E::K => (G::K, 'k'),
|
||||
E::L => (G::L, 'l'),
|
||||
E::M => (G::M, 'm'),
|
||||
E::N => (G::N, 'n'),
|
||||
E::O => (G::O, 'o'),
|
||||
E::P => (G::P, 'p'),
|
||||
E::Q => (G::Q, 'q'),
|
||||
E::R => (G::R, 'r'),
|
||||
E::S => (G::S, 's'),
|
||||
E::T => (G::T, 't'),
|
||||
E::U => (G::U, 'u'),
|
||||
E::V => (G::V, 'v'),
|
||||
E::W => (G::W, 'w'),
|
||||
E::X => (G::X, 'x'),
|
||||
E::Y => (G::Y, 'y'),
|
||||
E::Z => (G::Z, 'z'),
|
||||
E::Num0 => (G::Digit0, '0'),
|
||||
E::Num1 => (G::Digit1, '1'),
|
||||
E::Num2 => (G::Digit2, '2'),
|
||||
E::Num3 => (G::Digit3, '3'),
|
||||
E::Num4 => (G::Digit4, '4'),
|
||||
E::Num5 => (G::Digit5, '5'),
|
||||
E::Num6 => (G::Digit6, '6'),
|
||||
E::Num7 => (G::Digit7, '7'),
|
||||
E::Num8 => (G::Digit8, '8'),
|
||||
E::Num9 => (G::Digit9, '9'),
|
||||
E::Space => (G::Space, ' '),
|
||||
E::Minus => (G::Minus, '-'),
|
||||
E::Equals => (G::Equal, '='),
|
||||
E::Comma => (G::Comma, ','),
|
||||
E::Period => (G::Period, '.'),
|
||||
E::Slash => (G::Slash, '/'),
|
||||
E::Backslash => (G::Backslash, '\\'),
|
||||
E::OpenBracket => (G::BracketLeft, '['),
|
||||
E::CloseBracket => (G::BracketRight, ']'),
|
||||
E::Backtick => (G::Backquote, '`'),
|
||||
E::Semicolon => (G::Semicolon, ';'),
|
||||
E::Quote => (G::Quote, '\''),
|
||||
E::ArrowDown => (G::ArrowDown, '\0'),
|
||||
E::ArrowUp => (G::ArrowUp, '\0'),
|
||||
E::ArrowLeft => (G::ArrowLeft, '\0'),
|
||||
E::ArrowRight => (G::ArrowRight, '\0'),
|
||||
E::Enter => (G::Enter, '\0'),
|
||||
E::Escape => (G::Escape, '\0'),
|
||||
E::Tab => (G::Tab, '\0'),
|
||||
E::Backspace => (G::Backspace, '\0'),
|
||||
E::Delete => (G::Delete, '\0'),
|
||||
E::Insert => (G::Insert, '\0'),
|
||||
E::Home => (G::Home, '\0'),
|
||||
E::End => (G::End, '\0'),
|
||||
E::PageUp => (G::PageUp, '\0'),
|
||||
E::PageDown => (G::PageDown, '\0'),
|
||||
E::F1 => (G::F1, '\0'),
|
||||
E::F2 => (G::F2, '\0'),
|
||||
E::F3 => (G::F3, '\0'),
|
||||
E::F4 => (G::F4, '\0'),
|
||||
E::F5 => (G::F5, '\0'),
|
||||
E::F6 => (G::F6, '\0'),
|
||||
E::F7 => (G::F7, '\0'),
|
||||
E::F8 => (G::F8, '\0'),
|
||||
E::F9 => (G::F9, '\0'),
|
||||
E::F10 => (G::F10, '\0'),
|
||||
E::F11 => (G::F11, '\0'),
|
||||
E::F12 => (G::F12, '\0'),
|
||||
_ => (G::Unidentified, '\0'),
|
||||
}
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
use egui::Rect;
|
||||
|
||||
pub type PaneId = u64;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Axis {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Direction {
|
||||
Left,
|
||||
Down,
|
||||
Up,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Layout {
|
||||
Pane(PaneId),
|
||||
Split {
|
||||
axis: Axis,
|
||||
ratio: f32,
|
||||
first: Box<Layout>,
|
||||
second: Box<Layout>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn split(&mut self, target: PaneId, new: PaneId, axis: Axis) -> bool {
|
||||
match self {
|
||||
Self::Pane(id) if *id == target => {
|
||||
*self = Self::Split {
|
||||
axis,
|
||||
ratio: 0.5,
|
||||
first: Box::new(Self::Pane(target)),
|
||||
second: Box::new(Self::Pane(new)),
|
||||
};
|
||||
true
|
||||
}
|
||||
Self::Split { first, second, .. } => {
|
||||
first.split(target, new, axis) || second.split(target, new, axis)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(self, target: PaneId) -> Option<Self> {
|
||||
match self {
|
||||
Self::Pane(id) => (id != target).then_some(Self::Pane(id)),
|
||||
Self::Split {
|
||||
axis,
|
||||
ratio,
|
||||
first,
|
||||
second,
|
||||
} => match (first.remove(target), second.remove(target)) {
|
||||
(Some(first), Some(second)) => Some(Self::Split {
|
||||
axis,
|
||||
ratio,
|
||||
first: Box::new(first),
|
||||
second: Box::new(second),
|
||||
}),
|
||||
(first, second) => first.or(second),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ids(&self, out: &mut Vec<PaneId>) {
|
||||
match self {
|
||||
Self::Pane(id) => out.push(*id),
|
||||
Self::Split { first, second, .. } => {
|
||||
first.ids(out);
|
||||
second.ids(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn first(&self) -> PaneId {
|
||||
match self {
|
||||
Self::Pane(id) => *id,
|
||||
Self::Split { first, .. } => first.first(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arrange(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
rect: Rect,
|
||||
out: &mut Vec<(PaneId, Rect)>,
|
||||
path: u64,
|
||||
) {
|
||||
match self {
|
||||
Self::Pane(id) => out.push((*id, rect)),
|
||||
Self::Split {
|
||||
axis,
|
||||
ratio,
|
||||
first,
|
||||
second,
|
||||
} => {
|
||||
let horizontal = matches!(axis, Axis::Horizontal);
|
||||
let span = if horizontal {
|
||||
rect.width()
|
||||
} else {
|
||||
rect.height()
|
||||
};
|
||||
let start = if horizontal { rect.left() } else { rect.top() };
|
||||
let mut a = rect;
|
||||
let mut b = rect;
|
||||
let mid = start + span * *ratio;
|
||||
if horizontal {
|
||||
a.max.x = mid;
|
||||
b.min.x = mid;
|
||||
} else {
|
||||
a.max.y = mid;
|
||||
b.min.y = mid;
|
||||
}
|
||||
let divider = if horizontal {
|
||||
Rect::from_min_max(
|
||||
egui::pos2(mid - 3.0, rect.top()),
|
||||
egui::pos2(mid + 3.0, rect.bottom()),
|
||||
)
|
||||
} else {
|
||||
Rect::from_min_max(
|
||||
egui::pos2(rect.left(), mid - 3.0),
|
||||
egui::pos2(rect.right(), mid + 3.0),
|
||||
)
|
||||
};
|
||||
let response =
|
||||
ui.interact(divider, ui.id().with(("split", path)), egui::Sense::drag());
|
||||
response.clone().on_hover_cursor(if horizontal {
|
||||
egui::CursorIcon::ResizeHorizontal
|
||||
} else {
|
||||
egui::CursorIcon::ResizeVertical
|
||||
});
|
||||
if response.dragged()
|
||||
&& let Some(pos) = response.interact_pointer_pos()
|
||||
{
|
||||
*ratio = ((if horizontal { pos.x } else { pos.y } - start) / span.max(1.0))
|
||||
.clamp(0.1, 0.9);
|
||||
}
|
||||
first.arrange(ui, a, out, path * 2);
|
||||
second.arrange(ui, b, out, path * 2 + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Tab {
|
||||
pub id: u64,
|
||||
pub root: Option<Layout>,
|
||||
pub floats: Vec<PaneId>,
|
||||
pub active: PaneId,
|
||||
pub floats_visible: bool,
|
||||
pub zoomed: bool,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
pub fn new(id: u64, pane: PaneId) -> Self {
|
||||
Self {
|
||||
id,
|
||||
root: Some(Layout::Pane(pane)),
|
||||
floats: vec![],
|
||||
active: pane,
|
||||
floats_visible: false,
|
||||
zoomed: false,
|
||||
}
|
||||
}
|
||||
pub fn ids(&self) -> Vec<PaneId> {
|
||||
let mut ids = vec![];
|
||||
if let Some(root) = &self.root {
|
||||
root.ids(&mut ids);
|
||||
}
|
||||
ids.extend_from_slice(&self.floats);
|
||||
ids
|
||||
}
|
||||
pub fn detach(&mut self, id: PaneId) {
|
||||
self.root = self.root.take().and_then(|r| r.remove(id));
|
||||
self.floats.retain(|p| *p != id);
|
||||
self.zoomed = false;
|
||||
}
|
||||
pub fn is_visible(&self, id: PaneId) -> bool {
|
||||
self.floats_visible || !self.floats.contains(&id)
|
||||
}
|
||||
|
||||
pub fn toggle_floats(&mut self) {
|
||||
self.floats_visible = !self.floats_visible;
|
||||
self.zoomed = false;
|
||||
if self.floats_visible {
|
||||
if let Some(id) = self.floats.last() {
|
||||
self.active = *id;
|
||||
}
|
||||
} else if self.floats.contains(&self.active)
|
||||
&& let Some(root) = &self.root
|
||||
{
|
||||
self.active = root.first();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus_direction(&mut self, direction: Direction, rects: &[(PaneId, Rect)]) {
|
||||
let Some((_, source)) = rects
|
||||
.iter()
|
||||
.find(|(id, _)| *id == self.active && self.is_visible(*id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !source.is_positive() || !source.is_finite() {
|
||||
return;
|
||||
}
|
||||
let horizontal = matches!(direction, Direction::Left | Direction::Right);
|
||||
let sign = if matches!(direction, Direction::Left | Direction::Up) {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let ids = self.ids();
|
||||
let next = rects
|
||||
.iter()
|
||||
.filter_map(|(id, rect)| {
|
||||
if *id == self.active
|
||||
|| !ids.contains(id)
|
||||
|| !self.is_visible(*id)
|
||||
|| !rect.is_positive()
|
||||
|| !rect.is_finite()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let delta = rect.center() - source.center();
|
||||
let forward = sign * if horizontal { delta.x } else { delta.y };
|
||||
if forward <= 0.5 {
|
||||
return None;
|
||||
}
|
||||
let overlap = if horizontal {
|
||||
source.bottom().min(rect.bottom()) - source.top().max(rect.top())
|
||||
} else {
|
||||
source.right().min(rect.right()) - source.left().max(rect.left())
|
||||
};
|
||||
// Prefer aligned neighbors, then the nearest center, without wrapping.
|
||||
Some((*id, overlap <= 0.0, delta.length_sq()))
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
a.1.cmp(&b.1)
|
||||
.then_with(|| a.2.total_cmp(&b.2))
|
||||
.then_with(|| a.0.cmp(&b.0))
|
||||
});
|
||||
if let Some((id, _, _)) = next {
|
||||
self.active = id;
|
||||
}
|
||||
}
|
||||
pub fn cycle(&mut self, backwards: bool) {
|
||||
let ids: Vec<_> = self
|
||||
.ids()
|
||||
.into_iter()
|
||||
.filter(|id| self.floats_visible || !self.floats.contains(id))
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let pos = ids.iter().position(|p| *p == self.active).unwrap_or(0);
|
||||
self.active = ids[(pos + if backwards { ids.len() - 1 } else { 1 }) % ids.len()];
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn removal_collapses_splits_without_losing_siblings() {
|
||||
let mut root = Layout::Pane(1);
|
||||
assert!(root.split(1, 2, Axis::Horizontal));
|
||||
assert!(root.split(2, 3, Axis::Vertical));
|
||||
assert!(!root.split(99, 4, Axis::Vertical));
|
||||
let root = root.remove(2).unwrap();
|
||||
let mut ids = vec![];
|
||||
root.ids(&mut ids);
|
||||
assert_eq!(ids, vec![1, 3]);
|
||||
let root = root.remove(1).unwrap();
|
||||
assert_eq!(root.first(), 3);
|
||||
assert!(root.remove(3).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn focus_skips_hidden_floats() {
|
||||
let mut tab = Tab::new(1, 1);
|
||||
tab.floats.push(2);
|
||||
tab.floats_visible = true;
|
||||
tab.cycle(false);
|
||||
assert_eq!(tab.active, 2);
|
||||
tab.floats_visible = false;
|
||||
tab.cycle(false);
|
||||
assert_eq!(tab.active, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_visibility_controls_focus_even_without_tiles() {
|
||||
let mut tab = Tab::new(1, 1);
|
||||
assert!(!tab.floats_visible);
|
||||
tab.toggle_floats();
|
||||
assert!(tab.floats_visible);
|
||||
tab.floats.push(2);
|
||||
tab.active = 2;
|
||||
tab.toggle_floats();
|
||||
assert_eq!(tab.active, 1);
|
||||
assert!(!tab.is_visible(2));
|
||||
tab.toggle_floats();
|
||||
assert_eq!(tab.active, 2);
|
||||
tab.root = None;
|
||||
tab.toggle_floats();
|
||||
assert!(!tab.is_visible(2));
|
||||
tab.toggle_floats();
|
||||
assert!(tab.is_visible(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_focus_uses_geometry_and_ignores_hidden_or_foreign_panes() {
|
||||
let mut tab = Tab::new(1, 1);
|
||||
let root = tab.root.as_mut().unwrap();
|
||||
root.split(1, 2, Axis::Horizontal);
|
||||
root.split(1, 3, Axis::Vertical);
|
||||
root.split(2, 4, Axis::Vertical);
|
||||
tab.floats.push(5);
|
||||
let rect = |x, y| Rect::from_min_size(egui::pos2(x, y), egui::vec2(100.0, 100.0));
|
||||
let panes = [
|
||||
(1, rect(0.0, 0.0)),
|
||||
(2, rect(100.0, 0.0)),
|
||||
(3, rect(0.0, 100.0)),
|
||||
(4, rect(100.0, 100.0)),
|
||||
(5, rect(20.0, 0.0)),
|
||||
(99, rect(10.0, 0.0)),
|
||||
];
|
||||
tab.focus_direction(Direction::Right, &panes);
|
||||
assert_eq!(tab.active, 2);
|
||||
tab.focus_direction(Direction::Down, &panes);
|
||||
assert_eq!(tab.active, 4);
|
||||
tab.focus_direction(Direction::Left, &panes);
|
||||
assert_eq!(tab.active, 3);
|
||||
tab.focus_direction(Direction::Up, &panes);
|
||||
assert_eq!(tab.active, 1);
|
||||
tab.focus_direction(Direction::Left, &panes);
|
||||
assert_eq!(tab.active, 1);
|
||||
tab.floats_visible = true;
|
||||
tab.focus_direction(Direction::Right, &panes);
|
||||
assert_eq!(tab.active, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_rectangles_touch_without_gaps() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut layout = Layout::Pane(1);
|
||||
layout.split(1, 2, Axis::Horizontal);
|
||||
layout.split(2, 3, Axis::Vertical);
|
||||
let mut rects = vec![];
|
||||
let workspace = Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0));
|
||||
let _ = ctx.run(egui::RawInput::default(), |ctx| {
|
||||
egui::CentralPanel::default()
|
||||
.show(ctx, |ui| layout.arrange(ui, workspace, &mut rects, 1));
|
||||
});
|
||||
assert_eq!(rects.len(), 3);
|
||||
assert_eq!(rects[0].1.right(), rects[1].1.left());
|
||||
assert_eq!(rects[1].1.bottom(), rects[2].1.top());
|
||||
assert_eq!(
|
||||
rects.iter().map(|(_, rect)| rect.area()).sum::<f32>(),
|
||||
workspace.area()
|
||||
);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod input;
|
||||
mod layout;
|
||||
mod pane;
|
||||
mod pty;
|
||||
mod render;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut path = config::Config::path()?;
|
||||
let mut check = false;
|
||||
let mut init = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--config" => path = args.next().context("--config needs a path")?.into(),
|
||||
"--check-config" => check = true,
|
||||
"--init-config" => init = true,
|
||||
"--version" => {
|
||||
println!("rust-term {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"rust-term [--config PATH] [--check-config | --init-config]\n\nDefault config: {}",
|
||||
path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
_ => bail!("Unknown argument: {arg}"),
|
||||
}
|
||||
}
|
||||
if init {
|
||||
use std::io::Write;
|
||||
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Cannot create {} (existing files are never overwritten)",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
file.write_all(config::EXAMPLE.as_bytes())?;
|
||||
println!("Created {}", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
let config = config::Config::load(&path)?;
|
||||
if check {
|
||||
println!(
|
||||
"Configuration valid: {}{}",
|
||||
path.display(),
|
||||
if path.exists() {
|
||||
""
|
||||
} else {
|
||||
" (built-in defaults)"
|
||||
}
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
libghostty_vt::kitty::graphics::set_png_decoder(Some(Box::new(render::PngDecoder)))?;
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("rust-term")
|
||||
.with_inner_size([config.window_width, config.window_height])
|
||||
.with_min_inner_size([320.0, 200.0])
|
||||
.with_app_id("rust-term"),
|
||||
renderer: eframe::Renderer::Wgpu,
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"rust-term",
|
||||
options,
|
||||
Box::new(move |cc| Ok(Box::new(app::App::new(cc, config, path)?))),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Cannot start rust-term: {e}"))
|
||||
}
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
use crate::{
|
||||
config::{Config, rgb},
|
||||
input,
|
||||
pty::Pty,
|
||||
render::Renderer,
|
||||
};
|
||||
use anyhow::{Result, ensure};
|
||||
use egui::{FontId, Rect, Sense, vec2};
|
||||
use libghostty_vt::{
|
||||
Terminal, TerminalOptions, mouse,
|
||||
selection::{FormatOptions, Selection},
|
||||
terminal::{self, Mode, Point, PointCoordinate, ScrollViewport},
|
||||
};
|
||||
use std::{cell::RefCell, rc::Rc, sync::mpsc::TryRecvError};
|
||||
|
||||
pub struct Pane {
|
||||
pub terminal: Terminal<'static, 'static>,
|
||||
pub renderer: Renderer,
|
||||
pub pty: Pty,
|
||||
pub dead: bool,
|
||||
pub error: Option<String>,
|
||||
pub last_rect: Rect,
|
||||
size: (u16, u16, u32, u32),
|
||||
report_size: Rc<RefCell<terminal::SizeReportSize>>,
|
||||
callback_error: Rc<RefCell<Option<String>>>,
|
||||
mouse_encoder: mouse::Encoder<'static>,
|
||||
mouse_event: mouse::Event<'static>,
|
||||
selection_start: Option<PointCoordinate>,
|
||||
focused: bool,
|
||||
}
|
||||
|
||||
pub fn terminal(config: &Config) -> Result<Terminal<'static, 'static>> {
|
||||
let mut term = Terminal::new(TerminalOptions {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
max_scrollback: config.scrollback,
|
||||
})?;
|
||||
term.resize(80, 24, 8, 16)?;
|
||||
apply_config(&mut term, config)?;
|
||||
term.on_xtversion(|_| Some("rust-term 0.1.0"))?;
|
||||
term.on_device_attributes(|_| {
|
||||
Some(terminal::DeviceAttributes {
|
||||
primary: terminal::PrimaryDeviceAttributes::new(
|
||||
terminal::ConformanceLevel::VT220,
|
||||
&[terminal::DeviceAttributeFeature::ANSI_COLOR],
|
||||
),
|
||||
secondary: terminal::SecondaryDeviceAttributes {
|
||||
device_type: terminal::DeviceType::VT220,
|
||||
firmware_version: 1,
|
||||
rom_cartridge: 0,
|
||||
},
|
||||
tertiary: Default::default(),
|
||||
})
|
||||
})?;
|
||||
term.on_color_scheme(|_| Some(terminal::ColorScheme::Dark))?;
|
||||
Ok(term)
|
||||
}
|
||||
|
||||
pub fn apply_config(term: &mut Terminal<'_, '_>, config: &Config) -> Result<()> {
|
||||
term.set_default_fg_color(Some(rgb(&config.foreground)?))?
|
||||
.set_default_bg_color(Some(rgb(&config.background)?))?
|
||||
.set_default_cursor_color(Some(rgb(&config.accent)?))?
|
||||
.set_kitty_image_storage_limit(config.kitty.image_storage_mb * 1024 * 1024)?
|
||||
.set_kitty_image_from_file_allowed(config.kitty.allow_file)?
|
||||
.set_kitty_image_from_temp_file_allowed(config.kitty.allow_temp_file)?
|
||||
.set_kitty_image_from_shared_mem_allowed(config.kitty.allow_shared_memory)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Pane {
|
||||
pub fn new(config: &Config, ctx: &egui::Context) -> Result<Self> {
|
||||
let mut terminal = terminal(config)?;
|
||||
let renderer = Renderer::new()?;
|
||||
let mouse_encoder = mouse::Encoder::new()?;
|
||||
let mouse_event = mouse::Event::new()?;
|
||||
let pty = Pty::spawn(config, ctx)?;
|
||||
let input = pty.input.clone();
|
||||
let callback_error = Rc::new(RefCell::new(None));
|
||||
let errors = callback_error.clone();
|
||||
terminal.on_pty_write(move |_, data| {
|
||||
if let Err(e) = input.try_send(data.to_vec()) {
|
||||
*errors.borrow_mut() = Some(format!("PTY response queue: {e}"));
|
||||
}
|
||||
})?;
|
||||
let report_size = Rc::new(RefCell::new(terminal::SizeReportSize {
|
||||
rows: 24,
|
||||
columns: 80,
|
||||
cell_width: 8,
|
||||
cell_height: 16,
|
||||
}));
|
||||
let size = report_size.clone();
|
||||
terminal.on_size(move |_| Some(*size.borrow()))?;
|
||||
Ok(Self {
|
||||
terminal,
|
||||
renderer,
|
||||
pty,
|
||||
dead: false,
|
||||
error: None,
|
||||
last_rect: Rect::NOTHING,
|
||||
size: (80, 24, 8, 16),
|
||||
report_size,
|
||||
callback_error,
|
||||
mouse_encoder,
|
||||
mouse_event,
|
||||
selection_start: None,
|
||||
focused: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn drain(&mut self, ctx: &egui::Context) {
|
||||
// Bound work per frame as well as queued bytes, so one busy PTY cannot starve others.
|
||||
for _ in 0..64 {
|
||||
match self.pty.output.try_recv() {
|
||||
Ok(data) => self.terminal.vt_write(&data),
|
||||
Err(TryRecvError::Empty | TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
if self.pty.exited.try_recv().is_ok() {
|
||||
self.dead = true;
|
||||
}
|
||||
if let Ok(e) = self.pty.errors.try_recv() {
|
||||
self.error = Some(e);
|
||||
}
|
||||
if let Some(e) = self.callback_error.borrow_mut().take() {
|
||||
self.error = Some(e);
|
||||
}
|
||||
if !self.dead {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&mut self, bytes: Vec<u8>) -> Result<()> {
|
||||
if bytes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
ensure!(!self.dead, "shell has exited");
|
||||
self.pty
|
||||
.input
|
||||
.try_send(bytes)
|
||||
.map_err(|e| anyhow::anyhow!("PTY input queue: {e}"))?;
|
||||
self.terminal.scroll_viewport(ScrollViewport::Bottom);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn paste(&mut self, text: &str) -> Result<()> {
|
||||
ensure!(text.len() <= 4 * 1024 * 1024, "paste exceeds 4 MiB");
|
||||
// ESC can terminate bracketed paste early. Strip it and NUL from clipboard input.
|
||||
let text = text
|
||||
.replace(['\x1b', '\0'], "")
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\n', "\r");
|
||||
let data = if self.terminal.mode(Mode::BRACKETED_PASTE)? {
|
||||
format!("\x1b[200~{text}\x1b[201~").into_bytes()
|
||||
} else {
|
||||
text.into_bytes()
|
||||
};
|
||||
self.send(data)
|
||||
}
|
||||
|
||||
pub fn copy(&self) -> Result<Option<String>> {
|
||||
Ok(self
|
||||
.terminal
|
||||
.format_selection_alloc(None, FormatOptions::new().with_trim(true).with_unwrap(true))?
|
||||
.map(|b| String::from_utf8_lossy(&b).into_owned()))
|
||||
}
|
||||
|
||||
pub fn focus(&mut self, focused: bool) -> Result<()> {
|
||||
if focused != self.focused {
|
||||
self.focused = focused;
|
||||
if self
|
||||
.terminal
|
||||
.mode(Mode::new(1004, terminal::ModeKind::Dec))?
|
||||
{
|
||||
self.send(if focused { b"\x1b[I" } else { b"\x1b[O" }.to_vec())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
rect: Rect,
|
||||
config: &Config,
|
||||
active: bool,
|
||||
) -> Result<bool> {
|
||||
let font = FontId::monospace(config.font_size);
|
||||
let cw = ui
|
||||
.painter()
|
||||
.layout_no_wrap("M".into(), font.clone(), egui::Color32::WHITE)
|
||||
.size()
|
||||
.x
|
||||
.ceil()
|
||||
.max(1.0);
|
||||
let ch = (config.font_size * 1.3).ceil();
|
||||
let cell = vec2(cw, ch);
|
||||
let grid = rect.shrink(6.0);
|
||||
if grid.width() < cw || grid.height() < ch {
|
||||
return Ok(false);
|
||||
}
|
||||
let size = (
|
||||
(grid.width() / cw).floor().clamp(1.0, 1000.0) as u16,
|
||||
(grid.height() / ch).floor().clamp(1.0, 1000.0) as u16,
|
||||
cw as u32,
|
||||
ch as u32,
|
||||
);
|
||||
if size != self.size {
|
||||
self.pty.resize(size.0, size.1, size.2, size.3)?;
|
||||
self.terminal.resize(size.0, size.1, size.2, size.3)?;
|
||||
self.size = size;
|
||||
*self.report_size.borrow_mut() = terminal::SizeReportSize {
|
||||
columns: size.0,
|
||||
rows: size.1,
|
||||
cell_width: size.2,
|
||||
cell_height: size.3,
|
||||
};
|
||||
}
|
||||
let response = ui.interact(grid, ui.id().with("terminal"), Sense::click_and_drag());
|
||||
self.pointer(ui, &response, grid, cell)?;
|
||||
self.renderer
|
||||
.paint(&self.terminal, ui.painter(), grid, cell, &font, active)?;
|
||||
if let Some(error) = &self.error {
|
||||
ui.painter().text(
|
||||
grid.left_bottom() - vec2(0.0, ch),
|
||||
egui::Align2::LEFT_TOP,
|
||||
error,
|
||||
FontId::proportional(12.0),
|
||||
egui::Color32::LIGHT_RED,
|
||||
);
|
||||
}
|
||||
Ok(response.clicked()
|
||||
|| response.drag_started()
|
||||
|| (response.hovered() && ui.input(|i| i.pointer.any_pressed())))
|
||||
}
|
||||
|
||||
fn pointer(
|
||||
&mut self,
|
||||
ui: &egui::Ui,
|
||||
response: &egui::Response,
|
||||
grid: Rect,
|
||||
cell: egui::Vec2,
|
||||
) -> Result<()> {
|
||||
let modifiers = ui.input(|i| i.modifiers);
|
||||
let tracking = self.terminal.is_mouse_tracking()? && !modifiers.shift;
|
||||
let pos = response
|
||||
.interact_pointer_pos()
|
||||
.or_else(|| ui.input(|i| i.pointer.hover_pos()));
|
||||
let Some(pos) = pos else {
|
||||
return Ok(());
|
||||
};
|
||||
let local = pos - grid.min;
|
||||
if tracking && response.hovered() {
|
||||
let events = ui.input(|i| i.events.clone());
|
||||
self.mouse_encoder
|
||||
.set_options_from_terminal(&self.terminal)
|
||||
.set_size(mouse::EncoderSize {
|
||||
screen_width: self.size.0 as u32 * self.size.2,
|
||||
screen_height: self.size.1 as u32 * self.size.3,
|
||||
cell_width: self.size.2,
|
||||
cell_height: self.size.3,
|
||||
padding_top: 0,
|
||||
padding_bottom: 0,
|
||||
padding_left: 0,
|
||||
padding_right: 0,
|
||||
})
|
||||
.set_any_button_pressed(ui.input(|i| i.pointer.any_down()))
|
||||
.set_track_last_cell(true);
|
||||
self.mouse_event
|
||||
.set_position(mouse::Position {
|
||||
x: local.x,
|
||||
y: local.y,
|
||||
})
|
||||
.set_mods(input::mods(modifiers));
|
||||
let mut data = vec![];
|
||||
for event in events {
|
||||
match event {
|
||||
egui::Event::PointerButton {
|
||||
button, pressed, ..
|
||||
} => {
|
||||
let button = match button {
|
||||
egui::PointerButton::Primary => mouse::Button::Left,
|
||||
egui::PointerButton::Secondary => mouse::Button::Right,
|
||||
egui::PointerButton::Middle => mouse::Button::Middle,
|
||||
_ => continue,
|
||||
};
|
||||
self.mouse_event
|
||||
.set_action(if pressed {
|
||||
mouse::Action::Press
|
||||
} else {
|
||||
mouse::Action::Release
|
||||
})
|
||||
.set_button(Some(button));
|
||||
self.mouse_encoder
|
||||
.encode_to_vec(&self.mouse_event, &mut data)?;
|
||||
}
|
||||
egui::Event::PointerMoved(_) => {
|
||||
let button = ui.input(|i| {
|
||||
if i.pointer.primary_down() {
|
||||
Some(mouse::Button::Left)
|
||||
} else if i.pointer.secondary_down() {
|
||||
Some(mouse::Button::Right)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.mouse_event
|
||||
.set_action(mouse::Action::Motion)
|
||||
.set_button(button);
|
||||
self.mouse_encoder
|
||||
.encode_to_vec(&self.mouse_event, &mut data)?;
|
||||
}
|
||||
egui::Event::MouseWheel { delta, .. } => {
|
||||
self.mouse_event
|
||||
.set_action(mouse::Action::Press)
|
||||
.set_button(Some(if delta.y > 0.0 {
|
||||
mouse::Button::Four
|
||||
} else {
|
||||
mouse::Button::Five
|
||||
}));
|
||||
for _ in 0..3 {
|
||||
self.mouse_encoder
|
||||
.encode_to_vec(&self.mouse_event, &mut data)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.send(data)?;
|
||||
} else if !tracking {
|
||||
let point = PointCoordinate {
|
||||
x: (local.x / cell.x)
|
||||
.floor()
|
||||
.clamp(0.0, (self.size.0 - 1) as f32) as u16,
|
||||
y: (local.y / cell.y)
|
||||
.floor()
|
||||
.clamp(0.0, (self.size.1 - 1) as f32) as u32,
|
||||
};
|
||||
if response.drag_started() {
|
||||
self.selection_start = Some(point);
|
||||
}
|
||||
if response.clicked() {
|
||||
self.terminal.set_selection(None)?;
|
||||
self.selection_start = None;
|
||||
}
|
||||
if response.dragged()
|
||||
&& let Some(start) = self.selection_start
|
||||
{
|
||||
let a = self.terminal.grid_ref(Point::Viewport(start))?;
|
||||
let b = self.terminal.grid_ref(Point::Viewport(point))?;
|
||||
self.terminal
|
||||
.set_selection(Some(&Selection::new(a, b, modifiers.alt)))?;
|
||||
}
|
||||
if response.hovered() {
|
||||
let scroll = ui.input(|i| i.raw_scroll_delta.y);
|
||||
if scroll.abs() >= 1.0 {
|
||||
self.terminal.scroll_viewport(ScrollViewport::Delta(
|
||||
-(scroll / cell.y).round() as isize,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
use crate::config::Config;
|
||||
use anyhow::{Context, Result};
|
||||
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
sync::mpsc::{self, Receiver, Sender, SyncSender},
|
||||
thread,
|
||||
};
|
||||
|
||||
pub struct Pty {
|
||||
pub output: Receiver<Vec<u8>>,
|
||||
pub input: SyncSender<Vec<u8>>,
|
||||
pub errors: Receiver<String>,
|
||||
pub exited: Receiver<()>,
|
||||
master: Box<dyn MasterPty + Send>,
|
||||
shutdown: Sender<()>,
|
||||
reaper: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Pty {
|
||||
pub fn spawn(config: &Config, ctx: &egui::Context) -> Result<Self> {
|
||||
let pair = native_pty_system().openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 640,
|
||||
pixel_height: 384,
|
||||
})?;
|
||||
let mut cmd = CommandBuilder::new(&config.shell[0]);
|
||||
cmd.args(&config.shell[1..]);
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
cmd.env("COLORTERM", "truecolor");
|
||||
cmd.env("TERM_PROGRAM", "rust-term");
|
||||
cmd.env("TERM_PROGRAM_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
if let Some(dir) = &config.working_directory {
|
||||
cmd.cwd(dir);
|
||||
}
|
||||
let mut reader = pair.master.try_clone_reader()?;
|
||||
let mut writer = pair.master.take_writer()?;
|
||||
let mut child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.context("Cannot spawn shell")?;
|
||||
drop(pair.slave);
|
||||
let (output_tx, output) = mpsc::sync_channel(64);
|
||||
let (input, input_rx) = mpsc::sync_channel::<Vec<u8>>(64);
|
||||
let (error_tx, errors) = mpsc::channel();
|
||||
let (exit_tx, exited) = mpsc::channel();
|
||||
let (shutdown, shutdown_rx) = mpsc::channel();
|
||||
let read_ctx = ctx.clone();
|
||||
let write_ctx = ctx.clone();
|
||||
let exit_ctx = ctx.clone();
|
||||
let read_errors = error_tx.clone();
|
||||
thread::spawn(move || {
|
||||
let mut buf = [0; 16384];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
if output_tx.send(buf[..n].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
read_ctx.request_repaint();
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
// Linux PTYs report EIO when the slave closes.
|
||||
Err(e) if e.raw_os_error() == Some(5) => break,
|
||||
Err(e) => {
|
||||
let _ = read_errors.send(format!("PTY read: {e}"));
|
||||
read_ctx.request_repaint();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
thread::spawn(move || {
|
||||
while let Ok(bytes) = input_rx.recv() {
|
||||
if let Err(e) = writer.write_all(&bytes).and_then(|_| writer.flush()) {
|
||||
let _ = error_tx.send(format!("PTY write: {e}"));
|
||||
write_ctx.request_repaint();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let reaper = thread::spawn(move || {
|
||||
loop {
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
break;
|
||||
}
|
||||
if !matches!(
|
||||
shutdown_rx.recv_timeout(std::time::Duration::from_millis(25)),
|
||||
Err(mpsc::RecvTimeoutError::Timeout)
|
||||
) {
|
||||
// The worker owns the unreaped child, so shutdown cannot signal a reused PID.
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = exit_tx.send(());
|
||||
exit_ctx.request_repaint();
|
||||
});
|
||||
Ok(Self {
|
||||
output,
|
||||
input,
|
||||
errors,
|
||||
exited,
|
||||
master: pair.master,
|
||||
shutdown,
|
||||
reaper: Some(reaper),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resize(&self, cols: u16, rows: u16, cw: u32, ch: u32) -> Result<()> {
|
||||
self.master.resize(PtySize {
|
||||
cols,
|
||||
rows,
|
||||
pixel_width: (cols as u32 * cw).min(u16::MAX as u32) as u16,
|
||||
pixel_height: (rows as u32 * ch).min(u16::MAX as u32) as u16,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pty {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown.send(());
|
||||
if let Some(reaper) = self.reaper.take() {
|
||||
let _ = reaper.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
use anyhow::{Result, ensure};
|
||||
use egui::{
|
||||
Color32, ColorImage, FontId, Painter, Rect, Stroke, TextureHandle, TextureOptions, pos2, vec2,
|
||||
};
|
||||
use libghostty_vt::{
|
||||
RenderState, Terminal,
|
||||
alloc::{Allocator, Bytes},
|
||||
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
|
||||
render::{CellIterator, CursorVisualStyle, Dirty, RowIterator},
|
||||
style::{RgbColor, Underline},
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
io::Cursor,
|
||||
};
|
||||
|
||||
pub fn color(c: RgbColor) -> Color32 {
|
||||
Color32::from_rgb(c.r, c.g, c.b)
|
||||
}
|
||||
|
||||
pub struct PngDecoder;
|
||||
impl DecodePng for PngDecoder {
|
||||
fn decode_png<'a>(
|
||||
&mut self,
|
||||
alloc: &'a Allocator<'_>,
|
||||
bytes: &[u8],
|
||||
) -> Option<DecodedImage<'a>> {
|
||||
let mut decoder = png::Decoder::new(Cursor::new(bytes));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
decoder.set_limits(png::Limits {
|
||||
bytes: 128 * 1024 * 1024,
|
||||
});
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let size = reader.output_buffer_size()?;
|
||||
if size > 128 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let mut pixels = vec![0; size];
|
||||
let info = reader.next_frame(&mut pixels).ok()?;
|
||||
let count = (info.width as usize).checked_mul(info.height as usize)?;
|
||||
let len = count.checked_mul(4)?;
|
||||
if len > 128 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let mut data = Bytes::new_with_alloc(alloc, len).ok()?;
|
||||
let input = &pixels[..info.buffer_size()];
|
||||
match info.color_type {
|
||||
png::ColorType::Rgba => data.copy_from_slice(input),
|
||||
png::ColorType::Rgb => {
|
||||
for (out, src) in data.chunks_exact_mut(4).zip(input.chunks_exact(3)) {
|
||||
out.copy_from_slice(&[src[0], src[1], src[2], 255]);
|
||||
}
|
||||
}
|
||||
png::ColorType::Grayscale => {
|
||||
for (out, value) in data.chunks_exact_mut(4).zip(input) {
|
||||
out.copy_from_slice(&[*value, *value, *value, 255]);
|
||||
}
|
||||
}
|
||||
png::ColorType::GrayscaleAlpha => {
|
||||
for (out, src) in data.chunks_exact_mut(4).zip(input.chunks_exact(2)) {
|
||||
out.copy_from_slice(&[src[0], src[0], src[0], src[1]]);
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(DecodedImage {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Renderer {
|
||||
state: RenderState<'static>,
|
||||
rows: RowIterator<'static>,
|
||||
cells: CellIterator<'static>,
|
||||
placements: PlacementIterator<'static>,
|
||||
textures: HashMap<u32, (u64, TextureHandle)>,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
state: RenderState::new()?,
|
||||
rows: RowIterator::new()?,
|
||||
cells: CellIterator::new()?,
|
||||
placements: PlacementIterator::new()?,
|
||||
textures: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn paint(
|
||||
&mut self,
|
||||
term: &Terminal<'static, '_>,
|
||||
painter: &Painter,
|
||||
rect: Rect,
|
||||
cell: egui::Vec2,
|
||||
font: &FontId,
|
||||
active: bool,
|
||||
) -> Result<()> {
|
||||
let painter = painter.with_clip_rect(rect.intersect(painter.clip_rect()));
|
||||
let snapshot = self.state.update(term)?;
|
||||
let colors = snapshot.colors()?;
|
||||
painter.rect_filled(rect, 0, color(colors.background));
|
||||
let mut backgrounds = vec![];
|
||||
let mut foregrounds = vec![];
|
||||
let mut rows = self.rows.update(&snapshot)?;
|
||||
let mut text = String::new();
|
||||
let mut y = 0;
|
||||
while let Some(row) = rows.next() {
|
||||
let mut cells = self.cells.update(row)?;
|
||||
let mut x = 0;
|
||||
while let Some(c) = cells.next() {
|
||||
let pos = rect.min + vec2(x as f32 * cell.x, y as f32 * cell.y);
|
||||
let cell_rect = Rect::from_min_size(pos, cell);
|
||||
let style = c.style()?;
|
||||
let mut fg = color(c.fg_color()?.unwrap_or(colors.foreground));
|
||||
let explicit_bg = c.bg_color()?;
|
||||
let mut bg = color(explicit_bg.unwrap_or(colors.background));
|
||||
let selected = c.is_selected()?;
|
||||
if style.inverse ^ selected {
|
||||
std::mem::swap(&mut fg, &mut bg);
|
||||
}
|
||||
if explicit_bg.is_some() || style.inverse || selected {
|
||||
backgrounds.push(egui::Shape::rect_filled(cell_rect, 0, bg));
|
||||
}
|
||||
if style.faint {
|
||||
fg = fg.gamma_multiply(0.6);
|
||||
}
|
||||
if !style.invisible && c.graphemes_len()? > 0 {
|
||||
c.graphemes_utf8(&mut text)?;
|
||||
// Placeholder cells are reserved by the Kitty protocol, not printable text.
|
||||
if !text.starts_with('\u{10eeee}') {
|
||||
let galley = painter.layout_no_wrap(text.clone(), font.clone(), fg);
|
||||
foregrounds.push(egui::Shape::galley(pos, galley.clone(), fg));
|
||||
if style.bold {
|
||||
foregrounds.push(egui::Shape::galley(
|
||||
pos + vec2(0.45, 0.0),
|
||||
galley,
|
||||
fg,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (enabled, height) in [
|
||||
(style.underline != Underline::None, cell.y - 2.0),
|
||||
(style.strikethrough, cell.y * 0.55),
|
||||
(style.overline, 1.0),
|
||||
] {
|
||||
if enabled {
|
||||
foregrounds.push(egui::Shape::line_segment(
|
||||
[pos + vec2(0.0, height), pos + vec2(cell.x, height)],
|
||||
Stroke::new(1.0, fg),
|
||||
));
|
||||
}
|
||||
}
|
||||
x += 1;
|
||||
}
|
||||
row.set_dirty(false)?;
|
||||
y += 1;
|
||||
}
|
||||
// Separate passes preserve Kitty's three z-order bands exactly.
|
||||
let mut seen = HashSet::new();
|
||||
for layer in [Layer::BelowBg, Layer::BelowText, Layer::AboveText] {
|
||||
if layer == Layer::BelowText {
|
||||
painter.extend(std::mem::take(&mut backgrounds));
|
||||
}
|
||||
if layer == Layer::AboveText {
|
||||
painter.extend(std::mem::take(&mut foregrounds));
|
||||
}
|
||||
paint_images(
|
||||
term,
|
||||
&painter,
|
||||
rect,
|
||||
cell,
|
||||
layer,
|
||||
&mut self.placements,
|
||||
&mut self.textures,
|
||||
&mut seen,
|
||||
)?;
|
||||
}
|
||||
self.textures.retain(|id, _| seen.contains(id));
|
||||
if snapshot.cursor_visible()?
|
||||
&& let Some(cursor) = snapshot.cursor_viewport()?
|
||||
{
|
||||
let mut cursor_rect = Rect::from_min_size(
|
||||
rect.min + vec2(cursor.x as f32 * cell.x, cursor.y as f32 * cell.y),
|
||||
cell,
|
||||
);
|
||||
let c = color(colors.cursor.unwrap_or(colors.foreground));
|
||||
match snapshot.cursor_visual_style()? {
|
||||
CursorVisualStyle::Bar if active => {
|
||||
cursor_rect.max.x = cursor_rect.min.x + 2.0;
|
||||
painter.rect_filled(cursor_rect, 0, c);
|
||||
}
|
||||
CursorVisualStyle::Underline if active => {
|
||||
cursor_rect.min.y = cursor_rect.max.y - 2.0;
|
||||
painter.rect_filled(cursor_rect, 0, c);
|
||||
}
|
||||
_ => {
|
||||
painter.rect_stroke(
|
||||
cursor_rect,
|
||||
0,
|
||||
Stroke::new(if active { 1.5 } else { 0.75 }, c),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
snapshot.set_dirty(Dirty::Clean)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_images(
|
||||
term: &Terminal<'_, '_>,
|
||||
painter: &Painter,
|
||||
rect: Rect,
|
||||
cell: egui::Vec2,
|
||||
layer: Layer,
|
||||
iterator: &mut PlacementIterator<'_>,
|
||||
textures: &mut HashMap<u32, (u64, TextureHandle)>,
|
||||
seen: &mut HashSet<u32>,
|
||||
) -> Result<()> {
|
||||
let graphics = term.kitty_graphics()?;
|
||||
let mut placements = iterator.update(&graphics)?;
|
||||
placements.set_layer(layer)?;
|
||||
let mut images = vec![];
|
||||
while let Some(p) = placements.next() {
|
||||
let id = p.image_id()?;
|
||||
let Some(image) = graphics.image(id) else {
|
||||
continue;
|
||||
};
|
||||
let info = p.placement_render_info(&image, term)?;
|
||||
if !info.viewport_visible || info.pixel_width == 0 || info.pixel_height == 0 {
|
||||
continue;
|
||||
}
|
||||
let size = [image.width()? as usize, image.height()? as usize];
|
||||
if size.contains(&0) {
|
||||
continue;
|
||||
}
|
||||
ensure!(
|
||||
size[0] <= painter.ctx().input(|i| i.max_texture_side)
|
||||
&& size[1] <= painter.ctx().input(|i| i.max_texture_side),
|
||||
"Kitty image exceeds GPU texture size limit"
|
||||
);
|
||||
seen.insert(id);
|
||||
let generation = image.generation()?;
|
||||
if textures.get(&id).is_none_or(|(g, _)| *g != generation) {
|
||||
let data = image.data()?;
|
||||
let pixels = match image.format()? {
|
||||
ImageFormat::Rgba => ColorImage::from_rgba_unmultiplied(size, data),
|
||||
ImageFormat::Rgb => ColorImage::from_rgb(size, data),
|
||||
_ => continue,
|
||||
};
|
||||
textures.insert(
|
||||
id,
|
||||
(
|
||||
generation,
|
||||
painter.ctx().load_texture(
|
||||
format!("kitty-{id}"),
|
||||
pixels,
|
||||
TextureOptions::LINEAR,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
let scale = vec2(1.0, 1.0);
|
||||
let dest = Rect::from_min_size(
|
||||
rect.min
|
||||
+ vec2(
|
||||
info.viewport_col as f32 * cell.x,
|
||||
info.viewport_row as f32 * cell.y,
|
||||
)
|
||||
+ vec2(
|
||||
p.x_offset()? as f32 * scale.x,
|
||||
p.y_offset()? as f32 * scale.y,
|
||||
),
|
||||
vec2(
|
||||
info.pixel_width as f32 * scale.x,
|
||||
info.pixel_height as f32 * scale.y,
|
||||
),
|
||||
);
|
||||
let uv = Rect::from_min_max(
|
||||
pos2(
|
||||
info.source_x as f32 / size[0] as f32,
|
||||
info.source_y as f32 / size[1] as f32,
|
||||
),
|
||||
pos2(
|
||||
(info.source_x + info.source_width) as f32 / size[0] as f32,
|
||||
(info.source_y + info.source_height) as f32 / size[1] as f32,
|
||||
),
|
||||
);
|
||||
images.push((p.z()?, id, p.placement_id()?, dest, uv));
|
||||
}
|
||||
images.sort_by_key(|(z, id, pid, _, _)| (*z, *id, *pid));
|
||||
for (_, id, _, dest, uv) in images {
|
||||
painter.image(textures[&id].1.id(), dest, uv, Color32::WHITE);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
use crate::{config::Config, input, pane, render};
|
||||
use base64::Engine;
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use libghostty_vt::{
|
||||
fmt,
|
||||
kitty::graphics::{self, ImageFormat, PlacementIterator},
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
rc::Rc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
fn plain(term: &libghostty_vt::Terminal<'_, '_>) -> String {
|
||||
let mut formatter =
|
||||
fmt::Formatter::new(term, fmt::FormatterOptions::new().with_trim(true)).unwrap();
|
||||
let bytes = formatter.format_alloc(None).unwrap();
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
fn key(key: Key, modifiers: Modifiers) -> Event {
|
||||
Event::Key {
|
||||
key,
|
||||
physical_key: Some(key),
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_preserves_unicode_colors_and_application_input_modes() {
|
||||
let mut term = pane::terminal(&Config::default()).unwrap();
|
||||
term.vt_write("hello \x1b[31mred\x1b[0m \u{754c}e\u{301}\r\n".as_bytes());
|
||||
assert!(plain(&term).contains("hello red \u{754c}e\u{301}"));
|
||||
let events = [
|
||||
key(Key::A, Modifiers::NONE),
|
||||
Event::Text("a".into()),
|
||||
key(Key::B, Modifiers::NONE),
|
||||
Event::Text("b".into()),
|
||||
];
|
||||
assert_eq!(input::encode(&term, &events).unwrap(), b"ab");
|
||||
assert_eq!(
|
||||
input::encode(&term, &[key(Key::C, Modifiers::CTRL)]).unwrap(),
|
||||
b"\x03"
|
||||
);
|
||||
assert_eq!(
|
||||
input::encode(&term, &[key(Key::ArrowUp, Modifiers::NONE)]).unwrap(),
|
||||
b"\x1b[A"
|
||||
);
|
||||
term.vt_write(b"\x1b[?1h");
|
||||
assert_eq!(
|
||||
input::encode(&term, &[key(Key::ArrowUp, Modifiers::NONE)]).unwrap(),
|
||||
b"\x1bOA"
|
||||
);
|
||||
term.resize(12, 6, 9, 20).unwrap();
|
||||
assert!(plain(&term).contains("hello red"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_return_to_the_pty_and_alternate_screen_restores() {
|
||||
let mut term = pane::terminal(&Config::default()).unwrap();
|
||||
let response = Rc::new(RefCell::new(vec![]));
|
||||
let writer = response.clone();
|
||||
term.on_pty_write(move |_, bytes| writer.borrow_mut().extend_from_slice(bytes))
|
||||
.unwrap();
|
||||
term.vt_write(b"hello\x1b[6n");
|
||||
assert_eq!(*response.borrow(), b"\x1b[1;6R");
|
||||
term.vt_write(b"\x1b[?1049hother screen\x1b[?1049l");
|
||||
assert!(plain(&term).contains("hello"));
|
||||
assert!(!plain(&term).contains("other screen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_images_decode_render_reuse_and_delete() {
|
||||
graphics::set_png_decoder(Some(Box::new(render::PngDecoder))).unwrap();
|
||||
let mut term = pane::terminal(&Config::default()).unwrap();
|
||||
let replies = Rc::new(RefCell::new(vec![]));
|
||||
let output = replies.clone();
|
||||
term.on_pty_write(move |_, data| output.borrow_mut().extend_from_slice(data))
|
||||
.unwrap();
|
||||
// Split delivery across VT writes, including the control string terminator.
|
||||
term.vt_write(b"\x1b_Ga=T,f=24,s=2,v=1,i=7,c=4,r=2;//8A");
|
||||
term.vt_write(b"AP8A\x1b\\");
|
||||
assert!(String::from_utf8_lossy(&replies.borrow()).contains("OK"));
|
||||
{
|
||||
let graphics = term.kitty_graphics().unwrap();
|
||||
let image = graphics.image(7).expect("RGB image stored");
|
||||
assert_eq!(image.format().unwrap(), ImageFormat::Rgb);
|
||||
assert_eq!(image.data().unwrap(), [255, 255, 0, 0, 255, 0]);
|
||||
let mut it = PlacementIterator::new().unwrap();
|
||||
let mut ps = it.update(&graphics).unwrap();
|
||||
let info = ps
|
||||
.next()
|
||||
.unwrap()
|
||||
.placement_render_info(&image, &term)
|
||||
.unwrap();
|
||||
assert!(info.viewport_visible);
|
||||
assert_eq!((info.pixel_width, info.pixel_height), (32, 32));
|
||||
}
|
||||
let mut png = vec![];
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut png, 2, 1);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&[255, 0, 0, 0, 255, 0])
|
||||
.unwrap();
|
||||
}
|
||||
let payload = base64::engine::general_purpose::STANDARD.encode(png);
|
||||
let split = payload.len() / 8 * 4;
|
||||
term.vt_write(
|
||||
format!(
|
||||
"\x1b_Ga=T,f=100,i=8,c=2,r=1,m=1;{}\x1b\\",
|
||||
&payload[..split]
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
term.vt_write(format!("\x1b_Gm=0;{}\x1b\\", &payload[split..]).as_bytes());
|
||||
{
|
||||
let graphics = term.kitty_graphics().unwrap();
|
||||
let image = graphics.image(8).expect("PNG image stored");
|
||||
assert_eq!(image.format().unwrap(), ImageFormat::Rgba);
|
||||
assert_eq!(image.data().unwrap(), [255, 0, 0, 255, 0, 255, 0, 255]);
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut renderer = render::Renderer::new().unwrap();
|
||||
let rect = egui::Rect::from_min_size(egui::pos2(50.0, 60.0), egui::vec2(400.0, 240.0));
|
||||
let mut paint = || {
|
||||
ctx.run(egui::RawInput::default(), |ctx| {
|
||||
let painter = ctx
|
||||
.layer_painter(egui::LayerId::background())
|
||||
.with_clip_rect(rect);
|
||||
renderer
|
||||
.paint(
|
||||
&term,
|
||||
&painter,
|
||||
rect,
|
||||
egui::vec2(8.0, 16.0),
|
||||
&egui::FontId::monospace(12.0),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
})
|
||||
};
|
||||
let first = paint();
|
||||
assert_eq!(
|
||||
first
|
||||
.textures_delta
|
||||
.set
|
||||
.iter()
|
||||
.filter(|(id, _)| matches!(id,egui::TextureId::Managed(n) if *n>0))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(first.shapes.iter().all(|s| rect.contains_rect(s.clip_rect)));
|
||||
let second = paint();
|
||||
assert!(
|
||||
second.textures_delta.set.is_empty(),
|
||||
"unchanged images must reuse GPU textures"
|
||||
);
|
||||
term.vt_write(b"\x1b_Ga=d,d=I,i=7\x1b\\");
|
||||
assert!(term.kitty_graphics().unwrap().image(7).is_none());
|
||||
assert!(term.kitty_graphics().unwrap().image(8).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pty_executes_input_and_bracketed_paste_is_sanitized() {
|
||||
let config = Config {
|
||||
shell: vec!["/bin/sh".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
let mut pane = pane::Pane::new(&config, &ctx).unwrap();
|
||||
pane.send(b"printf '\\033[31mpty-%s\\033[0m\\n' 'success'\r".to_vec())
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
pane.drain(&ctx);
|
||||
if plain(&pane.terminal).contains("pty-success") {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(plain(&pane.terminal).contains("pty-success"));
|
||||
pane.pty.resize(91, 31, 9, 20).unwrap();
|
||||
pane.send(b"stty size\r".to_vec()).unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
pane.drain(&ctx);
|
||||
if plain(&pane.terminal).contains("31 91") {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(plain(&pane.terminal).contains("31 91"));
|
||||
pane.send(b"exit\r".to_vec()).unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !pane.dead && Instant::now() < deadline {
|
||||
pane.drain(&ctx);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(pane.dead);
|
||||
assert!(pane.send(b"after exit".to_vec()).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user