use std::time::{Duration, Instant};
use eframe::egui::{self, FontId, Popup, Vec2};
use ratatui::Terminal;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use rcmd_tui::app::{App, Exec};
use rcmd_tui::{state, ui};
use crate::dialog::{FontDialog, Verdict};
use crate::exec;
use crate::grid::{EguiBackend, Metrics, Palette};
use crate::keys::{self, Input};
use crate::menu::{self, WindowEntry};
use crate::settings::{self, Window};
use crate::term::TerminalPane;
const IDLE_FRAME: Duration = Duration::from_secs(2);
pub struct Gui {
app: App,
terminal: Terminal<EguiBackend>,
font: FontId,
metrics: Metrics,
palette: Palette,
size: (u16, u16),
last_frame: Instant,
startup_keys: Vec<KeyEvent>,
pane: TerminalPane,
saved: bool,
focus_menu: bool,
window: Window,
config_size: f32,
loaded_font: Option<String>,
font_dialog: Option<FontDialog>,
wheel: f32,
context: Option<egui::Pos2>,
}
const CONTEXT_MENU: &[(&str, &str)] = &[
("View", "view"),
("Edit", "edit"),
("Copy...", "copy"),
("Move/rename...", "move"),
("Delete", "delete"),
("Mark / unmark", "mark"),
("Pack into archive...", "pack"),
("Checksum...", "checksum"),
("Diff against HEAD", "diff-head"),
("Info", "info-view"),
];
impl Gui {
pub fn new(
cc: &eframe::CreationContext<'_>,
app: App,
window: Window,
config_size: f32,
startup_keys: Vec<KeyEvent>,
) -> anyhow::Result<Self> {
let loaded_font = crate::font::install(&cc.egui_ctx, window.font.as_deref());
egui_extras::install_image_loaders(&cc.egui_ctx);
cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false);
let font_size = window.size();
let font = FontId::monospace(font_size);
let metrics = Metrics::estimate(font_size);
let palette = palette_from_theme();
cc.egui_ctx.set_visuals(visuals_for(palette));
let terminal = Terminal::new(EguiBackend::new(80, 25, palette))?;
Ok(Self {
app,
terminal,
font,
metrics,
palette,
size: (80, 25),
last_frame: Instant::now(),
startup_keys,
pane: TerminalPane::new(80, 25),
saved: false,
focus_menu: false,
window,
config_size,
loaded_font,
font_dialog: None,
wheel: 0.0,
context: None,
})
}
fn run_exec(&mut self, cmd: Exec) {
let quiet = matches!(cmd, Exec::Quiet(_));
if !quiet && self.app.subshell_alive() {
let (cols, rows) = self.size;
self.pane.open(&mut self.app, cmd, cols, rows);
self.app.set_dirty();
return;
}
let cwd = self.app.panels[self.app.active].local_cwd();
match exec::run(&cmd, &cwd) {
Ok(Some(note)) => self.app.status = Some(format!(" {note} ")),
Ok(None) => {}
Err(err) => self.app.status = Some(format!(" {err} ")),
}
self.app.finish_remote_edit();
self.app.set_dirty();
}
fn pane_frame(&mut self, ui: &mut egui::Ui, origin: egui::Pos2, input: Vec<Input>) -> Duration {
let pane = &mut self.pane;
let (cols, rows) = self.size;
pane.resize(&mut self.app, cols, rows);
let open = pane.feed(&mut self.app, &input) & pane.step(&mut self.app);
pane.paint(ui.painter(), origin, self.metrics, &self.font, self.palette);
let wait = pane.repaint_after();
if !open {
pane.close();
self.app.end_subshell();
self.app.finish_remote_edit();
self.app.set_dirty();
return Duration::ZERO;
}
wait
}
fn set_font_size(&mut self, size: f32, persist: bool) {
let size = size.clamp(4.0, 96.0);
self.window.font_size = Some(size);
self.font = FontId::monospace(size);
self.app.set_dirty();
if persist && let Err(err) = settings::save(|state| state.font_size = Some(size)) {
self.app.status = Some(format!(" could not save font size: {err} "));
}
}
fn set_font(&mut self, ctx: &egui::Context, font: Option<String>) {
self.window.font = font;
self.loaded_font = crate::font::install(ctx, self.window.font.as_deref());
self.app.set_dirty();
}
fn size_key(&mut self, key: &KeyEvent) -> bool {
let Some(size) = stepped_size(key, self.window.size(), self.config_size) else {
return false;
};
self.set_font_size(size, true);
true
}
fn font_dialog_frame(&mut self, ctx: &egui::Context) {
let Some(dialog) = self.font_dialog.as_mut() else {
return;
};
match dialog.show(ctx) {
Verdict::Open { changed } => {
if changed {
let choice = dialog.choice.clone();
if choice.font != self.window.font {
self.set_font(ctx, choice.font.clone());
if let Some(dialog) = self.font_dialog.as_mut() {
dialog.loaded = self.loaded_font.clone();
}
}
if choice.font_size != self.window.font_size {
self.set_font_size(choice.size(), false);
}
}
}
Verdict::Keep => {
let choice = self.window.clone();
self.font_dialog = None;
if let Err(err) = settings::save(|state| {
state.font = choice.font.clone();
state.font_size = choice.font_size;
}) {
self.app.status = Some(format!(" could not save the font: {err} "));
}
self.app.set_dirty();
}
Verdict::Revert => {
let before = dialog.before().clone();
self.font_dialog = None;
if before.font != self.window.font {
self.set_font(ctx, before.font.clone());
}
self.set_font_size(before.size(), false);
}
}
}
fn paint_image(&self, ui: &mut egui::Ui, origin: egui::Pos2) {
let Some((path, cells)) = self.app.image_on_screen() else {
return;
};
let m = self.metrics;
let rect = egui::Rect::from_min_size(
origin + Vec2::new(cells.x as f32 * m.width, cells.y as f32 * m.height),
Vec2::new(cells.width as f32 * m.width, cells.height as f32 * m.height),
);
ui.painter().rect_filled(rect, 0.0, self.palette.bg);
let image = egui::Image::new(format!("file://{}", path.display()))
.max_size(rect.size())
.maintain_aspect_ratio(true);
ui.put(rect, image);
}
fn context_menu(&mut self, ctx: &egui::Context) {
let Some(pos) = self.context else {
return;
};
let mut chosen = None;
let area = egui::Area::new(egui::Id::new("context-menu"))
.fixed_pos(pos)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
egui::Frame::menu(ui.style()).show(ui, |ui| {
for (label, action) in CONTEXT_MENU {
if ui.button(*label).clicked() {
chosen = Some(*action);
}
}
})
});
let away = ctx.input(|i| {
i.key_pressed(egui::Key::Escape)
|| (i.pointer.any_pressed()
&& i.pointer
.interact_pos()
.is_some_and(|p| !area.response.rect.contains(p)))
});
if let Some(action) = chosen {
self.context = None;
self.app.run_named_action(action);
} else if away {
self.context = None;
self.app.set_dirty();
}
}
fn window_io(&mut self, ctx: &egui::Context, origin: egui::Pos2) {
if let Some(text) = self.app.take_clipboard_out() {
ctx.copy_text(text);
}
let m = self.metrics;
let cell = |p: egui::Pos2| {
(
((p.x - origin.x) / m.width).max(0.0) as u16,
((p.y - origin.y) / m.height).max(0.0) as u16,
)
};
let (dropped, hovering, at) = ctx.input(|i| {
(
i.raw.dropped_files.clone(),
!i.raw.hovered_files.is_empty(),
i.pointer.latest_pos(),
)
});
let paths: Vec<std::path::PathBuf> = dropped
.iter()
.map(|f| f.path().to_path_buf())
.filter(|p| !p.as_os_str().is_empty())
.collect();
if !paths.is_empty() {
let (x, y) = at.map(cell).unwrap_or((0, 0));
self.app.drop_paths(paths, x, y);
ctx.request_repaint();
} else if hovering {
self.app.status = Some(" drop to copy into the panel under the pointer ".into());
self.app.set_dirty();
}
if let Some(pos) = self.terminal.backend().cursor() {
let rect = egui::Rect::from_min_size(
origin + Vec2::new(pos.x as f32 * m.width, pos.y as f32 * m.height),
Vec2::new(m.width, m.height),
);
ctx.output_mut(|o| {
o.ime = Some(egui::output::IMEOutput {
purpose: Default::default(),
rect,
cursor_rect: rect,
should_interrupt_composition: false,
})
});
}
}
fn save_once(&mut self) {
if self.saved {
return;
}
self.saved = true;
self.app.cancel_background();
if let Err(err) = state::save_session(&self.app) {
eprintln!("rcmd-egui: could not save state: {err}");
}
}
}
impl eframe::App for Gui {
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
let bg = self.palette.bg;
[
bg.r() as f32 / 255.0,
bg.g() as f32 / 255.0,
bg.b() as f32 / 255.0,
1.0,
]
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
let title = self.app.title();
if self.app.title_shown.as_deref() != Some(title.as_str()) {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.app.title_shown = Some(title);
}
for notice in std::mem::take(&mut self.app.notices) {
notify_desktop(¬ice);
}
self.metrics = Metrics::measure(&ctx, &self.font);
let menu_open =
Popup::is_any_open(&ctx) || self.font_dialog.is_some() || self.context.is_some();
if menu_open && ctx.input(|i| i.key_pressed(egui::Key::F9)) {
Popup::close_all(&ctx);
}
let request = menu::Request {
open_first: self.app.take_menu_request(),
enabled: !self.pane.is_open(),
};
let Self {
app, focus_menu, ..
} = self;
let entry = egui::Panel::top("menubar")
.show(ui, |ui| menu::show(app, ui, request, focus_menu))
.inner;
if entry == Some(WindowEntry::Font) && self.font_dialog.is_none() {
self.font_dialog = Some(FontDialog::open(&self.window, self.loaded_font.clone()));
}
self.font_dialog_frame(&ctx);
if !menu_open && let Some(id) = ctx.memory(|m| m.focused()) {
ctx.memory_mut(|m| m.surrender_focus(id));
}
let available = ui.available_rect_before_wrap();
let origin = available.min;
let (cols, rows) = self.metrics.cells(available.size());
if (cols, rows) != self.size {
self.size = (cols, rows);
self.terminal.backend_mut().set_size(cols, rows);
self.app.resize_subshell(cols, rows);
self.app.set_dirty();
}
let mut input = if menu_open {
Vec::new()
} else {
ctx.input(|i| keys::collect(i, origin, self.metrics, &mut self.wheel))
};
if !self.startup_keys.is_empty() && !menu_open {
let key = self.startup_keys.remove(0);
input.insert(0, Input::Key(key));
ctx.request_repaint();
}
if self.pane.is_open() {
let wait = self.pane_frame(ui, origin, input);
ctx.request_repaint_after(wait);
return;
}
if !input.is_empty() {
self.app.set_dirty();
for event in input {
match event {
Input::Key(key) if self.size_key(&key) => {}
Input::Key(key) => self.app.on_key(key),
Input::Mouse(mouse) => self.app.on_mouse(mouse),
Input::Paste(text) => self.app.on_paste(&text),
Input::Context { pos, column, row } => {
self.app.on_mouse(ratatui::crossterm::event::MouseEvent {
kind: ratatui::crossterm::event::MouseEventKind::Down(
ratatui::crossterm::event::MouseButton::Left,
),
column,
row,
modifiers: KeyModifiers::NONE,
});
self.context = Some(pos);
}
}
}
if self.app.menu_requested() {
ctx.request_repaint();
}
}
let busy = self.app.tick();
if self.app.dirty() || busy || self.last_frame.elapsed() >= IDLE_FRAME {
if self.app.take_repaint() {
let _ = self.terminal.clear();
}
let Self { app, terminal, .. } = self;
if let Err(err) = terminal.draw(|frame| ui::draw(frame, app)) {
eprintln!("rcmd-egui: draw failed: {err}");
}
self.app.clear_dirty();
self.last_frame = Instant::now();
}
self.terminal
.backend()
.paint(ui.painter(), origin, self.metrics, &self.font);
self.paint_image(ui, origin);
self.context_menu(&ctx);
self.window_io(&ctx, origin);
if let Some(cmd) = self.app.take_exec() {
self.run_exec(cmd);
}
self.app.hold_quit_for_jobs();
if self.app.exiting() {
self.save_once();
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
return;
}
ctx.request_repaint_after(Duration::from_millis(if busy { 50 } else { 500 }));
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
self.save_once();
}
}
fn stepped_size(key: &KeyEvent, size: f32, config_size: f32) -> Option<f32> {
if !key.modifiers.contains(KeyModifiers::CONTROL) {
return None;
}
match key.code {
KeyCode::Char('=') | KeyCode::Char('+') => Some(size + 1.0),
KeyCode::Char('-') => Some(size - 1.0),
KeyCode::Char('0') => Some(config_size),
_ => None,
}
}
const MENU_BAR_HEIGHT: f32 = 26.0;
pub fn window_size(font_size: f32) -> Vec2 {
let metrics = Metrics::estimate(font_size);
Vec2::new(
metrics.width * 100.0,
metrics.height * 30.0 + MENU_BAR_HEIGHT,
)
}
fn visuals_for(palette: Palette) -> egui::Visuals {
let bg = palette.bg;
let bright =
0.299 * f32::from(bg.r()) + 0.587 * f32::from(bg.g()) + 0.114 * f32::from(bg.b()) > 140.0;
let mut visuals = if bright {
egui::Visuals::light()
} else {
egui::Visuals::dark()
};
visuals.panel_fill = bg;
visuals.window_fill = bg;
visuals
}
fn palette_from_theme() -> Palette {
let (fg, bg) = ui::base_colors();
let fallback = Palette::default();
Palette {
fg: crate::grid::to_color32(fg, fallback.fg),
bg: crate::grid::to_color32(bg, fallback.bg),
}
}
fn notify_desktop(text: &str) {
let spawned = if cfg!(target_os = "macos") {
let quoted = text.replace('\\', "\\\\").replace('"', "\\\"");
std::process::Command::new("osascript")
.args([
"-e",
&format!("display notification \"{quoted}\" with title \"rcmd\""),
])
.spawn()
} else {
std::process::Command::new("notify-send")
.args(["--app-name=rcmd", "rcmd", text])
.spawn()
};
if let Ok(mut child) = spawned {
std::thread::spawn(move || child.wait());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_size_keys_step_and_reset_and_nothing_else_is_theirs() {
let ctrl = |c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL);
assert_eq!(stepped_size(&ctrl('='), 14.0, 12.0), Some(15.0));
assert_eq!(stepped_size(&ctrl('+'), 14.0, 12.0), Some(15.0));
assert_eq!(stepped_size(&ctrl('-'), 14.0, 12.0), Some(13.0));
assert_eq!(stepped_size(&ctrl('0'), 14.0, 12.0), Some(12.0));
let plain = |c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
assert_eq!(stepped_size(&plain('-'), 14.0, 12.0), None);
assert_eq!(stepped_size(&plain('0'), 14.0, 12.0), None);
assert_eq!(stepped_size(&ctrl('x'), 14.0, 12.0), None);
}
}