Skip to content

base: Move TextView to gpui-base - #2881

Merged
huacnlee merged 24 commits into
mainfrom
base-text-view
Aug 31, 2026
Merged

huacnlee merged 24 commits into
mainfrom
base-text-view

Conversation

@huacnlee

@huacnlee huacnlee commented Aug 30, 2026

Copy link
Copy Markdown
Member

Description

TextView — the Markdown and HTML renderer, its parser, its inline layout and
its selection — lived in crates/ui, so a gpui-base application could not
render rich text without depending on the whole component library. This moves
the implementation to gpui-base and leaves a compatibility facade behind in
gpui_component::text.

What moved: document, format (Markdown and HTML), inline, inline_flow,
markdown_ext, node, selection, selection_adapter, state, style,
text_view, utils. What stayed: the component TextViewStyle, the
tree-sitter syntax highlighting, and the theme adapter that maps
gpui_component::Theme onto the Base style.

Base rich text is usable on its own. TextViewStyle::default() is a complete,
readable style rather than an empty customization bag, and syntax highlighting
is opt-in through code_block_highlighter so Base keeps no tree-sitter
language dependency.

gpui-component is unchanged

gpui_component::text::TextView keeps every builder, both associated element
types, Text, TextViewPlugin, markdown() and html(). Existing code
compiles as it did:

use gpui_component::text::{TextView, TextViewStyle};

TextView::markdown("readme", source)
    .style(TextViewStyle::default().table(scrolling_table))
    .selectable(true)

The facade folds a component style onto the one the active theme already
derived, so a caller who set one StyleRefinement field keeps the themed
padding and colors for the rest. crates/ui/tests/base_compat.rs and the
legacy_* unit tests hold that contract.

A new SelectableText element

Plain text that joins the window-scoped selection, for applications that want
copyable text without a rich-text document.

New semantic token: colors.selection

Selection had been a literal hsla(0.58, 0.85, 0.62, 0.35) written out in
three places: TextViewStyle::default(), the fallback in Inline::paint, and
SelectableText::paint. It is now one semantic token that the component
adapter maps from Theme::selection, so Base text selection follows the
application palette instead of a fixed blue. The field carries a serde default,
so palettes written before it existed still deserialize.

TextViewStyle::from_theme had derived selection as accent.alpha(0.4), which
in the default light palette is a near-white wash that barely reads as a
selection at all. It now uses the token.

Breaking Changes

Only gpui-base is affected. gpui-component's public API is unchanged.

TextViewStyle crosses the gpui-base seam, so its fields are now private
with with_* builders and accessors of the same name — a later field becomes
an additive change instead of a breaking one.

  let style = TextViewStyle::default()
-     .foreground(colors.foreground)
-     .muted_foreground(colors.muted_foreground)
-     .link(colors.link)
-     .selection(colors.selection);
- style.code_background = colors.elevated;
- style.border = colors.border;
- style.is_dark = true;
+     .with_foreground(colors.foreground)
+     .with_muted_foreground(colors.muted_foreground)
+     .with_link(colors.link)
+     .with_selection(colors.selection)
+     .with_code_background(colors.elevated)
+     .with_border(colors.border)
+     .with_dark(true);

- let radius = style.table.corner_radii.top_left;
+ let radius = style.table().corner_radii.top_left;

TextViewDefaults follows the same naming:

  TextViewDefaults::new()
-     .style(style)
-     .code_block_highlighter(highlighter)
+     .with_style(style)
+     .with_code_block_highlighter(highlighter)
      .install(cx);

gpui-shell mirrors the Base color tokens in its script API, so selection
joins them there too. A script that passes a literal token object to
set_theme must now supply it, the same rule every other color already
follows:

  set_theme({ appearance: "dark", tokens: {
    colors: {
      /* ... */
-     border: color, input: color, ring: color,
+     border: color, input: color, ring: color, selection: color,
    },
    /* ... */
  }});

ColorTokens::default() was every field zeroed, which is transparent on
transparent. It is now ColorTokens::light(), so a Base application that never
installs a palette renders legibly instead of black on black. This also removes
the gpui-shell binary's shipped palette and the "can this palette tell ink
from paper" guard in failure_surface, both of which existed only to work
around the zeroed default.

- let tokens = ColorTokens::default(); // all zeroes
+ let tokens = ColorTokens::default(); // == ColorTokens::light()

