From f2134f66332bdba9c26f6b32725ec2aeb484a5c9 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 30 Jul 2025 17:06:55 -0700 Subject: [PATCH] resizable viewport (#1732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proof of concept for a resizable viewport. The general approach here is to duplicate the `Terminal` struct from ratatui, but with our own logic. This is a "light fork" in that we are still using all the base ratatui functions (`Buffer`, `Widget` and so on), but we're doing our own bookkeeping at the top level to determine where to draw everything. This approach could use improvement—e.g, when the window is resized to a smaller size, if the UI wraps, we don't correctly clear out the artifacts from wrapping. This is possible with a little work (i.e. tracking what parts of our UI would have been wrapped), but this behavior is at least at par with the existing behavior. https://github.com/user-attachments/assets/4eb17689-09fd-4daa-8315-c7ebc654986d cc @joshka who might have Thoughts™ --- NOTICE | 4 + codex-rs/tui/src/app.rs | 40 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 13 +- codex-rs/tui/src/bottom_pane/command_popup.rs | 2 +- .../tui/src/bottom_pane/file_search_popup.rs | 2 +- codex-rs/tui/src/bottom_pane/mod.rs | 4 + codex-rs/tui/src/chatwidget.rs | 4 + codex-rs/tui/src/custom_terminal.rs | 588 ++++++++++++++++++ codex-rs/tui/src/insert_history.rs | 15 +- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/tui.rs | 18 +- 11 files changed, 668 insertions(+), 23 deletions(-) create mode 100644 codex-rs/tui/src/custom_terminal.rs diff --git a/NOTICE b/NOTICE index ad09ca42..2805899d 100644 --- a/NOTICE +++ b/NOTICE @@ -1,2 +1,6 @@ OpenAI Codex Copyright 2025 OpenAI + +This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license. +Copyright (c) 2016-2022 Florian Dehau +Copyright (c) 2023-2025 The Ratatui Developers diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6823a83a..13ceabd7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -12,6 +12,8 @@ use codex_core::protocol::Event; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use ratatui::layout::Offset; +use ratatui::prelude::Backend; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -321,6 +323,44 @@ impl App<'_> { fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> { // TODO: add a throttle to avoid redrawing too often + let screen_size = terminal.size()?; + let last_known_screen_size = terminal.last_known_screen_size; + if screen_size != last_known_screen_size { + let cursor_pos = terminal.get_cursor_position()?; + let last_known_cursor_pos = terminal.last_known_cursor_pos; + if cursor_pos.y != last_known_cursor_pos.y { + // The terminal was resized. The only point of reference we have for where our viewport + // was moved is the cursor position. + // NB this assumes that the cursor was not wrapped as part of the resize. + let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32; + + let new_viewport_area = terminal.viewport_area.offset(Offset { + x: 0, + y: cursor_delta, + }); + terminal.set_viewport_area(new_viewport_area); + terminal.clear()?; + } + } + + let size = terminal.size()?; + let desired_height = match &self.app_state { + AppState::Chat { widget } => widget.desired_height(), + AppState::GitWarning { .. } => 10, + }; + let mut area = terminal.viewport_area; + area.height = desired_height; + area.width = size.width; + if area.bottom() > size.height { + terminal + .backend_mut() + .scroll_region_up(0..area.top(), area.bottom() - size.height)?; + area.y = size.height - area.height; + } + if area != terminal.viewport_area { + terminal.clear()?; + terminal.set_viewport_area(area); + } match &mut self.app_state { AppState::Chat { widget } => { terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index b15d81f8..4d313f14 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -71,6 +71,15 @@ impl ChatComposer<'_> { this } + pub fn desired_height(&self) -> u16 { + 2 + self.textarea.lines().len() as u16 + + match &self.active_popup { + ActivePopup::None => 0u16, + ActivePopup::Command(c) => c.calculate_required_height(), + ActivePopup::File(c) => c.calculate_required_height(), + } + } + /// Returns true if the composer currently contains no user input. pub(crate) fn is_empty(&self) -> bool { self.textarea.is_empty() @@ -651,7 +660,7 @@ impl WidgetRef for &ChatComposer<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { match &self.active_popup { ActivePopup::Command(popup) => { - let popup_height = popup.calculate_required_height(&area); + let popup_height = popup.calculate_required_height(); // Split the provided rect so that the popup is rendered at the // *top* and the textarea occupies the remaining space below. @@ -673,7 +682,7 @@ impl WidgetRef for &ChatComposer<'_> { self.textarea.render(textarea_rect, buf); } ActivePopup::File(popup) => { - let popup_height = popup.calculate_required_height(&area); + let popup_height = popup.calculate_required_height(); let popup_rect = Rect { x: area.x, diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index fd865047..da3b3a82 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -71,7 +71,7 @@ impl CommandPopup { /// Determine the preferred height of the popup. This is the number of /// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the /// table/border overhead (one line at the top and one at the bottom). - pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 { + pub(crate) fn calculate_required_height(&self) -> u16 { let matches = self.filtered_commands(); let row_count = matches.len().clamp(1, MAX_POPUP_ROWS) as u16; // Account for the border added by the Block that wraps the table. diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index 34eb59e4..e15f8690 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -109,7 +109,7 @@ impl FileSearchPopup { } /// Preferred height (rows) including border. - pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 { + pub(crate) fn calculate_required_height(&self) -> u16 { // Row count depends on whether we already have matches. If no matches // yet (e.g. initial search or query with no results) reserve a single // row so the popup is still visible. When matches are present we show diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 4ec1ba4b..2ca858d8 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -64,6 +64,10 @@ impl BottomPane<'_> { } } + pub fn desired_height(&self) -> u16 { + self.composer.desired_height() + } + /// Forward a key event to the active view or the composer. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { if let Some(mut view) = self.active_view.take() { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fde69786..33e3ee11 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -143,6 +143,10 @@ impl ChatWidget<'_> { } } + pub fn desired_height(&self) -> u16 { + self.bottom_pane.desired_height() + } + pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { self.bottom_pane.clear_ctrl_c_quit_hint(); diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs new file mode 100644 index 00000000..1ada679f --- /dev/null +++ b/codex-rs/tui/src/custom_terminal.rs @@ -0,0 +1,588 @@ +// This is derived from `ratatui::Terminal`, which is licensed under the following terms: +// +// The MIT License (MIT) +// Copyright (c) 2016-2022 Florian Dehau +// Copyright (c) 2023-2025 The Ratatui Developers +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +use std::io; + +use ratatui::backend::Backend; +use ratatui::backend::ClearType; +use ratatui::buffer::Buffer; +use ratatui::layout::Position; +use ratatui::layout::Rect; +use ratatui::layout::Size; +use ratatui::widgets::StatefulWidget; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; + +#[derive(Debug, Hash)] +pub struct Frame<'a> { + /// Where should the cursor be after drawing this frame? + /// + /// If `None`, the cursor is hidden and its position is controlled by the backend. If `Some((x, + /// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`. + pub(crate) cursor_position: Option, + + /// The area of the viewport + pub(crate) viewport_area: Rect, + + /// The buffer that is used to draw the current frame + pub(crate) buffer: &'a mut Buffer, + + /// The frame count indicating the sequence number of this frame. + pub(crate) count: usize, +} + +#[allow(dead_code)] +impl Frame<'_> { + /// The area of the current frame + /// + /// This is guaranteed not to change during rendering, so may be called multiple times. + /// + /// If your app listens for a resize event from the backend, it should ignore the values from + /// the event for any calculations that are used to render the current frame and use this value + /// instead as this is the area of the buffer that is used to render the current frame. + pub const fn area(&self) -> Rect { + self.viewport_area + } + + /// Render a [`Widget`] to the current buffer using [`Widget::render`]. + /// + /// Usually the area argument is the size of the current frame or a sub-area of the current + /// frame (which can be obtained using [`Layout`] to split the total area). + /// + /// # Example + /// + /// ```rust + /// # use ratatui::{backend::TestBackend, Terminal}; + /// # let backend = TestBackend::new(5, 5); + /// # let mut terminal = Terminal::new(backend).unwrap(); + /// # let mut frame = terminal.get_frame(); + /// use ratatui::{layout::Rect, widgets::Block}; + /// + /// let block = Block::new(); + /// let area = Rect::new(0, 0, 5, 5); + /// frame.render_widget(block, area); + /// ``` + /// + /// [`Layout`]: crate::layout::Layout + pub fn render_widget(&mut self, widget: W, area: Rect) { + widget.render(area, self.buffer); + } + + /// Render a [`WidgetRef`] to the current buffer using [`WidgetRef::render_ref`]. + /// + /// Usually the area argument is the size of the current frame or a sub-area of the current + /// frame (which can be obtained using [`Layout`] to split the total area). + /// + /// # Example + /// + /// ```rust + /// # #[cfg(feature = "unstable-widget-ref")] { + /// # use ratatui::{backend::TestBackend, Terminal}; + /// # let backend = TestBackend::new(5, 5); + /// # let mut terminal = Terminal::new(backend).unwrap(); + /// # let mut frame = terminal.get_frame(); + /// use ratatui::{layout::Rect, widgets::Block}; + /// + /// let block = Block::new(); + /// let area = Rect::new(0, 0, 5, 5); + /// frame.render_widget_ref(block, area); + /// # } + /// ``` + #[allow(clippy::needless_pass_by_value)] + pub fn render_widget_ref(&mut self, widget: W, area: Rect) { + widget.render_ref(area, self.buffer); + } + + /// Render a [`StatefulWidget`] to the current buffer using [`StatefulWidget::render`]. + /// + /// Usually the area argument is the size of the current frame or a sub-area of the current + /// frame (which can be obtained using [`Layout`] to split the total area). + /// + /// The last argument should be an instance of the [`StatefulWidget::State`] associated to the + /// given [`StatefulWidget`]. + /// + /// # Example + /// + /// ```rust + /// # use ratatui::{backend::TestBackend, Terminal}; + /// # let backend = TestBackend::new(5, 5); + /// # let mut terminal = Terminal::new(backend).unwrap(); + /// # let mut frame = terminal.get_frame(); + /// use ratatui::{ + /// layout::Rect, + /// widgets::{List, ListItem, ListState}, + /// }; + /// + /// let mut state = ListState::default().with_selected(Some(1)); + /// let list = List::new(vec![ListItem::new("Item 1"), ListItem::new("Item 2")]); + /// let area = Rect::new(0, 0, 5, 5); + /// frame.render_stateful_widget(list, area, &mut state); + /// ``` + /// + /// [`Layout`]: crate::layout::Layout + pub fn render_stateful_widget(&mut self, widget: W, area: Rect, state: &mut W::State) + where + W: StatefulWidget, + { + widget.render(area, self.buffer, state); + } + + /// Render a [`StatefulWidgetRef`] to the current buffer using + /// [`StatefulWidgetRef::render_ref`]. + /// + /// Usually the area argument is the size of the current frame or a sub-area of the current + /// frame (which can be obtained using [`Layout`] to split the total area). + /// + /// The last argument should be an instance of the [`StatefulWidgetRef::State`] associated to + /// the given [`StatefulWidgetRef`]. + /// + /// # Example + /// + /// ```rust + /// # #[cfg(feature = "unstable-widget-ref")] { + /// # use ratatui::{backend::TestBackend, Terminal}; + /// # let backend = TestBackend::new(5, 5); + /// # let mut terminal = Terminal::new(backend).unwrap(); + /// # let mut frame = terminal.get_frame(); + /// use ratatui::{ + /// layout::Rect, + /// widgets::{List, ListItem, ListState}, + /// }; + /// + /// let mut state = ListState::default().with_selected(Some(1)); + /// let list = List::new(vec![ListItem::new("Item 1"), ListItem::new("Item 2")]); + /// let area = Rect::new(0, 0, 5, 5); + /// frame.render_stateful_widget_ref(list, area, &mut state); + /// # } + /// ``` + #[allow(clippy::needless_pass_by_value)] + pub fn render_stateful_widget_ref(&mut self, widget: W, area: Rect, state: &mut W::State) + where + W: StatefulWidgetRef, + { + widget.render_ref(area, self.buffer, state); + } + + /// After drawing this frame, make the cursor visible and put it at the specified (x, y) + /// coordinates. If this method is not called, the cursor will be hidden. + /// + /// Note that this will interfere with calls to [`Terminal::hide_cursor`], + /// [`Terminal::show_cursor`], and [`Terminal::set_cursor_position`]. Pick one of the APIs and + /// stick with it. + /// + /// [`Terminal::hide_cursor`]: crate::Terminal::hide_cursor + /// [`Terminal::show_cursor`]: crate::Terminal::show_cursor + /// [`Terminal::set_cursor_position`]: crate::Terminal::set_cursor_position + pub fn set_cursor_position>(&mut self, position: P) { + self.cursor_position = Some(position.into()); + } + + /// Gets the buffer that this `Frame` draws into as a mutable reference. + pub fn buffer_mut(&mut self) -> &mut Buffer { + self.buffer + } + + /// Returns the current frame count. + /// + /// This method provides access to the frame count, which is a sequence number indicating + /// how many frames have been rendered up to (but not including) this one. It can be used + /// for purposes such as animation, performance tracking, or debugging. + /// + /// Each time a frame has been rendered, this count is incremented, + /// providing a consistent way to reference the order and number of frames processed by the + /// terminal. When count reaches its maximum value (`usize::MAX`), it wraps around to zero. + /// + /// This count is particularly useful when dealing with dynamic content or animations where the + /// state of the display changes over time. By tracking the frame count, developers can + /// synchronize updates or changes to the content with the rendering process. + /// + /// # Examples + /// + /// ```rust + /// # use ratatui::{backend::TestBackend, Terminal}; + /// # let backend = TestBackend::new(5, 5); + /// # let mut terminal = Terminal::new(backend).unwrap(); + /// # let mut frame = terminal.get_frame(); + /// let current_count = frame.count(); + /// println!("Current frame count: {}", current_count); + /// ``` + pub const fn count(&self) -> usize { + self.count + } +} + +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct Terminal +where + B: Backend, +{ + /// The backend used to interface with the terminal + backend: B, + /// Holds the results of the current and previous draw calls. The two are compared at the end + /// of each draw pass to output the necessary updates to the terminal + buffers: [Buffer; 2], + /// Index of the current buffer in the previous array + current: usize, + /// Whether the cursor is currently hidden + hidden_cursor: bool, + /// Area of the viewport + pub viewport_area: Rect, + /// Last known size of the terminal. Used to detect if the internal buffers have to be resized. + pub last_known_screen_size: Size, + /// Last known position of the cursor. Used to find the new area when the viewport is inlined + /// and the terminal resized. + pub last_known_cursor_pos: Position, + /// Number of frames rendered up until current time. + frame_count: usize, +} + +impl Drop for Terminal +where + B: Backend, +{ + #[allow(clippy::print_stderr)] + fn drop(&mut self) { + // Attempt to restore the cursor state + if self.hidden_cursor { + if let Err(err) = self.show_cursor() { + eprintln!("Failed to show the cursor: {err}"); + } + } + } +} + +impl Terminal +where + B: Backend, +{ + /// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`]. + /// + /// # Example + /// + /// ```rust + /// use std::io::stdout; + /// + /// use ratatui::{backend::CrosstermBackend, layout::Rect, Terminal, TerminalOptions, Viewport}; + /// + /// let backend = CrosstermBackend::new(stdout()); + /// let viewport = Viewport::Fixed(Rect::new(0, 0, 10, 10)); + /// let terminal = Terminal::with_options(backend, TerminalOptions { viewport })?; + /// # std::io::Result::Ok(()) + /// ``` + pub fn with_options(mut backend: B) -> io::Result { + let screen_size = backend.size()?; + let cursor_pos = backend.get_cursor_position()?; + Ok(Self { + backend, + buffers: [ + Buffer::empty(Rect::new(0, 0, 0, 0)), + Buffer::empty(Rect::new(0, 0, 0, 0)), + ], + current: 0, + hidden_cursor: false, + viewport_area: Rect::new(0, cursor_pos.y, 0, 0), + last_known_screen_size: screen_size, + last_known_cursor_pos: cursor_pos, + frame_count: 0, + }) + } + + /// Get a Frame object which provides a consistent view into the terminal state for rendering. + pub fn get_frame(&mut self) -> Frame { + let count = self.frame_count; + Frame { + cursor_position: None, + viewport_area: self.viewport_area, + buffer: self.current_buffer_mut(), + count, + } + } + + /// Gets the current buffer as a mutable reference. + pub fn current_buffer_mut(&mut self) -> &mut Buffer { + &mut self.buffers[self.current] + } + + /// Gets the backend + pub const fn backend(&self) -> &B { + &self.backend + } + + /// Gets the backend as a mutable reference + pub fn backend_mut(&mut self) -> &mut B { + &mut self.backend + } + + /// Obtains a difference between the previous and the current buffer and passes it to the + /// current backend for drawing. + pub fn flush(&mut self) -> io::Result<()> { + let previous_buffer = &self.buffers[1 - self.current]; + let current_buffer = &self.buffers[self.current]; + let updates = previous_buffer.diff(current_buffer); + if let Some((col, row, _)) = updates.last() { + self.last_known_cursor_pos = Position { x: *col, y: *row }; + } + self.backend.draw(updates.into_iter()) + } + + /// Updates the Terminal so that internal buffers match the requested area. + /// + /// Requested area will be saved to remain consistent when rendering. This leads to a full clear + /// of the screen. + pub fn resize(&mut self, screen_size: Size) -> io::Result<()> { + self.last_known_screen_size = screen_size; + Ok(()) + } + + /// Sets the viewport area. + pub fn set_viewport_area(&mut self, area: Rect) { + self.buffers[self.current].resize(area); + self.buffers[1 - self.current].resize(area); + self.viewport_area = area; + } + + /// Queries the backend for size and resizes if it doesn't match the previous size. + pub fn autoresize(&mut self) -> io::Result<()> { + let screen_size = self.size()?; + if screen_size != self.last_known_screen_size { + self.resize(screen_size)?; + } + Ok(()) + } + + /// Draws a single frame to the terminal. + /// + /// Returns a [`CompletedFrame`] if successful, otherwise a [`std::io::Error`]. + /// + /// If the render callback passed to this method can fail, use [`try_draw`] instead. + /// + /// Applications should call `draw` or [`try_draw`] in a loop to continuously render the + /// terminal. These methods are the main entry points for drawing to the terminal. + /// + /// [`try_draw`]: Terminal::try_draw + /// + /// This method will: + /// + /// - autoresize the terminal if necessary + /// - call the render callback, passing it a [`Frame`] reference to render to + /// - flush the current internal state by copying the current buffer to the backend + /// - move the cursor to the last known position if it was set during the rendering closure + /// + /// The render callback should fully render the entire frame when called, including areas that + /// are unchanged from the previous frame. This is because each frame is compared to the + /// previous frame to determine what has changed, and only the changes are written to the + /// terminal. If the render callback does not fully render the frame, the terminal will not be + /// in a consistent state. + /// + /// # Examples + /// + /// ``` + /// # let backend = ratatui::backend::TestBackend::new(10, 10); + /// # let mut terminal = ratatui::Terminal::new(backend)?; + /// use ratatui::{layout::Position, widgets::Paragraph}; + /// + /// // with a closure + /// terminal.draw(|frame| { + /// let area = frame.area(); + /// frame.render_widget(Paragraph::new("Hello World!"), area); + /// frame.set_cursor_position(Position { x: 0, y: 0 }); + /// })?; + /// + /// // or with a function + /// terminal.draw(render)?; + /// + /// fn render(frame: &mut ratatui::Frame) { + /// frame.render_widget(Paragraph::new("Hello World!"), frame.area()); + /// } + /// # std::io::Result::Ok(()) + /// ``` + pub fn draw(&mut self, render_callback: F) -> io::Result<()> + where + F: FnOnce(&mut Frame), + { + self.try_draw(|frame| { + render_callback(frame); + io::Result::Ok(()) + }) + } + + /// Tries to draw a single frame to the terminal. + /// + /// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise + /// [`Result::Err`] containing the [`std::io::Error`] that caused the failure. + /// + /// This is the equivalent of [`Terminal::draw`] but the render callback is a function or + /// closure that returns a `Result` instead of nothing. + /// + /// Applications should call `try_draw` or [`draw`] in a loop to continuously render the + /// terminal. These methods are the main entry points for drawing to the terminal. + /// + /// [`draw`]: Terminal::draw + /// + /// This method will: + /// + /// - autoresize the terminal if necessary + /// - call the render callback, passing it a [`Frame`] reference to render to + /// - flush the current internal state by copying the current buffer to the backend + /// - move the cursor to the last known position if it was set during the rendering closure + /// - return a [`CompletedFrame`] with the current buffer and the area of the terminal + /// + /// The render callback passed to `try_draw` can return any [`Result`] with an error type that + /// can be converted into an [`std::io::Error`] using the [`Into`] trait. This makes it possible + /// to use the `?` operator to propagate errors that occur during rendering. If the render + /// callback returns an error, the error will be returned from `try_draw` as an + /// [`std::io::Error`] and the terminal will not be updated. + /// + /// The [`CompletedFrame`] returned by this method can be useful for debugging or testing + /// purposes, but it is often not used in regular applicationss. + /// + /// The render callback should fully render the entire frame when called, including areas that + /// are unchanged from the previous frame. This is because each frame is compared to the + /// previous frame to determine what has changed, and only the changes are written to the + /// terminal. If the render function does not fully render the frame, the terminal will not be + /// in a consistent state. + /// + /// # Examples + /// + /// ```should_panic + /// # use ratatui::layout::Position;; + /// # let backend = ratatui::backend::TestBackend::new(10, 10); + /// # let mut terminal = ratatui::Terminal::new(backend)?; + /// use std::io; + /// + /// use ratatui::widgets::Paragraph; + /// + /// // with a closure + /// terminal.try_draw(|frame| { + /// let value: u8 = "not a number".parse().map_err(io::Error::other)?; + /// let area = frame.area(); + /// frame.render_widget(Paragraph::new("Hello World!"), area); + /// frame.set_cursor_position(Position { x: 0, y: 0 }); + /// io::Result::Ok(()) + /// })?; + /// + /// // or with a function + /// terminal.try_draw(render)?; + /// + /// fn render(frame: &mut ratatui::Frame) -> io::Result<()> { + /// let value: u8 = "not a number".parse().map_err(io::Error::other)?; + /// frame.render_widget(Paragraph::new("Hello World!"), frame.area()); + /// Ok(()) + /// } + /// # io::Result::Ok(()) + /// ``` + pub fn try_draw(&mut self, render_callback: F) -> io::Result<()> + where + F: FnOnce(&mut Frame) -> Result<(), E>, + E: Into, + { + // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets + // and the terminal (if growing), which may OOB. + self.autoresize()?; + + let mut frame = self.get_frame(); + + render_callback(&mut frame).map_err(Into::into)?; + + // We can't change the cursor position right away because we have to flush the frame to + // stdout first. But we also can't keep the frame around, since it holds a &mut to + // Buffer. Thus, we're taking the important data out of the Frame and dropping it. + let cursor_position = frame.cursor_position; + + // Draw to stdout + self.flush()?; + + match cursor_position { + None => self.hide_cursor()?, + Some(position) => { + self.show_cursor()?; + self.set_cursor_position(position)?; + } + } + + self.swap_buffers(); + + // Flush + self.backend.flush()?; + + // increment frame count before returning from draw + self.frame_count = self.frame_count.wrapping_add(1); + + Ok(()) + } + + /// Hides the cursor. + pub fn hide_cursor(&mut self) -> io::Result<()> { + self.backend.hide_cursor()?; + self.hidden_cursor = true; + Ok(()) + } + + /// Shows the cursor. + pub fn show_cursor(&mut self) -> io::Result<()> { + self.backend.show_cursor()?; + self.hidden_cursor = false; + Ok(()) + } + + /// Gets the current cursor position. + /// + /// This is the position of the cursor after the last draw call. + #[allow(dead_code)] + pub fn get_cursor_position(&mut self) -> io::Result { + self.backend.get_cursor_position() + } + + /// Sets the cursor position. + pub fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { + let position = position.into(); + self.backend.set_cursor_position(position)?; + self.last_known_cursor_pos = position; + Ok(()) + } + + /// Clear the terminal and force a full redraw on the next draw call. + pub fn clear(&mut self) -> io::Result<()> { + if self.viewport_area.is_empty() { + return Ok(()); + } + self.backend + .set_cursor_position(self.viewport_area.as_position())?; + self.backend.clear_region(ClearType::AfterCursor)?; + // Reset the back buffer to make sure the next update will redraw everything. + self.buffers[1 - self.current].reset(); + Ok(()) + } + + /// Clears the inactive buffer and swaps it with the current buffer + pub fn swap_buffers(&mut self) { + self.buffers[1 - self.current].reset(); + self.current = 1 - self.current; + } + + /// Queries the real size of the backend. + pub fn size(&self) -> io::Result { + self.backend.size() + } +} diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 32d0b4b2..54faf4be 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -4,6 +4,7 @@ use std::io::Write; use crate::tui; use crossterm::Command; +use crossterm::cursor::MoveTo; use crossterm::queue; use crossterm::style::Color as CColor; use crossterm::style::Colors; @@ -12,7 +13,6 @@ use crossterm::style::SetAttribute; use crossterm::style::SetBackgroundColor; use crossterm::style::SetColors; use crossterm::style::SetForegroundColor; -use ratatui::layout::Position; use ratatui::layout::Size; use ratatui::prelude::Backend; use ratatui::style::Color; @@ -23,6 +23,7 @@ use ratatui::text::Span; /// Insert `lines` above the viewport. pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec) { let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); + let cursor_pos = terminal.get_cursor_position().ok(); let mut area = terminal.get_frame().area(); @@ -60,9 +61,10 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec) { // └──────────────────────────────┘ queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok(); - terminal - .set_cursor_position(Position::new(0, cursor_top)) - .ok(); + // NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the + // terminal's last_known_cursor_position, which hopefully will still be accurate after we + // fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :) + queue!(std::io::stdout(), MoveTo(0, cursor_top)).ok(); for line in lines { queue!(std::io::stdout(), Print("\r\n")).ok(); @@ -70,6 +72,11 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec) { } queue!(std::io::stdout(), ResetScrollRegion).ok(); + + // Restore the cursor position to where it was before we started. + if let Some(cursor_pos) = cursor_pos { + queue!(std::io::stdout(), MoveTo(cursor_pos.x, cursor_pos.y)).ok(); + } } fn wrapped_line_count(lines: &[Line], width: u16) -> u16 { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 424b5ac2..351fab4d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -25,6 +25,7 @@ mod bottom_pane; mod chatwidget; mod citation_regex; mod cli; +mod custom_terminal; mod exec_command; mod file_search; mod get_git_diff; diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 66ae1cfb..1b215961 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -5,14 +5,13 @@ use std::io::stdout; use codex_core::config::Config; use crossterm::event::DisableBracketedPaste; use crossterm::event::EnableBracketedPaste; -use ratatui::Terminal; -use ratatui::TerminalOptions; -use ratatui::Viewport; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::enable_raw_mode; +use crate::custom_terminal::Terminal; + /// A type alias for the terminal type used in this application pub type Tui = Terminal>; @@ -23,19 +22,8 @@ pub fn init(_config: &Config) -> Result { enable_raw_mode()?; set_panic_hook(); - // Reserve a fixed number of lines for the interactive viewport (composer, - // status, popups). History is injected above using `insert_before`. This - // is an initial step of the refactor – later the height can become - // dynamic. For now a conservative default keeps enough room for the - // multi‑line composer while not occupying the whole screen. - const BOTTOM_VIEWPORT_HEIGHT: u16 = 8; let backend = CrosstermBackend::new(stdout()); - let tui = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(BOTTOM_VIEWPORT_HEIGHT), - }, - )?; + let tui = Terminal::with_options(backend)?; Ok(tui) }