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>,
}
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());
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,
})
}
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 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();
self.metrics = Metrics::measure(&ctx, &self.font);
let menu_open = Popup::is_any_open(&ctx) || self.font_dialog.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))
};
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),
}
}
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);
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),
}
}
#[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);
}
}