A palette deserialized from JSON with #[serde(default)] fields now falls back
to the light value for anything it omits, rather than to transparent.

Test plan

  • cargo test --workspace --exclude gpui-shell --features gpui-component-story/test-support
  • cargo test -p gpui-component --doc
  • cargo check -p gpui-component --no-default-features
  • cargo clippy -p gpui-component -p gpui-component-story -p gpui-component-assets -- --deny warnings
  • cargo machete

Parts of this PR were written with Claude Code.

huacnlee and others added 24 commits August 30, 2026 20:34
TextView took its async use of `futures` to `gpui-base`, so the
declaration left in `crates/ui` is now unused and fails `cargo machete`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QapTx3YhSfQqDtbTECRH22
`TextViewStyle` crosses the gpui-base seam, so its fields are now private
behind `with_*` builders and accessors of the same name; `TextViewDefaults`
takes the same naming. The component facade builds its style as one chain
rather than a builder call followed by field assignment.

Selection was a literal `hsla(0.58, 0.85, 0.62, 0.35)` in three places. It is
now a `colors.selection` semantic token, mapped from `Theme::selection`, with
a serde default so older palettes still load. `from_theme` no longer derives
selection from `accent`, which was a near-white wash in the light palette.

`compat` compared two whole `HighlightTheme`s every frame to decide whether the
caller had replaced it; it now compares Arc identity against the shared
default.

Restores the three code-block highlighter tests dropped by the move: two follow
the highlighter to `crates/ui`, where the registry and theme now live, and one
covers style invalidation in Base. `CodeBlock::from_code` lets anyone writing a
highlighter build a block to test it against.

Restores the field and builder documentation trimmed from the component
`TextViewStyle`, and documents the facade and `SelectableText` builders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QapTx3YhSfQqDtbTECRH22
`token_names_match_gpui_base_fields` asserts that the script-facing color
names mirror `gpui_base::ColorTokens` field for field, so adding `selection`
to Base left gpui-shell one name short and failed the Shell Core job.

The token now resolves for reads and `set_theme` writes it, which means a
script passing a literal token object must supply `selection` — the same rule
every other color already follows. Documentation lists it with the other
highlight roles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QapTx3YhSfQqDtbTECRH22
@huacnlee
huacnlee merged commit 346f703 into main Aug 31, 2026
8 checks passed
@huacnlee
huacnlee deleted the base-text-view branch August 31, 2026 03:51
madcodelife added a commit that referenced this pull request Aug 31, 2026
## Description

A trackpad swipe over a horizontally scrollable Markdown table scrolled
the
document instead, and scrolling the document slid the table sideways.

#2881 moved the table renderer into `gpui-base` and replaced
`horizontal_scroll_area()` — an `overflow_hidden` viewport plus a
sibling
`ScrollableMask` — with a plain `div().overflow_x_scroll()`, dropping
#2468,
#2608 and #2648 in one step. The only trace was an `#[allow(dead_code)]`
added
to the now-unreachable helper, so CI stayed green: the mask's tests
cover the
mask, not whether the table uses it.

`ScrollableMask` moves to `gpui-base` with its 16 tests (its
dependencies —
`ScrollbarHandle`, `AxisExt`, `StyledExt`, `OngoingScrollExt` — already
lived
there), and the table calls `horizontal_scroll_area` again. The viewport
keeps
the border and background #2881 put on it, folded into the refinement.

`gpui-component`'s public API is unchanged:
`gpui_component::scroll::ScrollableMask`
still resolves, now re-exported from Base.

## Why the mask, and not `restrict_scroll_to_axis`

`.lock_scroll_axis()` alone fixes only half of it. GPUI's overflow
listener runs
in the bubble phase and never stops propagation, and `gpui::list` — the
body
under `TextView::scrollable(true)` — registers its listener after its
items
paint, so bubble's reverse order runs the list first. A diagonal swipe
therefore
loses its vertical component to the document no matter what the table
does.
Winning requires consuming the event in the capture phase, which is what
the
mask is for.

## Test plan

- `cargo test -p gpui-base --lib scrollable_mask` — 16 tests, including
`horizontal_scroll_area_in_list_keeps_horizontal_dominant_wheel`, which
is the
  regression this PR fixes
