Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 87 additions & 52 deletions crates/warp_tui/src/inline_menu.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
//! Reusable active-menu routing and character-cell presentation for TUI inline menus.
use std::rc::Rc;

use warp::tui_export::AcceptSlashCommandOrSavedPrompt;
use warpui_core::elements::tui::{
TuiBuffer, TuiConstraint, TuiContainer, TuiElement, TuiFlex, TuiLayoutContext, TuiPaintContext,
TuiRect, TuiSize, TuiText,
};
use warpui_core::elements::CrossAxisAlignment;
use warpui_core::{AppContext, ModelAsRef, ModelHandle, UpdateModel};
use warpui_core::{AppContext, ModelHandle};

use crate::slash_commands::TuiSlashCommandModel;
use crate::tui_builder::TuiUiBuilder;

pub(crate) const MAX_INLINE_MENU_ROWS: u16 = 10;
const INLINE_MENU_BORDER_ROWS: usize = 2;

/// A presentation-only row in a TUI inline menu.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TuiInlineMenuRow {
Expand Down Expand Up @@ -57,67 +61,85 @@ pub(crate) enum TuiInlineMenuAccepted {
SlashCommand(AcceptSlashCommandOrSavedPrompt),
}

/// The active TUI inline menu.
///
/// This is the input and placement boundary shared by menu kinds. Each variant
/// retains its own query state and accepted action, while all variants expose
/// the same navigation, dismissal, snapshot, and rendering behavior.
#[derive(Clone)]
pub(crate) enum TuiInlineMenu {
SlashCommands(ModelHandle<TuiSlashCommandModel>),
/// Type-erased operations shared by TUI inline-menu model handles.
pub(crate) trait TuiInlineMenuHandle {
/// Returns whether this menu is open.
fn is_open(&self, ctx: &AppContext) -> bool;
/// Moves selection to the previous row.
fn select_previous(&self, ctx: &mut AppContext);
/// Moves selection to the next row.
fn select_next(&self, ctx: &mut AppContext);
/// Accepts the selected row.
fn accept(&self, ctx: &mut AppContext) -> Option<TuiInlineMenuAccepted>;
/// Dismisses the menu.
fn dismiss(&self, ctx: &mut AppContext);
/// Returns the menu's presentation snapshot.
fn snapshot(&self, ctx: &AppContext) -> Option<TuiInlineMenuSnapshot>;
}

/// Cloneable type-erased handle for one TUI inline menu.
#[derive(Clone)]
pub(crate) struct TuiInlineMenu(Rc<dyn TuiInlineMenuHandle>);

impl TuiInlineMenu {
/// Erases a concrete menu-model handle behind the shared routing interface.
pub(crate) fn new(handle: impl TuiInlineMenuHandle + 'static) -> Self {
Self(Rc::new(handle))
}
pub(crate) fn is_open(&self, ctx: &AppContext) -> bool {
match self {
Self::SlashCommands(model) => model.as_ref(ctx).is_open(),
}
self.0.is_open(ctx)
}

pub(crate) fn render(&self, ctx: &AppContext) -> Option<Box<dyn TuiElement>> {
self.snapshot(ctx)
.map(|snapshot| render_inline_menu(&snapshot, &TuiUiBuilder::from_app(ctx)))
}

pub(crate) fn select_previous(&self, ctx: &mut impl UpdateModel) {
match self {
Self::SlashCommands(model) => {
model.update(ctx, |model, ctx| model.select_previous(ctx));
}
}
pub(crate) fn select_previous(&self, ctx: &mut AppContext) {
self.0.select_previous(ctx);
}

pub(crate) fn select_next(&self, ctx: &mut impl UpdateModel) {
match self {
Self::SlashCommands(model) => {
model.update(ctx, |model, ctx| model.select_next(ctx));
}
}
pub(crate) fn select_next(&self, ctx: &mut AppContext) {
self.0.select_next(ctx);
}

pub(crate) fn accept(
&self,
ctx: &mut (impl ModelAsRef + UpdateModel),
) -> Option<TuiInlineMenuAccepted> {
match self {
Self::SlashCommands(model) => model
.update(ctx, |model, ctx| model.accept_selected(ctx))
.map(TuiInlineMenuAccepted::SlashCommand),
}
pub(crate) fn accept(&self, ctx: &mut AppContext) -> Option<TuiInlineMenuAccepted> {
self.0.accept(ctx)
}

pub(crate) fn dismiss(&self, ctx: &mut impl UpdateModel) {
match self {
Self::SlashCommands(model) => {
model.update(ctx, |model, ctx| model.dismiss(ctx));
}
}
pub(crate) fn dismiss(&self, ctx: &mut AppContext) {
self.0.dismiss(ctx);
}

fn snapshot(&self, ctx: &AppContext) -> Option<TuiInlineMenuSnapshot> {
match self {
Self::SlashCommands(model) => model.as_ref(ctx).snapshot(),
}
self.0.snapshot(ctx)
}
}

impl TuiInlineMenuHandle for ModelHandle<TuiSlashCommandModel> {
fn is_open(&self, ctx: &AppContext) -> bool {
self.as_ref(ctx).is_open()
}

fn select_previous(&self, ctx: &mut AppContext) {
self.update(ctx, |model, ctx| model.select_previous(ctx));
}

fn select_next(&self, ctx: &mut AppContext) {
self.update(ctx, |model, ctx| model.select_next(ctx));
}

fn accept(&self, ctx: &mut AppContext) -> Option<TuiInlineMenuAccepted> {
self.update(ctx, |model, ctx| model.accept_selected(ctx))
.map(TuiInlineMenuAccepted::SlashCommand)
}

fn dismiss(&self, ctx: &mut AppContext) {
self.update(ctx, |model, ctx| model.dismiss(ctx));
}

fn snapshot(&self, ctx: &AppContext) -> Option<TuiInlineMenuSnapshot> {
self.as_ref(ctx).snapshot()
}
}

Expand Down Expand Up @@ -158,6 +180,29 @@ impl TuiElement for TuiInlineMenuElement {
}
}

/// Returns the result rows available after reserving border and header chrome.
pub(crate) const fn result_row_capacity(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is just fixing an issue I noticed further up the stack where there were some selection issues b/c the row capacity wasn't being correctly calculated w/ a title

allocated_height: u16,
has_title: bool,
has_tabs: bool,
) -> usize {
let title_rows = if has_title { 1 } else { 0 };
let tab_rows = if has_tabs { 1 } else { 0 };
(allocated_height as usize).saturating_sub(INLINE_MENU_BORDER_ROWS + title_rows + tab_rows)
}

fn visible_result_capacity(snapshot: &TuiInlineMenuSnapshot, allocated_height: u16) -> usize {
let has_title = snapshot
.header
.as_ref()
.is_some_and(|header| header.title.is_some());
let has_tabs = snapshot
.header
.as_ref()
.is_some_and(|header| !header.tabs.is_empty());
result_row_capacity(allocated_height, has_title, has_tabs).min(snapshot.max_visible_rows)
}

fn build_inline_menu(
snapshot: &TuiInlineMenuSnapshot,
builder: &TuiUiBuilder,
Expand Down Expand Up @@ -225,16 +270,6 @@ fn build_inline_menu(
.finish()
}

fn visible_result_capacity(snapshot: &TuiInlineMenuSnapshot, allocated_height: u16) -> usize {
const BORDER_ROWS: usize = 2;
let header_rows = snapshot.header.as_ref().map_or(0, |header| {
usize::from(header.title.is_some()) + usize::from(!header.tabs.is_empty())
});
usize::from(allocated_height)
.saturating_sub(BORDER_ROWS + header_rows)
.min(snapshot.max_visible_rows)
}

/// Clamps stale scroll offsets and moves the viewport only as far as needed to
/// keep the selected row within a window of `visible_rows`.
pub(crate) fn keep_selected_visible(
Expand Down
38 changes: 18 additions & 20 deletions crates/warp_tui/src/input/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,8 +564,8 @@ pub struct TuiInputView {
model: ModelHandle<CodeEditorModel>,
/// Shared input-mode state driving `!` shell-mode handling.
input_mode: ModelHandle<BlocklistAIInputModel>,
/// Optional generalized inline menu used to route prioritized menu actions.
inline_menu: Option<TuiInlineMenu>,
/// Generalized inline menus used to route prioritized menu actions.
inline_menus: Vec<TuiInlineMenu>,
/// Single-entry kill buffer for `Ctrl+K` / `Ctrl+U` / `Ctrl+Y`.
kill_buffer: KillBuffer,
/// Maximum number of visible rows before the input scrolls.
Expand Down Expand Up @@ -596,7 +596,7 @@ impl TuiInputView {
pub(crate) fn new(
model: ModelHandle<CodeEditorModel>,
input_mode: ModelHandle<BlocklistAIInputModel>,
inline_menu: Option<TuiInlineMenu>,
inline_menus: Vec<TuiInlineMenu>,
ctx: &mut ViewContext<Self>,
) -> Self {
ctx.subscribe_to_model(&model, |_, _, event, ctx| {
Expand All @@ -610,7 +610,7 @@ impl TuiInputView {
Self {
model,
input_mode,
inline_menu,
inline_menus,
kill_buffer: KillBuffer::default(),
max_visible_rows: 6,
prefix_mouse_state: MouseStateHandle::default(),
Expand Down Expand Up @@ -719,12 +719,7 @@ impl TuiView for TuiInputView {
}

fn keymap_context(&self, ctx: &AppContext) -> keymap::Context {
input_keymap_context(
self.inline_menu
.as_ref()
.is_some_and(|inline_menu| inline_menu.is_open(ctx))
|| self.is_shell_mode(ctx),
)
input_keymap_context(self.active_inline_menu(ctx).is_some() || self.is_shell_mode(ctx))
}
}

Expand Down Expand Up @@ -1084,12 +1079,9 @@ impl TuiInputView {
) {
return false;
}
let Some(inline_menu) = self.inline_menu.clone() else {
let Some(inline_menu) = self.active_inline_menu(ctx) else {
return false;
};
if !inline_menu.is_open(ctx) {
return false;
}

match action {
TuiInputAction::MoveUp => {
Expand Down Expand Up @@ -1118,20 +1110,26 @@ impl TuiInputView {
/// order. New input modes should be added after the inline-menu branch so
/// one Escape always closes the most local surface first.
fn handle_escape(&mut self, ctx: &mut ViewContext<Self>) -> bool {
if let Some(inline_menu) = self.inline_menu.clone() {
if inline_menu.is_open(ctx) {
inline_menu.dismiss(ctx);
ctx.notify();
return true;
}
if let Some(inline_menu) = self.active_inline_menu(ctx) {
inline_menu.dismiss(ctx);
ctx.notify();
return true;
}

if self.is_shell_mode(ctx) {
self.exit_shell_mode(ctx);
return true;
}
false
}

fn active_inline_menu(&self, ctx: &AppContext) -> Option<TuiInlineMenu> {
self.inline_menus
.iter()
.find(|menu| menu.is_open(ctx))
.cloned()
}

// ── Kill / yank ───────────────────────────────────────────────────────────
//
// The kill *edits* (visual-row range computation and deletion) live on
Expand Down
6 changes: 3 additions & 3 deletions crates/warp_tui/src/input/view_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ fn build_view(ctx: &mut AppContext) -> ViewHandle<TuiInputView> {
},
|ctx| {
let model = ctx.add_model(|ctx| CodeEditorModel::new_tui(W, ctx));
TuiInputView::new(model, input_mode, None, ctx)
TuiInputView::new(model, input_mode, Vec::new(), ctx)
},
);
view
Expand Down Expand Up @@ -91,13 +91,13 @@ fn build_view_with_inline_menu(
.collect();
let menu_model =
ctx.add_model(|_| TuiSlashCommandModel::new_for_test(input_model.clone(), mixer, rows, 0));
let inline_menu = TuiInlineMenu::SlashCommands(menu_model.clone());
let inline_menu = TuiInlineMenu::new(menu_model.clone());
let (_window_id, view) = ctx.add_tui_window(
AddWindowOptions {
window_style: WindowStyle::NotStealFocus,
..Default::default()
},
move |ctx| TuiInputView::new(input_model, input_mode, Some(inline_menu), ctx),
move |ctx| TuiInputView::new(input_model, input_mode, vec![inline_menu], ctx),
);
(view, menu_model, ids)
}
Expand Down
5 changes: 3 additions & 2 deletions crates/warp_tui/src/slash_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ use warp_search_core::inline_menu::{
use warpui_core::{AppContext, Entity, ModelContext, ModelHandle};

use crate::inline_menu::{
keep_selected_visible, TuiInlineMenuRow, TuiInlineMenuSnapshot, TuiInlineMenuStatus,
keep_selected_visible, result_row_capacity, TuiInlineMenuRow, TuiInlineMenuSnapshot,
TuiInlineMenuStatus, MAX_INLINE_MENU_ROWS,
};

const MAX_VISIBLE_ROWS: usize = 8;
const MAX_VISIBLE_ROWS: usize = result_row_capacity(MAX_INLINE_MENU_ROWS, false, false);

#[allow(dead_code)]
#[derive(Debug, Clone)]
Expand Down
15 changes: 7 additions & 8 deletions crates/warp_tui/src/terminal_session_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ use crate::autoupdate::{TuiAutoupdater, TuiAutoupdaterEvent};
use crate::clipboard::copy_to_clipboard;
use crate::conversation_selection::TuiConversationSelection;
use crate::exit_confirmation::{ExitConfirmation, CTRL_C_EXIT_WINDOW};
use crate::inline_menu::TuiInlineMenu;
use crate::inline_menu::{TuiInlineMenu, MAX_INLINE_MENU_ROWS};
use crate::input::{TuiInputView, TuiInputViewEvent};
use crate::input_mode_policy::{self, TuiInputModePolicy};
use crate::keybindings::TUI_BINDING_GROUP;
Expand All @@ -65,7 +65,6 @@ use crate::zero_state::render_zero_state;
/// Width used before the first layout pass pushes the real terminal width into the editor.
const INITIAL_INPUT_WIDTH: u16 = 80;
const MAX_INPUT_TEXT_ROWS: u16 = 6;
const MAX_INLINE_MENU_ROWS: u16 = 10;

/// The footer hint shown while the ctrl-c exit confirmation is armed.
const CTRL_C_EXIT_HINT: &str = "ctrl-c again to exit";
Expand Down Expand Up @@ -164,7 +163,7 @@ pub(crate) enum TuiTerminalSessionAction {
pub(crate) struct TuiTerminalSessionView {
transcript: ViewHandle<TuiTranscriptView>,
input_view: ViewHandle<TuiInputView>,
inline_menu: TuiInlineMenu,
inline_menus: Vec<TuiInlineMenu>,
slash_commands_source: ModelHandle<TuiSlashCommandDataSource>,
conversation_selection: ConversationSelectionHandle,
ai_action_model: ModelHandle<BlocklistAIActionModel>,
Expand Down Expand Up @@ -376,13 +375,13 @@ impl TuiTerminalSessionView {
});

let input_mode_for_input_view = ai_input_model.clone();
let inline_menu = TuiInlineMenu::SlashCommands(slash_commands.clone());
let inline_menu_for_input = inline_menu.clone();
let inline_menus = vec![TuiInlineMenu::new(slash_commands.clone())];
let inline_menus_for_input = inline_menus.clone();
let input_view = ctx.add_typed_action_tui_view(move |ctx| {
TuiInputView::new(
input_editor_model,
input_mode_for_input_view,
Some(inline_menu_for_input),
inline_menus_for_input,
ctx,
)
});
Expand Down Expand Up @@ -568,7 +567,7 @@ impl TuiTerminalSessionView {
Self {
transcript,
input_view,
inline_menu,
inline_menus,
slash_commands_source,
conversation_selection,
ai_action_model: action_model,
Expand Down Expand Up @@ -1317,7 +1316,7 @@ impl TuiView for TuiTerminalSessionView {
}
ConversationRestoreState::Idle => {}
}
let inline_menu = self.inline_menu.render(ctx);
let inline_menu = self.inline_menus.iter().find_map(|menu| menu.render(ctx));
Comment thread
harryalbert marked this conversation as resolved.
// The border takes the shell-mode accent while in shell mode.
let builder = TuiUiBuilder::from_app(ctx);
let border_style = if self.is_shell_mode(ctx) {
Expand Down
Loading