Skip to content

Commit 11742b3

Browse files
authored
feat(tui): implement TUI input view milestone 1 (warpdotdev#13064)
## Description Implements the TUI input view (Milestone 1) as specified in `specs/tui-input-view/TECH.md`. This is the first functional editor-backed text input for the TUI rendering path. **What's in this PR:** ### Prerequisite refactor in `crates/editor/` (~580 LOC + ~200 LOC tests, no GUI-path regressions) - **`ColumnUnit` enum** on `SoftWrapPoint`: replaces the bare `Pixels` column with an explicit `ColumnUnit::Pixels(Pixels)` / `ColumnUnit::Chars(u16)` discriminant so the GUI and TUI coordinate spaces are enforced at the type level (cross-variant ops `debug_assert!`). The ~15 construction sites and `SelectionModel::goal_xs` (now `Option<Vec1<ColumnUnit>>`) / `NavigationResult::goal_x` are updated; `navigate_line` sticky-column logic is unchanged in shape. - **`LayoutMode` on `RenderState`**: adds `LayoutMode::CharCell(CharCellState)` alongside the existing `LayoutMode::Pixels` path (`CharCellState` holds `terminal_width`, `line_starts`, `total_chars`). In CharCell mode `handle_layout_action` skips font shaping entirely, and `offset_to_softwrap_point` / `softwrap_point_to_offset` / `max_line` use pure char-cell integer arithmetic. New APIs: `RenderState::new_tui(terminal_width, styles, ctx)`, `is_char_cell_mode()`, `update_char_cell_text(text)`, `set_char_cell_terminal_width(width)`. The styles field is retained for API compatibility but unused in CharCell mode. - **`usize → u32` overflow guard**: `char_cell_softwrap_point_to_offset` treats the final (unbounded) logical line as spanning all remaining rows, so a target row past the end resolves there instead of overflowing. Covered by the round-trip unit tests. ### Editor-backed TUI input (no separate model) There is **no** `TuiInputModel` — the input reuses the existing `CodeEditorModel` in char-cell mode: - `app/src/code/editor/model.rs`: adds `CodeEditorModel::new_tui(terminal_width, ctx)` (shares sub-model wiring with the GUI `new()` via a `from_content(..)` helper) and `set_tui_terminal_width(..)`. Its `CoreEditorModel::on_buffer_version_updated` override drives `update_char_cell_text` synchronously on every edit when in char-cell mode (the async font-shaping pipeline is bypassed there). Re-exported for the TUI front-end via `app/src/editor/mod.rs`. - `crates/warp_tui/src/input/` (~1.2k LOC incl. tests): - `kill_buffer.rs`: single-entry kill buffer (Ctrl+K / Ctrl+U / Ctrl+W → Ctrl+Y yank) - `view.rs`: `TuiInputView` implementing `TuiView` + `TypedActionView`. Holds `ModelHandle<CodeEditorModel>` plus TUI session state (kill buffer, scroll offset, terminal width, `max_visible_rows = 6`). Full Emacs/readline keybinding dispatch via a `TuiInputAction` enum (Ctrl+A/E/B/F/P/N/K/U/W/Y/H/D/Z, word movement, Shift+arrows, Shift+Enter). Renders via pure char-cell helpers; `TuiInputElement` paints rows, applies `Modifier::REVERSED` to selection spans, and reports the block cursor via `cursor_position()`. Emits `TuiInputViewEvent::Submitted(String)` on Enter. - `view_tests.rs`: drives a real `CodeEditorModel` + `TuiInputView`. ### Spec + examples - `specs/tui-input-view/TECH.md`: as-built architecture spec (design rationale, keybinding table, follow-up scope for runtime wiring, input mode, slash commands, history). - `crates/warp_tui/examples/tui_input_demo.rs`: interactive editor-backed input demo (`cargo run -p warp_tui --example tui_input_demo`). - `crates/warpui_core/examples/tui_file_viewer.rs`: validates the TUI rendering/runtime pipeline independently of the editor (scrollable file viewer). ## Linked Issue N/A — spec-driven feature, see `specs/tui-input-view/TECH.md` ## Testing - `crates/editor/src/render/model/mod_tests.rs::char_cell`: 12 unit tests (max_line, `offset → point → offset` round-trips at multiple widths, `ColumnUnit::Chars` correctness, multi-line/wrapping boundaries). - `crates/warp_tui/src/input/view_tests.rs`: 12 tests driving a real `CodeEditorModel` + `TuiInputView` (cursor placement, blank-line navigation, selection text, kill/yank). - Existing `warp_editor` test suite continues to pass (no GUI-path behaviour changes). - `./script/format` and `cargo clippy --workspace --all-targets --tests -- -D warnings` pass. Run the examples from a real terminal: ```sh cargo run -p warp_tui --example tui_input_demo cargo run -p warpui_core --example tui_file_viewer --features tui -- specs/tui-input-view/TECH.md ``` Wiring `TuiInputView` into the `warp-tui` binary's runtime (today only auth runs there) is the next step. ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode Conversation: https://staging.warp.dev/conversation/c5b70ee0-60c6-49f9-908c-2e3275949d37 CHANGELOG-NEW-FEATURE: TUI input view (Milestone 1): multi-line, Emacs/readline keybindings, editor-backed via CodeEditorModel + SelectionModel + CharCell RenderState
1 parent ef0ac45 commit 11742b3

35 files changed

Lines changed: 3337 additions & 233 deletions

Cargo.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ embed-resource = "3.0"
463463

464464
# Note that we support channel-specific enables for these features
465465
[features]
466-
tui = []
466+
tui = ["warpui_core/tui"]
467467
ai_resume_button = []
468468
autoupdate = []
469469
figma_detection = []

app/src/code/editor/model.rs

Lines changed: 167 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ use warp_editor::editor::TextDecoration;
4747
use warp_editor::model::{CoreEditorModel, PlainTextEditorModel};
4848
use warp_editor::multiline::{AnyMultilineString, MultilineString, LF};
4949
use warp_editor::render::model::{
50-
AutoScrollMode, BlockItem, Decoration, LineCount, LineDecoration, RenderEvent,
51-
RenderLineLocation, RenderState, RichTextStyles, StyleUpdateAction,
50+
AutoScrollMode, BlockItem, BlockSpacings, BrokenLinkStyle, CheckBoxStyle, ColumnUnit,
51+
Decoration, HorizontalRuleStyle, InlineCodeStyle, LineCount, LineDecoration, ParagraphStyles,
52+
RenderEvent, RenderLineLocation, RenderState, RichTextStyles, StyleUpdateAction, TableStyle,
5253
UpdateDecorationAfterLayout, WidthSetting,
5354
};
5455
use warp_editor::selection::{SelectionMode, SelectionModel, TextDirection, TextUnit};
@@ -332,6 +333,72 @@ impl CodeEditorModel {
332333
content.update(ctx, |buffer, _| {
333334
buffer.set_session_platform(session_platform);
334335
});
336+
337+
Self::from_content(
338+
content,
339+
true, // show_current_line_highlights
340+
lazy_layout, // lazy_layout_enabled
341+
false, // lazy_layout_initialized
342+
ctx,
343+
|hidden_lines, ctx| {
344+
ctx.add_model(|ctx| {
345+
RenderState::new(text_styles, lazy_layout, Some(hidden_lines.clone()), ctx)
346+
.with_width_setting(WidthSetting::InfiniteWidth)
347+
})
348+
},
349+
)
350+
}
351+
352+
/// Constructs a `CodeEditorModel` in TUI char-cell mode.
353+
///
354+
/// Identical to `new` but creates the `RenderState` with
355+
/// [`LayoutMode::CharCell`] so all soft-wrap positions use monospace
356+
/// character-count arithmetic rather than font-aware pixel layout.
357+
/// `TuiEditorModel` (in `warp_tui`) is a type alias for this type;
358+
/// constructing via this method is what gives the TUI editor all of
359+
/// `CodeEditorModel`'s features (vim, syntax, diff, hidden lines) for free
360+
/// while sharing no GUI-rendering infrastructure.
361+
///
362+
/// Like `new`, this reads syntax-highlight colors from the `Appearance`
363+
/// singleton, so callers must register `Appearance` (a real one for the
364+
/// runtime, `Appearance::mock()` for tests) before constructing the model.
365+
pub fn new_tui(terminal_width: u16, ctx: &mut ModelContext<Self>) -> Self {
366+
let content = ctx.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
367+
368+
Self::from_content(
369+
content,
370+
false, // show_current_line_highlights: no GPU rendering in TUI
371+
false, // lazy_layout_enabled: no lazy layout in TUI
372+
true, // lazy_layout_initialized: no lazy layout in TUI
373+
ctx,
374+
|_hidden_lines, ctx| {
375+
// CharCell layout never consults `RichTextStyles`, so pass a stub.
376+
ctx.add_model(|ctx| {
377+
RenderState::new_tui(terminal_width, Self::tui_stub_text_styles(), ctx)
378+
})
379+
},
380+
)
381+
}
382+
383+
/// Shared construction for [`Self::new`] and [`Self::new_tui`]. The two modes
384+
/// differ only in how the backing `content` buffer and the `RenderState` are
385+
/// built (GUI pixel layout vs. TUI char-cell layout) plus a few flags; all
386+
/// other sub-models (selection, syntax tree, diff, hidden lines, comments)
387+
/// and event subscriptions are identical and wired up here.
388+
///
389+
/// `build_render_state` receives the freshly-created `hidden_lines` handle so
390+
/// the GUI path can attach it; the TUI path ignores it.
391+
fn from_content(
392+
content: ModelHandle<Buffer>,
393+
show_current_line_highlights: bool,
394+
lazy_layout_enabled: bool,
395+
lazy_layout_initialized: bool,
396+
ctx: &mut ModelContext<Self>,
397+
build_render_state: impl FnOnce(
398+
&ModelHandle<HiddenLinesModel>,
399+
&mut ModelContext<Self>,
400+
) -> ModelHandle<RenderState>,
401+
) -> Self {
335402
ctx.subscribe_to_model(&content, |me, _, event, ctx| {
336403
me.handle_content_model_event(event, ctx);
337404
});
@@ -355,10 +422,7 @@ impl CodeEditorModel {
355422
let hidden_lines =
356423
ctx.add_model(|_| HiddenLinesModel::new(content.clone(), selection_model.clone()));
357424

358-
let render_state = ctx.add_model(|ctx| {
359-
RenderState::new(text_styles, lazy_layout, Some(hidden_lines.clone()), ctx)
360-
.with_width_setting(WidthSetting::InfiniteWidth)
361-
});
425+
let render_state = build_render_state(&hidden_lines, ctx);
362426
ctx.subscribe_to_model(&render_state, |me, _, event, ctx| {
363427
me.handle_render_state_model_event(event, ctx);
364428
});
@@ -388,17 +452,96 @@ impl CodeEditorModel {
388452
hidden_lines,
389453
diff_navigation_state: DiffNavigationState::Collapsed,
390454
interaction_state: InteractionState::Editable,
391-
show_current_line_highlights: true,
455+
show_current_line_highlights,
392456
delay_rendering: None,
393457
vim_visual_tails: vec![],
394458
hovered_symbol_range: None,
395459
hide_lines_outside_of_active_diff: None,
396-
lazy_layout_enabled: lazy_layout,
397-
lazy_layout_initialized: false,
460+
lazy_layout_enabled,
461+
lazy_layout_initialized,
398462
pending_syntax_tree_bootstrap: false,
399463
}
400464
}
401465

466+
/// A minimal [`RichTextStyles`] for the TUI char-cell editor.
467+
///
468+
/// `RenderState::new_tui` stores styles only for API compatibility and never
469+
/// uses them for char-cell layout, so these values are placeholders. This
470+
/// lives here (the caller of `RenderState::new_tui`) rather than in the core
471+
/// editor crate so the editor API doesn't carry a TUI-specific stub.
472+
fn tui_stub_text_styles() -> RichTextStyles {
473+
use warpui::elements::{Border, Fill};
474+
use warpui::fonts::{FamilyId, Weight};
475+
476+
const TRANSPARENT: warpui::color::ColorU = warpui::color::ColorU {
477+
r: 0,
478+
g: 0,
479+
b: 0,
480+
a: 0,
481+
};
482+
let paragraph = |fixed_width_tab_size| ParagraphStyles {
483+
font_family: FamilyId(0),
484+
font_size: 10.,
485+
font_weight: Weight::Normal,
486+
line_height_ratio: 1.,
487+
text_color: TRANSPARENT,
488+
baseline_ratio: 0.7,
489+
fixed_width_tab_size,
490+
};
491+
RichTextStyles {
492+
base_text: paragraph(None),
493+
code_text: paragraph(Some(4)),
494+
code_background: Fill::None,
495+
embedding_background: Fill::None,
496+
embedding_text: paragraph(None),
497+
code_border: Border::new(0.),
498+
placeholder_color: TRANSPARENT,
499+
selection_fill: Fill::None,
500+
cursor_fill: Fill::None,
501+
inline_code_style: InlineCodeStyle {
502+
font_family: FamilyId(0),
503+
background: TRANSPARENT,
504+
font_color: TRANSPARENT,
505+
},
506+
check_box_style: CheckBoxStyle {
507+
border_width: 0.,
508+
border_color: TRANSPARENT,
509+
icon_path: "",
510+
background: TRANSPARENT,
511+
hover_background: TRANSPARENT,
512+
},
513+
horizontal_rule_style: HorizontalRuleStyle {
514+
rule_height: 0.,
515+
color: TRANSPARENT,
516+
},
517+
broken_link_style: BrokenLinkStyle {
518+
icon_path: "",
519+
icon_color: TRANSPARENT,
520+
},
521+
block_spacings: BlockSpacings::default(),
522+
minimum_paragraph_height: None,
523+
show_placeholder_text_on_empty_block: false,
524+
cursor_width: 0.,
525+
highlight_urls: false,
526+
table_style: TableStyle {
527+
border_color: TRANSPARENT,
528+
header_background: TRANSPARENT,
529+
cell_background: TRANSPARENT,
530+
alternate_row_background: None,
531+
text_color: TRANSPARENT,
532+
header_text_color: TRANSPARENT,
533+
scrollbar_nonactive_thumb_color: TRANSPARENT,
534+
scrollbar_active_thumb_color: TRANSPARENT,
535+
font_family: FamilyId(0),
536+
font_size: 10.,
537+
cell_padding: 0.,
538+
outer_border: false,
539+
column_dividers: false,
540+
row_dividers: false,
541+
},
542+
}
543+
}
544+
402545
fn should_defer_syntax_tree_parsing(&self) -> bool {
403546
self.lazy_layout_enabled && !self.lazy_layout_initialized
404547
}
@@ -1588,8 +1731,6 @@ impl CodeEditorModel {
15881731
fn update_cursor_line_highlights(&self, ctx: &mut ModelContext<CodeEditorModel>) {
15891732
let selection_model = self.selection_model.as_ref(ctx);
15901733

1591-
let overlay = Appearance::as_ref(ctx).theme().surface_2();
1592-
15931734
let highlight_line = if self.diff_nav_is_active() {
15941735
// We don't show current line highlights during diff navigation so we don't need
15951736
// to update the `RenderState`. This lets us keep the line decorations we set
@@ -1598,6 +1739,7 @@ impl CodeEditorModel {
15981739
} else if selection_model.all_single_cursors() && self.show_current_line_highlights {
15991740
// When diff is not expanded, the only source of line decoration is highlights
16001741
// from the active cursor, e.g. the current line highlight.
1742+
let overlay = Appearance::as_ref(ctx).theme().surface_2();
16011743
Some(
16021744
selection_model
16031745
.selected_lines(ctx)
@@ -2435,7 +2577,7 @@ impl CodeEditorModel {
24352577
if let Some(existing) = self.selection().as_ref(ctx).goal_xs.as_ref() {
24362578
existing
24372579
.iter()
2438-
.map(|px| px.as_f32().round() as u32)
2580+
.map(|col| col.as_pixels().as_f32().round() as u32)
24392581
.collect()
24402582
} else {
24412583
current_selections
@@ -2476,10 +2618,11 @@ impl CodeEditorModel {
24762618
if let Ok(new_selections) = Vec1::try_from_vec(new_selections_vec) {
24772619
self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx);
24782620

2479-
// Update goal_xs to the desired columns (stored as pixels for consistency with SelectionModel)
2621+
// Update goal_xs to the desired columns (stored as ColumnUnit::Pixels for
2622+
// consistency with the GUI SelectionModel pixel path)
24802623
let goal_pixels: Vec<_> = goal_cols
24812624
.into_iter()
2482-
.map(|c| (c as usize).into_pixels())
2625+
.map(|c| ColumnUnit::Pixels((c as usize).into_pixels()))
24832626
.collect();
24842627
self.selection().update(ctx, |selection, _| {
24852628
selection.goal_xs = Vec1::try_from_vec(goal_pixels).ok();
@@ -3649,11 +3792,19 @@ impl CoreEditorModel for CodeEditorModel {
36493792
buffer_version: BufferVersion,
36503793
ctx: &mut ModelContext<Self::T>,
36513794
) {
3652-
// Synchronously convert hidden range anchors into offsets for the given version. This allows the render model
3653-
// to accurately hide line ranges based on the corresponding incoming buffer state.
3795+
// Synchronously convert hidden range anchors into offsets for the given version. This allows
3796+
// the render model to accurately hide line ranges based on the corresponding incoming buffer state.
36543797
self.hidden_lines.update(ctx, |hidden_lines_model, ctx| {
36553798
hidden_lines_model.materialize_hidden_range_offsets(buffer_version, ctx);
36563799
});
3800+
// In TUI char-cell mode the async font-shaping pipeline is bypassed entirely (the
3801+
// LayoutAction::BufferEdit arm is a no-op for CharCell). We must therefore refresh the
3802+
// char-cell line index synchronously here so that offset_to_softwrap_point, max_line,
3803+
// and all cursor-positioning queries see up-to-date data in the same frame.
3804+
if let Some(char_cell) = self.render_state.as_ref(ctx).char_cell() {
3805+
let text = self.content.as_ref(ctx).text().into_string();
3806+
char_cell.update_text(&text);
3807+
}
36573808
}
36583809

36593810
fn content(&self) -> &ModelHandle<Buffer> {

app/src/editor/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ pub use view::*;
1313
pub use warpui::text::point::Point;
1414
use warpui::AppContext;
1515

16+
// Re-exported for use by the `warp_tui` TUI front-end, which needs to
17+
// construct and subscribe to `CodeEditorModel` in char-cell mode.
18+
pub use crate::code::editor::model::{CodeEditorModel, CodeEditorModelEvent};
19+
1620
pub fn init(app: &mut AppContext) {
1721
view::init(app);
1822
}
File renamed without changes.

crates/editor/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ string-offset.workspace = true
4646
sum_tree.workspace = true
4747
thiserror.workspace = true
4848
pathfinder_color = "0.5.0"
49+
unicode-width.workspace = true
4950
vec1.workspace = true
5051
warpui_core.workspace = true
5152
warp_core.workspace = true

0 commit comments

Comments
 (0)