- `cargo test --workspace --exclude gpui-shell`
- `cargo test -p gpui-component --doc`
- `cargo check -p gpui-component --no-default-features`
- `cargo clippy --workspace --exclude gpui-shell --all-targets -- --deny
warnings`
- `cargo fmt --check`
- By hand with a trackpad: `cargo run -p markdown_table`, and at several
frame
  widths via `WIN_W=<px>`

---

Parts of this PR were written with Claude Code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
huacnlee added a commit that referenced this pull request Aug 31, 2026
…ue (#2893)

## Summary

This is fix for #2881 included issue.

- avoid reparsing unchanged markdown when stateless TextView plugin
closures are rebuilt during render
- keep parser and renderer handles refreshed without triggering a render
loop
- restore upward and downward drag-selection auto-scroll in the Markdown
and HTML examples by keeping vertical viewport edges interactive
- add regressions for stateless markdown settling and bidirectional
selection auto-scroll

## Test Plan

- `cargo fmt --all -- --check`
- `cargo check --example markdown --example html`
- `cargo test -p gpui-base -p gpui-component`

Co-authored-by: Codex <codex@openai.com>
madcodelife added a commit that referenced this pull request Sep 4, 2026
## Summary

- Fix markdown (and any other `TextView`) scrolling on its own after a
single click: click on text, then move the pointer toward the top or
bottom edge of the viewport, and the view starts auto scrolling and
keeps going.
- Root cause: `WindowSelectionState::end()` keeps the anchor after mouse
up so a shift-click can extend it, and only clears `is_selecting`. #2881
moved the auto scroll call out of `update_impl` (which has the
`is_selecting` guard) into `update_in_window`, so `update_auto_scroll`
ran on every pointer move with a live anchor. Once the pointer was in
the edge zone it started the anchor auto scroll, and the synthesized
`ScrollWheelEvent` re-entered the same path and kept it alive.
- Guard `update_auto_scroll` on `is_selecting`, restoring the pre-#2881
behavior: only a live drag may scroll.
- Add the regression test
`pointer_moves_after_a_click_do_not_auto_scroll`: begin a gesture on
text, end it, then drive `update_in_window` past the viewport edge and
assert auto scroll stays idle.

The fix and the test were written with Claude Code.

## Test plan

- [x] `pointer_moves_after_a_click_do_not_auto_scroll` fails before the
fix and passes after.
- [x] `cargo test -p gpui-base --lib text_selection` — all 49 tests
pass.
- [x] `cargo test -p gpui-component --lib text::window_selection` — all
51 tests pass, including drag auto scroll in both directions.
- [x] `cargo clippy -p gpui-base --all-targets -- --deny warnings` and
`cargo fmt --check` are clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
feigeCode pushed a commit to feigeCode/gpui-kit that referenced this pull request Sep 5, 2026
…gbridge#2941)

## Summary

- Fix markdown (and any other `TextView`) scrolling on its own after a
single click: click on text, then move the pointer toward the top or
bottom edge of the viewport, and the view starts auto scrolling and
keeps going.
- Root cause: `WindowSelectionState::end()` keeps the anchor after mouse
up so a shift-click can extend it, and only clears `is_selecting`. longbridge#2881
moved the auto scroll call out of `update_impl` (which has the
`is_selecting` guard) into `update_in_window`, so `update_auto_scroll`
ran on every pointer move with a live anchor. Once the pointer was in
the edge zone it started the anchor auto scroll, and the synthesized
`ScrollWheelEvent` re-entered the same path and kept it alive.
- Guard `update_auto_scroll` on `is_selecting`, restoring the pre-longbridge#2881
behavior: only a live drag may scroll.
- Add the regression test
`pointer_moves_after_a_click_do_not_auto_scroll`: begin a gesture on
text, end it, then drive `update_in_window` past the viewport edge and
assert auto scroll stays idle.

The fix and the test were written with Claude Code.

## Test plan

- [x] `pointer_moves_after_a_click_do_not_auto_scroll` fails before the
fix and passes after.
- [x] `cargo test -p gpui-base --lib text_selection` — all 49 tests
pass.
- [x] `cargo test -p gpui-component --lib text::window_selection` — all
51 tests pass, including drag auto scroll in both directions.
- [x] `cargo clippy -p gpui-base --all-targets -- --deny warnings` and
`cargo fmt --check` are clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant