tui: add comments to tui.rs (#6369)
This commit is contained in:
@@ -22,6 +22,8 @@ use crossterm::event::DisableFocusChange;
|
|||||||
use crossterm::event::EnableBracketedPaste;
|
use crossterm::event::EnableBracketedPaste;
|
||||||
use crossterm::event::EnableFocusChange;
|
use crossterm::event::EnableFocusChange;
|
||||||
use crossterm::event::Event;
|
use crossterm::event::Event;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use crossterm::event::KeyboardEnhancementFlags;
|
use crossterm::event::KeyboardEnhancementFlags;
|
||||||
use crossterm::event::PopKeyboardEnhancementFlags;
|
use crossterm::event::PopKeyboardEnhancementFlags;
|
||||||
@@ -39,12 +41,17 @@ use ratatui::text::Line;
|
|||||||
|
|
||||||
use crate::custom_terminal;
|
use crate::custom_terminal;
|
||||||
use crate::custom_terminal::Terminal as CustomTerminal;
|
use crate::custom_terminal::Terminal as CustomTerminal;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use crate::key_hint;
|
||||||
use tokio::select;
|
use tokio::select;
|
||||||
use tokio_stream::Stream;
|
use tokio_stream::Stream;
|
||||||
|
|
||||||
/// A type alias for the terminal type used in this application
|
/// A type alias for the terminal type used in this application
|
||||||
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
|
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z'));
|
||||||
|
|
||||||
pub fn set_modes() -> Result<()> {
|
pub fn set_modes() -> Result<()> {
|
||||||
execute!(stdout(), EnableBracketedPaste)?;
|
execute!(stdout(), EnableBracketedPaste)?;
|
||||||
|
|
||||||
@@ -217,60 +224,11 @@ impl FrameRequester {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Tui {
|
impl Tui {
|
||||||
/// Emit a desktop notification now if the terminal is unfocused.
|
|
||||||
/// Returns true if a notification was posted.
|
|
||||||
pub fn notify(&mut self, message: impl AsRef<str>) -> bool {
|
|
||||||
if !self.terminal_focused.load(Ordering::Relaxed) {
|
|
||||||
let _ = execute!(stdout(), PostNotification(message.as_ref().to_string()));
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn new(terminal: Terminal) -> Self {
|
pub fn new(terminal: Terminal) -> Self {
|
||||||
let (frame_schedule_tx, frame_schedule_rx) = tokio::sync::mpsc::unbounded_channel();
|
let (frame_schedule_tx, frame_schedule_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let (draw_tx, _) = tokio::sync::broadcast::channel(1);
|
let (draw_tx, _) = tokio::sync::broadcast::channel(1);
|
||||||
|
|
||||||
// Spawn background scheduler to coalesce frame requests and emit draws at deadlines.
|
spawn_frame_scheduler(frame_schedule_rx, draw_tx.clone());
|
||||||
let draw_tx_clone = draw_tx.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
use tokio::select;
|
|
||||||
use tokio::time::Instant as TokioInstant;
|
|
||||||
use tokio::time::sleep_until;
|
|
||||||
|
|
||||||
let mut rx = frame_schedule_rx;
|
|
||||||
let mut next_deadline: Option<Instant> = None;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let target = next_deadline
|
|
||||||
.unwrap_or_else(|| Instant::now() + Duration::from_secs(60 * 60 * 24 * 365));
|
|
||||||
let sleep_fut = sleep_until(TokioInstant::from_std(target));
|
|
||||||
tokio::pin!(sleep_fut);
|
|
||||||
|
|
||||||
select! {
|
|
||||||
recv = rx.recv() => {
|
|
||||||
match recv {
|
|
||||||
Some(at) => {
|
|
||||||
if next_deadline.is_none_or(|cur| at < cur) {
|
|
||||||
next_deadline = Some(at);
|
|
||||||
}
|
|
||||||
// Do not send a draw immediately here. By continuing the loop,
|
|
||||||
// we recompute the sleep target so the draw fires once via the
|
|
||||||
// sleep branch, coalescing multiple requests into a single draw.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = &mut sleep_fut => {
|
|
||||||
if next_deadline.is_some() {
|
|
||||||
next_deadline = None;
|
|
||||||
let _ = draw_tx_clone.send(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Detect keyboard enhancement support before any EventStream is created so the
|
// Detect keyboard enhancement support before any EventStream is created so the
|
||||||
// crossterm poller can acquire its lock without contention.
|
// crossterm poller can acquire its lock without contention.
|
||||||
@@ -305,16 +263,46 @@ impl Tui {
|
|||||||
self.enhanced_keys_supported
|
self.enhanced_keys_supported
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emit a desktop notification now if the terminal is unfocused.
|
||||||
|
/// Returns true if a notification was posted.
|
||||||
|
pub fn notify(&mut self, message: impl AsRef<str>) -> bool {
|
||||||
|
if !self.terminal_focused.load(Ordering::Relaxed) {
|
||||||
|
let _ = execute!(stdout(), PostNotification(message.as_ref().to_string()));
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn event_stream(&self) -> Pin<Box<dyn Stream<Item = TuiEvent> + Send + 'static>> {
|
pub fn event_stream(&self) -> Pin<Box<dyn Stream<Item = TuiEvent> + Send + 'static>> {
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
let mut crossterm_events = crossterm::event::EventStream::new();
|
let mut crossterm_events = crossterm::event::EventStream::new();
|
||||||
let mut draw_rx = self.draw_tx.subscribe();
|
let mut draw_rx = self.draw_tx.subscribe();
|
||||||
|
|
||||||
|
// State for tracking how we should resume from ^Z suspend.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
let resume_pending = self.resume_pending.clone();
|
let resume_pending = self.resume_pending.clone();
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
let alt_screen_active = self.alt_screen_active.clone();
|
let alt_screen_active = self.alt_screen_active.clone();
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
let suspend_cursor_y = self.suspend_cursor_y.clone();
|
let suspend_cursor_y = self.suspend_cursor_y.clone();
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let suspend = move || {
|
||||||
|
if alt_screen_active.load(Ordering::Relaxed) {
|
||||||
|
// Disable alternate scroll when suspending from alt-screen
|
||||||
|
let _ = execute!(stdout(), DisableAlternateScroll);
|
||||||
|
let _ = execute!(stdout(), LeaveAlternateScreen);
|
||||||
|
resume_pending.store(ResumeAction::RestoreAlt as u8, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
resume_pending.store(ResumeAction::RealignInline as u8, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
let y = suspend_cursor_y.load(Ordering::Relaxed);
|
||||||
|
let _ = execute!(stdout(), MoveTo(0, y), crossterm::cursor::Show);
|
||||||
|
let _ = Tui::suspend();
|
||||||
|
};
|
||||||
|
|
||||||
let terminal_focused = self.terminal_focused.clone();
|
let terminal_focused = self.terminal_focused.clone();
|
||||||
let event_stream = async_stream::stream! {
|
let event_stream = async_stream::stream! {
|
||||||
loop {
|
loop {
|
||||||
@@ -323,31 +311,9 @@ impl Tui {
|
|||||||
match event {
|
match event {
|
||||||
crossterm::event::Event::Key(key_event) => {
|
crossterm::event::Event::Key(key_event) => {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
if matches!(
|
if SUSPEND_KEY.is_press(key_event) {
|
||||||
key_event,
|
suspend();
|
||||||
crossterm::event::KeyEvent {
|
// We continue here after resume.
|
||||||
code: crossterm::event::KeyCode::Char('z'),
|
|
||||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
|
||||||
kind: crossterm::event::KeyEventKind::Press,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if alt_screen_active.load(Ordering::Relaxed) {
|
|
||||||
// Disable alternate scroll when suspending from alt-screen
|
|
||||||
let _ = execute!(stdout(), DisableAlternateScroll);
|
|
||||||
let _ = execute!(stdout(), LeaveAlternateScreen);
|
|
||||||
resume_pending.store(ResumeAction::RestoreAlt as u8, Ordering::Relaxed);
|
|
||||||
} else {
|
|
||||||
resume_pending.store(ResumeAction::RealignInline as u8, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let y = suspend_cursor_y.load(Ordering::Relaxed);
|
|
||||||
let _ = execute!(stdout(), MoveTo(0, y));
|
|
||||||
}
|
|
||||||
let _ = execute!(stdout(), crossterm::cursor::Show);
|
|
||||||
let _ = Tui::suspend();
|
|
||||||
yield TuiEvent::Draw;
|
yield TuiEvent::Draw;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -389,6 +355,7 @@ impl Tui {
|
|||||||
};
|
};
|
||||||
Box::pin(event_stream)
|
Box::pin(event_stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn suspend() -> Result<()> {
|
fn suspend() -> Result<()> {
|
||||||
restore()?;
|
restore()?;
|
||||||
@@ -397,6 +364,8 @@ impl Tui {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When resuming from ^Z suspend, we want to put things back the way they were before suspend.
|
||||||
|
/// We capture the action in an object so we can pass it into the event stream, since the relevant
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn prepare_resume_action(
|
fn prepare_resume_action(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -490,12 +459,15 @@ impl Tui {
|
|||||||
height: u16,
|
height: u16,
|
||||||
draw_fn: impl FnOnce(&mut custom_terminal::Frame),
|
draw_fn: impl FnOnce(&mut custom_terminal::Frame),
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Precompute any viewport updates that need a cursor-position query before entering
|
// If we are resuming from ^Z, we need to prepare the resume action now so we can apply it
|
||||||
// the synchronized update, to avoid racing with the event reader.
|
// in the synchronized update.
|
||||||
let mut pending_viewport_area: Option<ratatui::layout::Rect> = None;
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
let mut prepared_resume =
|
let mut prepared_resume =
|
||||||
self.prepare_resume_action(take_resume_action(&self.resume_pending))?;
|
self.prepare_resume_action(take_resume_action(&self.resume_pending))?;
|
||||||
|
|
||||||
|
// Precompute any viewport updates that need a cursor-position query before entering
|
||||||
|
// the synchronized update, to avoid racing with the event reader.
|
||||||
|
let mut pending_viewport_area: Option<ratatui::layout::Rect> = None;
|
||||||
{
|
{
|
||||||
let terminal = &mut self.terminal;
|
let terminal = &mut self.terminal;
|
||||||
let screen_size = terminal.size()?;
|
let screen_size = terminal.size()?;
|
||||||
@@ -504,6 +476,9 @@ impl Tui {
|
|||||||
&& let Ok(cursor_pos) = terminal.get_cursor_position()
|
&& let Ok(cursor_pos) = terminal.get_cursor_position()
|
||||||
{
|
{
|
||||||
let last_known_cursor_pos = terminal.last_known_cursor_pos;
|
let last_known_cursor_pos = terminal.last_known_cursor_pos;
|
||||||
|
// If we resized AND the cursor moved, we adjust the viewport area to keep the
|
||||||
|
// cursor in the same position. This is a heuristic that seems to work well
|
||||||
|
// at least in iTerm2.
|
||||||
if cursor_pos.y != last_known_cursor_pos.y {
|
if cursor_pos.y != last_known_cursor_pos.y {
|
||||||
let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32;
|
let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32;
|
||||||
let new_viewport_area = terminal.viewport_area.offset(Offset {
|
let new_viewport_area = terminal.viewport_area.offset(Offset {
|
||||||
@@ -515,7 +490,6 @@ impl Tui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use synchronized update via backend instead of stdout()
|
|
||||||
std::io::stdout().sync_update(|_| {
|
std::io::stdout().sync_update(|_| {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -534,6 +508,7 @@ impl Tui {
|
|||||||
let mut area = terminal.viewport_area;
|
let mut area = terminal.viewport_area;
|
||||||
area.height = height.min(size.height);
|
area.height = height.min(size.height);
|
||||||
area.width = size.width;
|
area.width = size.width;
|
||||||
|
// If the viewport has expanded, scroll everything else up to make room.
|
||||||
if area.bottom() > size.height {
|
if area.bottom() > size.height {
|
||||||
terminal
|
terminal
|
||||||
.backend_mut()
|
.backend_mut()
|
||||||
@@ -541,9 +516,11 @@ impl Tui {
|
|||||||
area.y = size.height - area.height;
|
area.y = size.height - area.height;
|
||||||
}
|
}
|
||||||
if area != terminal.viewport_area {
|
if area != terminal.viewport_area {
|
||||||
|
// TODO(nornagon): probably this could be collapsed with the clear + set_viewport_area above.
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
terminal.set_viewport_area(area);
|
terminal.set_viewport_area(area);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.pending_history_lines.is_empty() {
|
if !self.pending_history_lines.is_empty() {
|
||||||
crate::insert_history::insert_history_lines(
|
crate::insert_history::insert_history_lines(
|
||||||
terminal,
|
terminal,
|
||||||
@@ -551,6 +528,7 @@ impl Tui {
|
|||||||
)?;
|
)?;
|
||||||
self.pending_history_lines.clear();
|
self.pending_history_lines.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the y position for suspending so Ctrl-Z can place the cursor correctly.
|
// Update the y position for suspending so Ctrl-Z can place the cursor correctly.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -564,6 +542,7 @@ impl Tui {
|
|||||||
self.suspend_cursor_y
|
self.suspend_cursor_y
|
||||||
.store(inline_area_bottom, Ordering::Relaxed);
|
.store(inline_area_bottom, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.draw(|frame| {
|
terminal.draw(|frame| {
|
||||||
draw_fn(frame);
|
draw_fn(frame);
|
||||||
})
|
})
|
||||||
@@ -571,6 +550,51 @@ impl Tui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn background scheduler to coalesce frame requests and emit draws at deadlines.
|
||||||
|
fn spawn_frame_scheduler(
|
||||||
|
frame_schedule_rx: tokio::sync::mpsc::UnboundedReceiver<Instant>,
|
||||||
|
draw_tx: tokio::sync::broadcast::Sender<()>,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use tokio::select;
|
||||||
|
use tokio::time::Instant as TokioInstant;
|
||||||
|
use tokio::time::sleep_until;
|
||||||
|
|
||||||
|
let mut rx = frame_schedule_rx;
|
||||||
|
let mut next_deadline: Option<Instant> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let target = next_deadline
|
||||||
|
.unwrap_or_else(|| Instant::now() + Duration::from_secs(60 * 60 * 24 * 365));
|
||||||
|
let sleep_fut = sleep_until(TokioInstant::from_std(target));
|
||||||
|
tokio::pin!(sleep_fut);
|
||||||
|
|
||||||
|
select! {
|
||||||
|
recv = rx.recv() => {
|
||||||
|
match recv {
|
||||||
|
Some(at) => {
|
||||||
|
if next_deadline.is_none_or(|cur| at < cur) {
|
||||||
|
next_deadline = Some(at);
|
||||||
|
}
|
||||||
|
// Do not send a draw immediately here. By continuing the loop,
|
||||||
|
// we recompute the sleep target so the draw fires once via the
|
||||||
|
// sleep branch, coalescing multiple requests into a single draw.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = &mut sleep_fut => {
|
||||||
|
if next_deadline.is_some() {
|
||||||
|
next_deadline = None;
|
||||||
|
let _ = draw_tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Command that emits an OSC 9 desktop notification with a message.
|
/// Command that emits an OSC 9 desktop notification with a message.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PostNotification(pub String);
|
pub struct PostNotification(pub String);
|
||||||
|
|||||||
Reference in New Issue
Block a user