use std::io;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use newt_core::tty::raw_mode::RawModeGuard;
use crate::inline_viewport::InlineTerm;
use crate::modal_size::{ModalSize, SizeKey, MIN_ROWS};
use crate::panel_controls::{Controls, Effect};
use crate::session_worker::PanelWindow;
pub(crate) use newtui::{Flow, Key};
pub(crate) fn key_from_event(code: KeyCode, ctrl: bool) -> Key {
match (code, ctrl) {
(KeyCode::Char(c), true) => Key::Ctrl(c),
(KeyCode::Char(c), false) => Key::Char(c),
(KeyCode::Up, _) => Key::Up,
(KeyCode::Down, _) => Key::Down,
(KeyCode::Left, _) => Key::Left,
(KeyCode::Right, _) => Key::Right,
(KeyCode::Enter, _) => Key::Enter,
(KeyCode::Esc, _) => Key::Esc,
(KeyCode::Backspace, _) => Key::Backspace,
(KeyCode::Tab, _) => Key::Tab,
_ => Key::Other,
}
}
pub(crate) trait Screen {
fn draw(&self, frame: &mut ratatui::Frame);
fn key(&mut self, key: Key) -> Flow;
}
pub(crate) struct PanelRawGuard {
_raw: RawModeGuard,
}
impl PanelRawGuard {
pub(crate) fn enter() -> io::Result<Self> {
Ok(Self {
_raw: RawModeGuard::enter()?,
})
}
}
pub(crate) fn make_terminal(height: u16) -> io::Result<InlineTerm> {
let lease =
crate::inline_viewport::lease_below_transcript(height, newt_core::tty::OnCollision::Shift)?;
crate::inline_viewport::inline_terminal(lease)
}
fn relet<T>(
granted: u16,
wanted: u16,
mut lease: impl FnMut(u16) -> io::Result<T>,
) -> io::Result<(T, u16)> {
let refused = match lease(wanted) {
Ok(terminal) => return Ok((terminal, wanted)),
Err(error) => error,
};
for rows in (MIN_ROWS..=granted).rev().filter(|rows| *rows != wanted) {
if let Ok(terminal) = lease(rows) {
return Ok((terminal, rows));
}
}
Err(refused)
}
fn draw_overlay(frame: &mut ratatui::Frame, text: &str) {
let area = frame.area();
if area.height < 3 || area.width < 4 {
return;
}
let line = ratatui::layout::Rect {
x: area.x + 2,
y: area.bottom() - 2,
width: area.width - 3,
height: 1,
};
frame.render_widget(ratatui::widgets::Clear, line);
frame.render_widget(
ratatui::widgets::Paragraph::new(text.to_string())
.style(crate::theme::style(crate::theme::Role::Identity)),
line,
);
}
fn clear_own_rows(terminal: &mut InlineTerm) -> io::Result<()> {
use ratatui::backend::{Backend, ClearType};
let area = terminal.get_frame().area();
for y in area.top()..area.bottom() {
let backend = terminal.backend_mut();
backend.set_cursor_position(ratatui::layout::Position { x: 0, y })?;
backend.clear_region(ClearType::CurrentLine)?;
}
terminal.swap_buffers();
terminal.swap_buffers();
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
enum Input {
Key(Key),
Remeasure,
Size(SizeKey),
Mouse(crossterm::event::MouseEvent),
Ignore,
}
fn classify(event: Event) -> Input {
match event {
Event::Key(key) if key.kind != KeyEventKind::Press => Input::Ignore,
Event::Key(key)
if key.modifiers.contains(KeyModifiers::SHIFT) && key.code == KeyCode::Up =>
{
Input::Size(SizeKey::Grow)
}
Event::Key(key)
if key.modifiers.contains(KeyModifiers::SHIFT) && key.code == KeyCode::Down =>
{
Input::Size(SizeKey::Shrink)
}
Event::Key(key)
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('z') =>
{
Input::Size(SizeKey::Zoom)
}
Event::Key(key) => Input::Key(key_from_event(
key.code,
key.modifiers.contains(KeyModifiers::CONTROL),
)),
Event::Resize(..) => Input::Remeasure,
Event::Mouse(mouse) => Input::Mouse(mouse),
_ => Input::Ignore,
}
}
pub(crate) fn drive(
screen: &mut dyn Screen,
height: u16,
window: Option<&PanelWindow>,
) -> io::Result<bool> {
let mut applied = false;
let loop_result = {
let _raw = PanelRawGuard::enter()?;
(|| -> io::Result<()> {
let mut size = ModalSize::new(height);
let mut terminal = match window {
Some(window) => window.terminal()?,
None => make_terminal(size.requested())?,
};
let mut controls = Controls::new(crate::prefix::current());
#[cfg(unix)]
let _mouse = crate::mouse::MouseCaptureGuard::enable_drag(match window {
Some(window) => crate::mouse::MouseSink::Tty(window.output()?),
None => crate::mouse::MouseSink::Stdout,
});
clear_own_rows(&mut terminal)?;
loop {
let overlay = controls.overlay();
terminal.draw(|f| {
screen.draw(f);
if let Some(text) = &overlay {
draw_overlay(f, text);
}
})?;
if !event::poll(Duration::from_millis(250))? {
continue;
}
let area = window.map_or_else(|| terminal.get_frame().area(), PanelWindow::area);
let effect = match classify(event::read()?) {
Input::Key(key) => controls.key(key),
Input::Mouse(mouse) => controls.mouse(mouse, area),
Input::Size(key) => Effect::Size(key),
Input::Remeasure => Effect::Redraw,
Input::Ignore => Effect::Nothing,
};
let relayout = match effect {
Effect::Forward(key) => {
if let Flow::Close(apply) = screen.key(key) {
applied = apply;
break;
}
None
}
Effect::Redraw => Some(None),
Effect::Size(key) => size.apply(key, area.height).map(Some),
Effect::Nothing => None,
};
if let Some(rows) = relayout {
terminal = match window {
Some(window) => {
window.remeasure(rows);
window.terminal()?
}
None => {
let granted = terminal.get_frame().area().height;
drop(terminal);
let (terminal, got) = relet(granted, size.requested(), make_terminal)?;
if got != size.requested() {
size = ModalSize::new(got);
}
terminal
}
};
clear_own_rows(&mut terminal)?;
}
}
clear_own_rows(&mut terminal)?;
Ok(())
})()
};
loop_result.map(|()| applied)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_ungrantable_lease_keeps_the_panel_at_its_granted_rows() {
let free = 12;
let asked = std::cell::RefCell::new(Vec::new());
let mut arbiter = |rows: u16| {
asked.borrow_mut().push(rows);
if rows <= free {
Ok(rows)
} else {
Err(io::Error::other("another surface already owns these rows"))
}
};
assert_eq!(relet(8, 10, &mut arbiter).unwrap(), (10, 10), "grantable");
assert_eq!(
relet(8, 20, &mut arbiter).unwrap(),
(8, 8),
"kept, not closed"
);
assert_eq!(
*asked.borrow(),
vec![10, 20, 8],
"the fallback asks the arbiter too"
);
assert_eq!(
relet(20, 20, &mut arbiter).unwrap(),
(12, 12),
"degrades to the free rows"
);
let mut five_free = |rows: u16| {
if rows <= 5 {
Ok(rows)
} else {
Err(io::Error::other("another surface already owns these rows"))
}
};
assert_eq!(relet(7, 7, &mut five_free).unwrap(), (5, 5));
let mut none_free = |rows: u16| -> io::Result<u16> {
let _ = rows;
Err(io::Error::other("another surface already owns these rows"))
};
assert!(
relet(7, 7, &mut none_free).is_err(),
"not even MIN_ROWS fits: the only error"
);
}
#[test]
#[serial_test::serial(tty_arbiter)]
fn with_a_real_row_holder_relet_keeps_what_the_arbiter_grants() {
use newt_core::tty::{OnCollision, Region, Terminal};
let screen =
ratatui::backend::Backend::size(&ratatui::backend::CrosstermBackend::new(io::stdout()))
.map_or(24, |size| size.height);
let holder = Terminal::lease_region(
Region::Rows {
top: screen - 3,
height: 3,
},
OnCollision::Refuse,
)
.expect("the holder takes the bottom rows");
let lease = |rows| crate::inline_viewport::lease_bottom_rows(rows, OnCollision::Shift);
assert!(
lease(screen).is_err(),
"the whole screen overlaps the holder"
);
let (_kept, rows) = relet(6, screen, lease).expect("falls back, stays open");
assert_eq!(rows, 6);
let (_kept, rows) = relet(6, crate::modal_size::FILL, lease).expect("zoom falls back");
assert_eq!(rows, 6);
drop(holder);
}
#[test]
fn a_resize_is_never_dropped_and_only_presses_are_keys() {
use crossterm::event::{KeyEvent, KeyEventState};
assert_eq!(classify(Event::Resize(80, 24)), Input::Remeasure);
assert_eq!(classify(Event::Resize(1, 1)), Input::Remeasure);
let key = |kind| {
Event::Key(KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
kind,
state: KeyEventState::NONE,
})
};
assert_eq!(
classify(key(KeyEventKind::Press)),
Input::Key(Key::Char('j'))
);
assert_eq!(classify(key(KeyEventKind::Release)), Input::Ignore);
assert_eq!(classify(key(KeyEventKind::Repeat)), Input::Ignore);
assert_eq!(classify(Event::FocusGained), Input::Ignore);
}
#[test]
fn shift_arrows_size_the_modal_and_ctrl_z_zooms() {
use crossterm::event::{KeyEvent, KeyEventState};
let press = |code, modifiers| {
Event::Key(KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
};
assert_eq!(
classify(press(KeyCode::Up, KeyModifiers::SHIFT)),
Input::Size(SizeKey::Grow)
);
assert_eq!(
classify(press(KeyCode::Down, KeyModifiers::SHIFT)),
Input::Size(SizeKey::Shrink)
);
assert_eq!(
classify(press(KeyCode::Char('z'), KeyModifiers::CONTROL)),
Input::Size(SizeKey::Zoom)
);
assert_eq!(
classify(press(KeyCode::Up, KeyModifiers::NONE)),
Input::Key(Key::Up)
);
assert_eq!(
classify(press(KeyCode::Char('z'), KeyModifiers::NONE)),
Input::Key(Key::Char('z'))
);
}
#[test]
fn enter_panel_raw_mode_is_the_only_way_in() {
let driver = crate::production_source(include_str!("panel.rs"));
let config = crate::production_source(include_str!("config_panel.rs"));
let backend = crate::production_source(include_str!("backend_panel.rs"));
let code_only = |source: &str| -> String {
source
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
};
for (name, source) in [
("panel", &driver),
("config_panel", &config),
("backend_panel", &backend),
] {
let code = code_only(source);
assert_eq!(
code.matches("enable_raw_mode(").count(),
0,
"{name}: raw mode comes from RawModeGuard, never crossterm directly"
);
assert_eq!(
code.matches("disable_raw_mode(").count(),
0,
"{name}: a bare disable means a restore that a panic skips"
);
}
assert!(
driver.contains("_raw: RawModeGuard"),
"PanelRawGuard must HOLD a RawModeGuard — composition, not a \
reimplementation"
);
for (name, source) in [("config_panel", &config), ("backend_panel", &backend)] {
assert_eq!(
source.matches("event::poll(").count(),
0,
"{name}: the event loop belongs to panel::drive"
);
}
assert!(
!driver.contains("impl Drop for PanelRawGuard"),
"the restore is RawModeGuard's; a Drop impl here would be a second one"
);
}
#[derive(Default)]
struct Recorder {
seen: Vec<Key>,
close_on: Option<(Key, bool)>,
}
impl Screen for Recorder {
fn draw(&self, _frame: &mut ratatui::Frame) {}
fn key(&mut self, key: Key) -> Flow {
self.seen.push(key);
match self.close_on {
Some((c, applied)) if c == key => Flow::Close(applied),
_ => Flow::Stay,
}
}
}
#[test]
fn screens_accept_newtui_keys_and_return_newtui_flow_directly() {
let mut screen = Recorder {
close_on: Some((Key::Enter, true)),
..Recorder::default()
};
let key: newtui::Key = Key::Enter;
let flow: newtui::Flow = screen.key(key);
assert_eq!(flow, newtui::Flow::Close(true));
}
#[test]
fn control_is_folded_into_the_key_rather_than_carried_beside_it() {
assert_eq!(key_from_event(KeyCode::Char('q'), false), Key::Char('q'));
assert_eq!(key_from_event(KeyCode::Char('q'), true), Key::Ctrl('q'));
assert_ne!(
key_from_event(KeyCode::Char('s'), true),
Key::Char('s'),
"Ctrl-S must not be readable as a plain `s`"
);
assert_eq!(key_from_event(KeyCode::Up, true), Key::Up);
assert_eq!(key_from_event(KeyCode::Esc, true), Key::Esc);
for code in [
KeyCode::F(7),
KeyCode::Delete,
KeyCode::Insert,
KeyCode::BackTab,
KeyCode::Home,
KeyCode::End,
KeyCode::PageUp,
KeyCode::PageDown,
KeyCode::Null,
] {
assert_eq!(key_from_event(code, false), Key::Other);
assert_eq!(key_from_event(code, true), Key::Other);
}
}
#[test]
fn a_close_carries_whether_the_operator_applied() {
let mut screen = Recorder {
close_on: Some((Key::Enter, true)),
..Recorder::default()
};
assert_eq!(screen.key(Key::Up), Flow::Stay);
assert_eq!(screen.key(Key::Enter), Flow::Close(true));
let mut cancelled = Recorder {
close_on: Some((Key::Esc, false)),
..Recorder::default()
};
assert_eq!(cancelled.key(Key::Esc), Flow::Close(false));
assert_eq!(
cancelled.seen,
vec![Key::Esc],
"the screen sees the key it closed on"
);
}
#[test]
fn the_screen_is_told_about_control() {
let mut screen = Recorder::default();
screen.key(Key::Ctrl('s'));
screen.key(Key::Char('s'));
assert_eq!(screen.seen, vec![Key::Ctrl('s'), Key::Char('s')]);
}
}