From 6c6f2b57f29a4b9152cb8b3ba59d4c1db3830ad8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 17 Sep 2026 15:19:09 +0700 Subject: [PATCH 01/10] add plan --- docs/gpui-base-migration.md | 258 ++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/gpui-base-migration.md diff --git a/docs/gpui-base-migration.md b/docs/gpui-base-migration.md new file mode 100644 index 00000000..09bca188 --- /dev/null +++ b/docs/gpui-base-migration.md @@ -0,0 +1,258 @@ +# Migrating `crates/ui` to `gpui-base` + +`crates/ui` is a fork of an early version of `gpui-component`: 71 files and roughly +21.9k lines that mix behavior, presentation, and application shell. This document is +the plan for moving its behavior half onto the upstream `gpui-base` crate while the +application keeps the design system it has today. + +The facts below were checked against `gpui-base 0.6.1` (crates.io), the `gpui-kit` +repository at `main`, and this workspace's `Cargo.lock` (zed at `4b47ceb`, +2026-09-17). Line counts come from `wc -l` under `crates/ui/src`. + +## The two facts that shape the work + +**GPUI still comes from upstream — addressed as the `gpui-pre` package.** `gpui-base` +declares its GPUI dependency as `gpui = { package = "gpui-pre", version = "0.3.1" }`: +the crate in the graph is the published package `gpui-pre`, and `gpui` is only the name +used in code. That package is upstream zed's gpui (a snapshot of `zed@d89e9c2`) +republished unchanged, so nothing is forked and there is no source to align. Coop +currently pins zed's git repository at `4b47ceb` (2026-09-17), roughly four days ahead +of that snapshot. + +The two cannot be mixed. Zed's git `gpui` and the `gpui-pre` package are different +crates, so `App`, `Window`, `Entity`, and elements from one are not the other's types, +and a dependency graph that contains both does not compile. Zed's crates.io `gpui` +(0.2.2, October 2025) is also far behind the APIs coop already uses. The workspace's +`gpui` entry therefore has to resolve to the `gpui-pre` package; with the `package =` +alias, every `use gpui::…` site stays as it is. + +**The fork's external contract is small.** Outside `crates/ui`, the crate is consumed +as 38 imported items plus a single `ui::init(cx)` call, across 13 modules, and never +deeper than `ui::::`: + +| Module | Items | Consumer files | +| --- | --- | --- | +| crate root (`Icon`, `IconName`, `h_flex`, `v_flex`, `divider`, `Root`, `TitleBar`, `Sizable`, `Selectable`, `Disableable`, `StyledExt`, `WindowExtension`, `InteractiveElementExt`) | 13 | 17 | +| `input` (`InputState`, `Input`, `InputEvent`) | 3 | 10 | +| `button` (`Button`, `ButtonVariants`) | 2 | 14 | +| `dock` (`Panel`, `PanelView`, `DockArea`, `DockItem`, `DockPlacement`, `PanelEvent`, `ClosePanel`) | 7 | 10 | +| `notification`, `avatar`, `menu`, `scroll`, `group_box`, `indicator`, `switch`, `modal`, `tooltip` | 12 | 16 | +| `list`, `checkbox`, `popover`, `resizable`, `skeleton`, `tab`, `divider` (module), `history`, `animation`, `actions` | 0 references | 0 | + +`ui::list` and `ui::checkbox` have no consumers at all; the message list in +`crates/chat_ui` uses GPUI's own `list::ListState`. The modules with zero external +references still serve as internal machinery for `dock`, `menu`, `modal`, and `input`. + +The consequence: this is not a rewrite of an app-facing library. Most of the work is +deleting internals and re-expressing a few thousand lines of presentation over base +primitives. + +## What must not change + +- `crates/theme` stays the source of truth: `ThemeColors`, `ThemeFamily`, the registry, + scrollbar mode, platform, font size and radii. +- Behavior comes from `gpui-base`; presentation comes from `theme` plus the `ui` styled + layer. Every migrated component keeps reading `cx.theme()` and keeps its current + spacing, radius, and shadow math, so the rendered result does not move. +- Application code keeps importing `theme::ActiveTheme` and `ui::*` under its current + names. Module paths are part of the contract; internals are not. +- `gpui-component` is not adopted. It is a complete, styled visual language, and taking + it would replace the design system rather than preserve it. + +Two `Theme` types will exist — `theme::Theme` and `gpui_base::Theme` — as separate GPUI +globals. Coop's stays the application-facing one. Base's is touched in exactly one +place: a `theme::sync_base(cx)` that projects coop's colors into +`gpui_base::Theme::global_mut(cx).tokens` (`SemanticThemeTokens`: colors, radius, +typography, shadow) plus `ThemeAppearance`, `ScrollbarTheme`, and `ResizableTheme`. It +runs from `ui::init` and on every theme change. This is needed because base paints a +few things itself — the focus ring from `FocusableExt`, text selection under glyphs, +scrollbars, resize handles, and the dialog backdrop — and those should follow coop's +palette rather than base's default. + +## What each module becomes + +| `ui` module | LOC | Plan | `gpui-base` counterpart | +| --- | --- | --- | --- | +| `input/` (state, element, display_map, rope_ext, mask_pattern, movement, selection, indent, mode, change, cursor, blink_cursor, clear_button) | 6,929 | Replace; keep `ui::input::{Input, InputEvent, InputState}` as the import path | `Input`/`InputState`, `Textarea`/`TextareaState`, `Editor` | +| `list/` | 1,477 | Delete | GPUI's own `list` (already in use) | +| `checkbox.rs` | 312 | Delete | `Checkbox` | +| `scroll/` (scrollbar, scrollable, scrollable_mask) | 1,332 | Replace; keep the `ScrollableElement` and `Scrollbar` names | `Scrollbar`, `ScrollableMask` | +| `resizable/` | 927 | Replace; base exports the same names (`h_resizable`, `v_resizable`, `resizable_panel`, `PANEL_MIN_SIZE`, `resize_handle`) | `Resizable` + `ResizeHandleRenderer` for the coop hairline | +| `modal.rs` | 540 | Port onto base parts; keep `Modal`, `ModalButtonProps`, and `window.open_modal` | `Dialog`, `AlertDialog` | +| `notification.rs` | 584 | Port; keep `Notification`, `NotificationKind`, and `window.push_notification` | `Toast`, `ToastManager`, `ToastStack` | +| `popover.rs` | 432 | Replace with a coop-styled wrapper | `Popover`, `Popup`, `Positioner` | +| `tooltip.rs` | 36 | Replace with a coop-styled wrapper | `Tooltip` | +| `button.rs` | 626 | Skin: base behavior plus coop's existing variant tables | `Button`, `StateStyle` | +| `switch.rs` | 287 | Skin | `Switch`, `SwitchTrack`, `SwitchThumb` | +| `avatar.rs` | 141 | Skin | `Avatar`, `AvatarImage`, `AvatarFallback` | +| `history.rs`, `index_path.rs`, `element_ext.rs`, `event.rs`, `focusable.rs` | 340 | Delete | `History`/`UndoHistory`, `IndexPath`, `ElementExt`, `InteractiveElementExt`, `FocusableExt`, `FocusTrapElement` | +| `styled.rs`, `actions.rs`, `animation.rs` | 305 | Keep `ui::StyledExt`, `Size`, and `Sizable` as the app's import; base's `h_flex`/`v_flex` helpers are identical (`flex_row` + `items_center`) and can be delegated to | `styled`, `StateStyle` | +| `icon.rs`, `kbd.rs`, `divider.rs`, `skeleton.rs`, `group_box.rs`, `indicator.rs` | 1,023 | Keep; no base equivalent, these are the design system | — | +| `menu/` | 2,208 | Keep; base has no menu. Optional later: re-base anchoring and dismissal on `Popup`/`Positioner` | `Popup` (optional) | +| `dock/` + `tab/` | 3,356 | Keep for now; see phase 5 | base dock (different contract) | +| `root.rs`, `window_ext.rs`, `title_bar.rs` | 965 | Keep; app shell. `Root` continues to host the dialog and toast layers and `focused_input` | — | + +Roughly 10k lines are removed, 3k are re-expressed as thin skins, and 8k are kept. + +## Dependency change + +The workspace manifest's GPUI entries become: + +```toml +[workspace.dependencies] +gpui = { package = "gpui-pre", version = "0.3.5" } +gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] } +gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" } +gpui_windows = { package = "gpui-pre-windows", version = "0.3.5" } +gpui_macos = { package = "gpui-pre-macos", version = "0.3.5" } +gpui_web = { package = "gpui-pre-web", version = "0.3.5" } +reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" } +sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" } +gpui-base = "0.6.1" +``` + +Because of the `package =` alias, `use gpui::…` and `use gpui_platform::…` keep +compiling unchanged. `gpui_web` moves from `web/Cargo.toml` into the workspace table +with the rest. + +The only alternative — leaving the workspace on zed's git `gpui` and redirecting +`gpui-base`'s dependency to it — means vendoring `gpui-base` and owning its source. +That is a fork, and this plan deliberately avoids it. + +`gpui_tokio` is the one missing piece: longbridge does not republish it, and +`crates/state` uses it in three places (`init`, `spawn`, `spawn_result`). Either vendor +zed's small crate into the workspace, or drop it for `cx.background_spawn`. Decide in +phase 0. + +`gpui-base` and `gpui-pre` move together on minor versions (`0.6.x` requires `0.3.x`); +bump both in the same change. + +## Phases + +### Phase 0 — move `gpui` onto the `gpui-pre` package (manifest only) + +Point the workspace's GPUI entries at the published `gpui-pre` crates and fix whatever +the four days of API drift between `4b47ceb` and `zed@d89e9c2` broke. There is no GPUI +source to align, patch, or vendor. Confirm that the entry points coop calls still exist +in 0.3.5: `gpui_platform::application()`, `web_init()`, and `single_threaded_web()`. + +Exit criteria: `cargo check` passes for `desktop` and for +`cargo check -p coop_web --target wasm32-unknown-unknown`, and the drift fixes are +listed in the pull request. The change rewrites the dependency graph, so it stays in a +pull request of its own. + +### Phase 1 — Wire base, delete dead weight (no visual change) + +Add `gpui-base`, make `ui::init` call `gpui_base::init(cx)` followed by +`theme::sync_base(cx)`, and re-export the base utilities the app already imports under +their current names (`ElementExt`, `InteractiveElementExt`, `IndexPath`, `History`, +`Disableable`, `Selectable`). Delete `checkbox.rs` and `list/`, which have no +consumers, along with `history.rs`, `index_path.rs`, `element_ext.rs`, and `event.rs` +once base supplies them. Drop the dependencies this leaves unused. + +Exit criteria: no diff outside `crates/ui` and `crates/theme`, the app launches, and +switching the theme still restyles everything. + +### Phase 2 — `input/` (the largest single win, ~6.9k lines) + +The mapping is close to 1:1 with what the app actually uses: + +| Coop today | `gpui-base` | +| --- | --- | +| `InputState::new(window, cx).placeholder(..)` | same | +| `.auto_grow(1, 20)` (chat composer) | `TextareaState::auto_grow(2, 8)` with `Textarea` | +| `.masked(true)` (nsec, password, key) | `InputState::masked(true)`, `unmask_value()` | +| `.set_value(value, window, cx)` | `set_value(value, window, cx)` | +| `InputEvent::{Change, PressEnter, Focus, Blur}` | identical variants | +| `Input::new(&state).appearance(false)` | coop's `Input` keeps these chrome options | + +Known gaps to reconcile here, verified against the 0.6.1 source before starting: +`clean_on_escape()`, `set_loading()` (called from `crates/workspace/src/sidebar/mod.rs`), +and the `InputEditorStyle` hook that has to be filled from coop tokens. Everything else +in `input/` — `display_map`, `rope_ext`, `mask_pattern`, `movement`, `selection`, +`indent`, `mode`, `element` — is deleted. Afterwards, `ropey`, `sum_tree`, +`lsp-types`, `tree-sitter`, `regex`, `unicode-segmentation`, and `uuid` can probably +leave `crates/ui`'s manifest. + +Surfaces to re-verify: the chat composer (auto-grow, Enter to send, IME), the subject +line, the settings dialog, profile, relay and messaging lists, the import/restore/backup +dialogs, and sidebar search. + +### Phase 3 — overlays and feedback + +`popover` becomes a wrapper over base `Popover`; `modal` composes base `Dialog` and +`AlertDialog` while keeping the `Modal` API and `window.open_modal`; `notification` +moves onto `Toast`/`ToastManager` (base owns the stack, timers, and motion; coop owns +the visual and the placement from `theme.notification`); `tooltip` becomes a wrapper +over base `Tooltip`. `Root` and `window_ext` keep their public API and host the new +layers. No call site changes. + +### Phase 4 — leaf controls, scroll, and resizable (one module per pull request) + +Order: `tooltip`, `avatar`, `switch`, `button`, `scroll/`, `resizable/`. `button` is the +largest skin: the `ButtonVariants` and `ButtonCustomVariant` tables, the `compact`, +`loading`, and `caret` builders, and the variant names stay as they are, with styling +supplied through base's semantic-state styles. `scroll/` keeps the `ScrollableElement` +trait name so `.vertical_scrollbar(..)` call sites keep compiling, and `resizable/` +becomes a thin re-export of base's identically named API plus a `ResizeHandleRenderer` +for the coop hairline. + +Each of these is independently shippable. Acceptance for each: no change outside +`crates/ui`, and the surfaces that use the module are pixel-identical before and after. + +### Phase 5 — dock, tab, and menu (deliberately later) + +Base has a full dock, but its contract is "layout is data, and the application +implements the renderer traits", while coop's `Panel`/`PanelView`/`DockArea`/`DockItem` +is an app-specific shell already consumed by `crates/workspace` and `crates/chat_ui`. +Moving it is a project of its own, and it would also retire `tab/` and touch `menu/`. +Keep them local until phases 1–4 have landed, then plan dock separately. Re-basing +menu positioning and dismissal on base `Popup`/`Positioner` is optional and later still. + +## Verification + +There is no UI test suite to lean on, so each phase gets the same treatment: + +- `cargo check` at the workspace root, plus + `cargo check -p coop_web --target wasm32-unknown-unknown` for the web target. +- Launch the app and walk the surfaces the phase touched. The settings dialog is the + densest single smoke surface (Button, GroupBox, Switch, Input, DropdownMenu, + PopupMenuItem), followed by the chat panel and the sidebar. +- For phase 4, record before/after screenshots per module. +- Keep the call-site diff at zero for phases 1, 3, and 4; if a call site has to change + because base has no equivalent (`set_loading` is the known candidate), list it in the + pull request. + +## Risks and non-goals + +- **Snapshot lag.** The `gpui-pre` package is a republished snapshot, so it trails zed + `main` by however long it takes longbridge to cut the next release (a few days). A new + GPUI API is therefore unavailable until then. That is the price of not maintaining a + fork; the escape hatch — vendoring `gpui-base` and patching it onto zed's git + repository — should stay unused. +- **The `gpui` dependency line is load-bearing.** Depending on zed's git `gpui` + alongside `gpui-base` looks harmless and is not: it puts two GPUI crates in the graph + and every window, context, and element crossing between them becomes a type error. +- **Two `Theme` globals.** Confine `gpui_base::Theme` to `theme::sync_base` and + `crates/ui` internals; application code keeps using `theme::ActiveTheme`. Avoid + importing both `Theme` types into one file. +- **`gpui_tokio`** has to be vendored or dropped (phase 0). +- **Non-goals:** adopting `gpui-component`, migrating dock/tab/menu, rewriting the + self-contained pieces (`icon`, `kbd`, `divider`, `skeleton`, `group_box`, + `indicator`), and changing any color, radius, or spacing value. + +## Pull request sequence + +| PR | Content | Touches outside `crates/ui` | +| --- | --- | --- | +| 1 | Phase 0: move `gpui` to the `gpui-pre` package, fix drift | `Cargo.toml`, possibly `crates/state` | +| 2 | Phase 1: base wiring, `sync_base`, deletions | none | +| 3 | Phase 2: input | none, or the named gaps above | +| 4 | Phase 3: popover, modal, notification, tooltip | none | +| 5–10 | Phase 4: one leaf module each | none | +| later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | + +The end state: the application keeps its design system and its call sites, `crates/ui` +shrinks by roughly half, and the parts that are genuinely hard — text editing, +focus and IME, drag-resize arithmetic, overlay lifecycle, accessibility semantics — are +maintained upstream instead of in a fork. -- 2.54.0 From 7118565b4ddcfcfb9a5bb58b39c052fbd17b4c23 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 17 Sep 2026 15:50:53 +0700 Subject: [PATCH 02/10] clean up --- Cargo.lock | 973 ++++++++++++++++----------- Cargo.toml | 23 +- crates/gpui_tokio/Cargo.toml | 11 + crates/gpui_tokio/src/lib.rs | 102 +++ crates/theme/Cargo.toml | 1 + crates/theme/src/lib.rs | 61 ++ crates/ui/Cargo.toml | 3 +- crates/ui/src/checkbox.rs | 312 --------- crates/ui/src/element_ext.rs | 27 - crates/ui/src/event.rs | 21 - crates/ui/src/focusable.rs | 39 -- crates/ui/src/index_path.rs | 69 -- crates/ui/src/lib.rs | 14 +- crates/ui/src/list/cache.rs | 221 ------ crates/ui/src/list/delegate.rs | 171 ----- crates/ui/src/list/list.rs | 747 -------------------- crates/ui/src/list/list_item.rs | 226 ------- crates/ui/src/list/loading.rs | 34 - crates/ui/src/list/mod.rs | 28 - crates/ui/src/list/separator_item.rs | 50 -- crates/ui/src/styled.rs | 27 +- docs/gpui-base-migration.md | 218 ++++-- web/Cargo.toml | 2 +- 23 files changed, 942 insertions(+), 2438 deletions(-) create mode 100644 crates/gpui_tokio/Cargo.toml create mode 100644 crates/gpui_tokio/src/lib.rs delete mode 100644 crates/ui/src/checkbox.rs delete mode 100644 crates/ui/src/element_ext.rs delete mode 100644 crates/ui/src/event.rs delete mode 100644 crates/ui/src/focusable.rs delete mode 100644 crates/ui/src/index_path.rs delete mode 100644 crates/ui/src/list/cache.rs delete mode 100644 crates/ui/src/list/delegate.rs delete mode 100644 crates/ui/src/list/list.rs delete mode 100644 crates/ui/src/list/list_item.rs delete mode 100644 crates/ui/src/list/loading.rs delete mode 100644 crates/ui/src/list/mod.rs delete mode 100644 crates/ui/src/list/separator_item.rs diff --git a/Cargo.lock b/Cargo.lock index 75038728..d8ad9f99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,7 +270,7 @@ name = "assets" version = "1.0.2" dependencies = [ "anyhow", - "gpui", + "gpui-pre", "log", "rust-embed", ] @@ -531,7 +531,7 @@ dependencies = [ name = "auto_update" version = "1.0.2" dependencies = [ - "gpui", + "gpui-pre", "gpui-updater-core", "instant", "log", @@ -1072,7 +1072,7 @@ dependencies = [ "flume 0.11.1", "futures", "fuzzy-matcher", - "gpui", + "gpui-pre", "instant", "itertools 0.13.0", "log", @@ -1093,7 +1093,7 @@ dependencies = [ "common", "flume 0.11.1", "futures", - "gpui", + "gpui-pre", "itertools 0.13.0", "linkify", "log", @@ -1241,16 +1241,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "collections" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" -dependencies = [ - "gpui_util", - "indexmap", - "rustc-hash 2.1.3", -] - [[package]] name = "color_quant" version = "1.1.0" @@ -1272,9 +1262,9 @@ name = "common" version = "1.0.2" dependencies = [ "chrono", - "dirs 5.0.1", + "dirs", "futures", - "gpui", + "gpui-pre", "instant", "itertools 0.13.0", "log", @@ -1423,15 +1413,15 @@ dependencies = [ "chat", "common", "device", - "gpui", - "gpui_linux", - "gpui_macos", - "gpui_platform", - "gpui_windows", + "gpui-pre", + "gpui-pre-linux", + "gpui-pre-macos", + "gpui-pre-platform", + "gpui-pre-reqwest-client", + "gpui-pre-windows", "log", "nostr-sdk", "person", - "reqwest_client", "settings", "state", "theme", @@ -1453,9 +1443,9 @@ dependencies = [ "getrandom 0.2.17", "getrandom 0.3.4", "getrandom 0.4.3", - "gpui", - "gpui_platform", - "gpui_web", + "gpui-pre", + "gpui-pre-platform", + "gpui-pre-web", "instant", "log", "person", @@ -1790,16 +1780,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "derive_refineable" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "device" version = "1.0.2" @@ -1807,7 +1787,7 @@ dependencies = [ "anyhow", "common", "flume 0.11.1", - "gpui", + "gpui-pre", "instant", "log", "nostr-sdk", @@ -1849,16 +1829,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys 0.4.1", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys 0.5.0", + "dirs-sys", ] [[package]] @@ -1869,22 +1840,10 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users 0.4.6", + "redox_users", "windows-sys 0.48.0", ] -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.3", - "windows-sys 0.61.2", -] - [[package]] name = "dispatch" version = "0.2.0" @@ -2387,6 +2346,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.34" @@ -2704,9 +2673,45 @@ dependencies = [ ] [[package]] -name = "gpui" -version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" +name = "gpui-base" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d45dcaaeac889bf1e7757db1beb26c9043c8ea3156651facc11c6be56bb6722" +dependencies = [ + "aho-corasick", + "anyhow", + "async-channel", + "chrono", + "futures", + "gpui-pre", + "gpui-pre-macros", + "gpui-pre-sum-tree", + "html5ever", + "instant", + "lsp-types", + "markdown", + "markup5ever_rcdom", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "raw-window-handle", + "regex", + "ropey", + "schemars", + "serde", + "serde_json", + "smallvec", + "smol", + "tracing", + "unicode-segmentation", + "web-time", +] + +[[package]] +name = "gpui-pre" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a9ca98fdcad06276de623c2f48ebd710f1e7724eebef2f2673c583e85dc8335" dependencies = [ "accesskit", "anyhow", @@ -2715,7 +2720,6 @@ dependencies = [ "bindgen", "bitflags 2.13.2", "chrono", - "collections", "core-video", "ctor", "derive_more", @@ -2724,19 +2728,23 @@ dependencies = [ "futures", "futures-concurrency", "getrandom 0.3.4", - "gpui_macros", - "gpui_shared_string", - "gpui_util", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-macros", + "gpui-pre-refineable", + "gpui-pre-scheduler", + "gpui-pre-shared-string", + "gpui-pre-sum-tree", + "gpui-pre-util", + "gpui-pre-util-macros", + "gpui-pre-ztracing", "heapless 0.9.3", - "http_client", "image", "inventory", "itertools 0.14.0", "log", "lyon", "num_cpus", - "objc2-core-foundation", - "objc2-core-video", "parking", "parking_lot", "pin-project", @@ -2745,10 +2753,8 @@ dependencies = [ "profiling", "rand 0.9.5", "raw-window-handle", - "refineable", "regex", "resvg", - "scheduler", "schemars", "seahash", "serde", @@ -2757,13 +2763,12 @@ dependencies = [ "smallvec", "spin 0.10.1", "strum", - "sum_tree", "taffy", "thiserror 2.0.20", + "tracing", "ttf-parser", "url", "usvg", - "util_macros", "uuid", "waker-fn", "web-time", @@ -2773,36 +2778,22 @@ dependencies = [ ] [[package]] -name = "gpui-updater-core" -version = "0.1.0" -source = "git+https://github.com/AprilNEA/gpui-updater#a622818f581eb8663a3a19d65f18b962f665946c" -dependencies = [ - "minisign-verify", - "semver", - "serde", - "serde_json", - "sha2 0.10.9", - "thiserror 2.0.20", - "tracing", - "ureq", -] - -[[package]] -name = "gpui_apple" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" +name = "gpui-pre-apple" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fc4da82c5c2dbb25443655cfbc4305a2e2d1380bc0b815e80664fdfbf4cfdb" dependencies = [ "anyhow", "block", "cbindgen", "cocoa 0.26.0", - "collections", "core-foundation 0.10.1", "core-video", "derive_more", "etagere", "foreign-types", - "gpui", + "gpui-pre", + "gpui-pre-collections", "image", "log", "metal", @@ -2812,9 +2803,65 @@ dependencies = [ ] [[package]] -name = "gpui_linux" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" +name = "gpui-pre-collections" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626a09f683ff6f3b2f708341bdcea9d479af5b14a6a2fccec9cff935ec0751b7" +dependencies = [ + "gpui-pre-util", + "indexmap", + "rustc-hash 2.1.3", +] + +[[package]] +name = "gpui-pre-derive-refineable" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d06116a2b857588f6ea7bfb447b3554ba985e780b5995ae47780252da152f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-http-client" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0d2071c582c1a0f71b8c7c00607a3b9cd18f81fb3510d41845db3447b2a11c9" +dependencies = [ + "anyhow", + "async-compression", + "bytes", + "derive_more", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + +[[package]] +name = "gpui-pre-http-client-tls" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b6f3936802580259d5af9bcb2ab37fb0d7002caeed449eaabb007c62aea7341" +dependencies = [ + "log", + "rustls", + "rustls-platform-verifier", + "webpki-roots 1.0.9", +] + +[[package]] +name = "gpui-pre-linux" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b427589681ec1fed27951fd82edc70a7e288bb2487ede26dbbd07c2ce23d4f" dependencies = [ "accesskit", "accesskit_unix", @@ -2825,13 +2872,13 @@ dependencies = [ "bytemuck", "calloop", "calloop-wayland-source", - "collections", "filedescriptor", "futures", - "gpui", - "gpui_util", - "gpui_wgpu", - "http_client", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-util", + "gpui-pre-wgpu", "libc", "log", "notify-rust", @@ -2858,17 +2905,18 @@ dependencies = [ ] [[package]] -name = "gpui_macos" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" +name = "gpui-pre-macos" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806eac0cb5c0eebc0b032c6450c11d283264f9ac05754dda83fc80d0b86b4ee8" dependencies = [ "accesskit", "accesskit_macos", "anyhow", "async-task", + "block", "block2 0.6.2", "cocoa 0.26.0", - "collections", "core-foundation 0.10.1", "core-foundation-sys", "core-graphics 0.24.0", @@ -2877,19 +2925,20 @@ dependencies = [ "dispatch2", "foreign-types", "futures", - "gpui", - "gpui_apple", - "gpui_util", + "gpui-pre", + "gpui-pre-apple", + "gpui-pre-collections", + "gpui-pre-media", + "gpui-pre-util", "image", "itertools 0.14.0", "libc", "log", "mach2", + "metal", "objc", "objc2 0.6.4", "objc2-app-kit 0.3.2", - "objc2-core-graphics", - "objc2-core-media", "objc2-foundation 0.3.2", "objc2-screen-capture-kit", "objc2-user-notifications", @@ -2904,33 +2953,157 @@ dependencies = [ ] [[package]] -name = "gpui_macros" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" +name = "gpui-pre-macros" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06139051bf1db51949bfbb4ee338208bd64b4c35340d8c1a6a8200744e28fe1" dependencies = [ "heck 0.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.119", ] [[package]] -name = "gpui_platform" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" +name = "gpui-pre-media" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500acb13f818ff234021c1b9eae8592b0cbe017a64e26908c736b7421b1c6213" dependencies = [ - "console_error_panic_hook", - "gpui", - "gpui_linux", - "gpui_macos", - "gpui_web", - "gpui_windows", + "anyhow", + "bindgen", + "core-foundation 0.10.1", + "core-video", + "foreign-types", + "metal", + "objc", ] [[package]] -name = "gpui_shared_string" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" +name = "gpui-pre-perf" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0973bbb02bf46940ac0861d9dce0fc03a3109634172b69108d1bc13fa29d76c" +dependencies = [ + "gpui-pre-collections", + "serde", + "serde_json", +] + +[[package]] +name = "gpui-pre-platform" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b613512e29f1e7b825bfa7e50a447388ff1347044350a475e4c98a892a985c8d" +dependencies = [ + "console_error_panic_hook", + "gpui-pre", + "gpui-pre-linux", + "gpui-pre-macos", + "gpui-pre-web", + "gpui-pre-windows", +] + +[[package]] +name = "gpui-pre-refineable" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428f23703ead5601618ffea73c137897b98a94ce8d30e257f22be947732dfbf9" +dependencies = [ + "gpui-pre-derive-refineable", +] + +[[package]] +name = "gpui-pre-reqwest" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05be23908e707966824f8c51a609904b7f30739e8d915e120a53c1b995ba964c" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry 0.4.0", +] + +[[package]] +name = "gpui-pre-reqwest-client" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05d4e3d2eb2ab777597879e46047247447b9d93cef568e63d545c9deb1b3e57" +dependencies = [ + "anyhow", + "bytes", + "futures", + "gpui-pre-http-client", + "gpui-pre-http-client-tls", + "gpui-pre-reqwest", + "gpui-pre-util", + "log", + "regex", + "tokio", +] + +[[package]] +name = "gpui-pre-scheduler" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd1b7ff2e7bf43302ae982cd786a22c16c52e16682e67b66cfbe2cb2bc3a72d" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume 0.12.0", + "futures", + "parking_lot", + "rand 0.9.5", + "wasm_thread", + "web-time", +] + +[[package]] +name = "gpui-pre-shared-string" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424a4dd6a570473d57acf1df2bfb9f3028d4b568abcaeb9252e0f13047b50c03" dependencies = [ "schemars", "serde", @@ -2938,20 +3111,23 @@ dependencies = [ ] [[package]] -name = "gpui_tokio" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" +name = "gpui-pre-sum-tree" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4855718e82630d29198b515d2b027361ef63dd19b6021cefe945789dcff82372" dependencies = [ - "anyhow", - "gpui", - "gpui_util", - "tokio", + "gpui-pre-ztracing", + "heapless 0.9.3", + "log", + "rayon", + "tracing", ] [[package]] -name = "gpui_util" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" +name = "gpui-pre-util" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e973148165346c0f9b3f5072b321d7c2f33807c93daea67fe298c005c0e0c2a8" dependencies = [ "anyhow", "log", @@ -2959,21 +3135,33 @@ dependencies = [ ] [[package]] -name = "gpui_web" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" +name = "gpui-pre-util-macros" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f71f6212d3772a70988c7d64627c7284dec73c17992ac4f9f0cdfdd46cbb677" +dependencies = [ + "gpui-pre-perf", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-web" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f0daada903f106be946d87e25f5bc85ad6cf60d6d82cb8f8aed945417a9c1a3" dependencies = [ "anyhow", "console_error_panic_hook", "futures", - "gpui", - "gpui_wgpu", - "http_client", + "gpui-pre", + "gpui-pre-http-client", + "gpui-pre-scheduler", + "gpui-pre-wgpu", "js-sys", "log", "parking_lot", "raw-window-handle", - "scheduler", "smallvec", "unicode-properties", "unicode-script", @@ -2987,17 +3175,18 @@ dependencies = [ ] [[package]] -name = "gpui_wgpu" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" +name = "gpui-pre-wgpu" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640b666a16ddf9504e2eb3e7a997acb2b2adfaa7859925bdd36fc3acfd8f30a7" dependencies = [ "anyhow", "bytemuck", - "collections", "cosmic-text", "etagere", - "gpui", - "gpui_util", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-util", "itertools 0.14.0", "log", "parking_lot", @@ -3013,19 +3202,20 @@ dependencies = [ ] [[package]] -name = "gpui_windows" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" +name = "gpui-pre-windows" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c12c7d84ec422feb2f064117d572447998381e5c219fcad69fe19f10d0b7aaea" dependencies = [ "accesskit", "accesskit_windows", "anyhow", - "collections", "dunce", "etagere", "futures", - "gpui", - "gpui_util", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-util", "image", "itertools 0.14.0", "log", @@ -3040,6 +3230,61 @@ dependencies = [ "windows-registry 0.6.1", ] +[[package]] +name = "gpui-pre-zlog" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98352858fac3d4d05b46d6014ad04bad313652a691d4e21dc55fa96fb4acb7cf" +dependencies = [ + "anyhow", + "chrono", + "gpui-pre-collections", + "log", +] + +[[package]] +name = "gpui-pre-ztracing" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d63a3c7276875b4d7e6d3961e92a138e9233e86a591bf318f2e54df4ad58102" +dependencies = [ + "gpui-pre-zlog", + "gpui-pre-ztracing-macro", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "gpui-pre-ztracing-macro" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e2a5ff79002223edfcfa76c6f9f79f4f60f6ec545ccc27832f12bd46348b39" + +[[package]] +name = "gpui-updater-core" +version = "0.1.0" +source = "git+https://github.com/AprilNEA/gpui-updater#a622818f581eb8663a3a19d65f18b962f665946c" +dependencies = [ + "minisign-verify", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "tracing", + "ureq", +] + +[[package]] +name = "gpui_tokio" +version = "0.1.0" +dependencies = [ + "anyhow", + "gpui-pre", + "gpui-pre-util", + "tokio", +] + [[package]] name = "h2" version = "0.4.19" @@ -3254,6 +3499,20 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "http" version = "1.5.0" @@ -3287,37 +3546,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http_client" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" -dependencies = [ - "anyhow", - "async-compression", - "bytes", - "derive_more", - "futures", - "http", - "http-body", - "log", - "parking_lot", - "serde", - "serde_json", - "serde_urlencoded", - "url", -] - -[[package]] -name = "http_client_tls" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" -dependencies = [ - "log", - "rustls", - "rustls-platform-verifier", - "webpki-roots 1.0.9", -] - [[package]] name = "httparse" version = "1.10.1" @@ -4034,6 +4262,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -4066,6 +4300,42 @@ dependencies = [ "libc", ] +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "serde", + "unicode-id", +] + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -4645,8 +4915,8 @@ dependencies = [ "block2 0.5.1", "libc", "objc2 0.5.2", - "objc2-core-data", - "objc2-core-image", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-foundation 0.2.2", "objc2-quartz-core 0.2.2", ] @@ -4659,31 +4929,28 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", + "libc", "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data 0.3.2", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] -name = "objc2-core-audio" +name = "objc2-cloud-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" -dependencies = [ - "dispatch2", - "objc2 0.6.4", - "objc2-core-audio-types", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-core-audio-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.13.2", "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] @@ -4698,6 +4965,17 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -4734,6 +5012,16 @@ dependencies = [ "objc2-metal 0.2.2", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-location" version = "0.3.2" @@ -4745,18 +5033,15 @@ dependencies = [ ] [[package]] -name = "objc2-core-media" +name = "objc2-core-text" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.13.2", - "dispatch2", "objc2 0.6.4", - "objc2-core-audio", - "objc2-core-audio-types", "objc2-core-foundation", - "objc2-core-video", + "objc2-core-graphics", ] [[package]] @@ -4871,10 +5156,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394" dependencies = [ "block2 0.6.2", - "dispatch2", "objc2 0.6.4", "objc2-core-graphics", - "objc2-core-media", "objc2-foundation 0.3.2", ] @@ -5114,16 +5397,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "perf" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" -dependencies = [ - "collections", - "serde", - "serde_json", -] - [[package]] name = "person" version = "1.0.2" @@ -5131,7 +5404,7 @@ dependencies = [ "anyhow", "common", "flume 0.11.1", - "gpui", + "gpui-pre", "instant", "log", "nostr-sdk", @@ -5160,6 +5433,16 @@ dependencies = [ "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -5413,6 +5696,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "presser" version = "0.3.1" @@ -5866,16 +6155,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "redox_users" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" -dependencies = [ - "libredox", - "thiserror 2.0.20", -] - [[package]] name = "ref-cast" version = "1.0.27" @@ -5896,14 +6175,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "refineable" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" -dependencies = [ - "derive_refineable", -] - [[package]] name = "regex" version = "1.13.1" @@ -5978,23 +6249,6 @@ dependencies = [ "webpki-roots 1.0.9", ] -[[package]] -name = "reqwest_client" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" -dependencies = [ - "anyhow", - "bytes", - "futures", - "gpui_util", - "http_client", - "http_client_tls", - "log", - "regex", - "tokio", - "zed-reqwest", -] - [[package]] name = "resvg" version = "0.46.0" @@ -6279,22 +6533,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scheduler" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" -dependencies = [ - "async-task", - "backtrace", - "chrono", - "flume 0.12.0", - "futures", - "parking_lot", - "rand 0.9.5", - "wasm_thread", - "web-time", -] - [[package]] name = "schemars" version = "1.2.2" @@ -6554,7 +6792,7 @@ version = "1.0.2" dependencies = [ "anyhow", "common", - "gpui", + "gpui-pre", "log", "nostr-sdk", "paste", @@ -6803,7 +7041,7 @@ dependencies = [ "data-encoding", "flume 0.11.1", "futures", - "gpui", + "gpui-pre", "gpui_tokio", "instant", "log", @@ -6849,6 +7087,31 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + [[package]] name = "strum" version = "0.28.0" @@ -6876,18 +7139,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "sum_tree" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" -dependencies = [ - "heapless 0.9.3", - "log", - "rayon", - "tracing", - "ztracing", -] - [[package]] name = "sval" version = "2.22.0" @@ -7070,12 +7321,12 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.8.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501336eb7ba9e417300a6a0fa985721065467aa83a6dcf0422a8e43e4c0328fa" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.13.2", - "core-foundation 0.10.1", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -7137,6 +7388,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -7151,7 +7413,8 @@ name = "theme" version = "1.0.2" dependencies = [ "anyhow", - "gpui", + "gpui-base", + "gpui-pre", "log", "schemars", "serde", @@ -7692,7 +7955,9 @@ version = "1.0.2" dependencies = [ "anyhow", "common", - "gpui", + "gpui-base", + "gpui-pre", + "gpui-pre-sum-tree", "instant", "itertools 0.13.0", "log", @@ -7702,7 +7967,6 @@ dependencies = [ "serde", "smallvec", "smol", - "sum_tree", "theme", "tree-sitter", "unicode-segmentation", @@ -7733,6 +7997,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + [[package]] name = "unicode-ident" version = "1.0.25" @@ -7907,16 +8177,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "util_macros" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" -dependencies = [ - "perf", - "quote", - "syn 2.0.119", -] - [[package]] name = "uuid" version = "1.26.1" @@ -8120,7 +8380,8 @@ dependencies = [ [[package]] name = "wasm_thread" version = "0.3.3" -source = "git+https://github.com/zed-industries/wasm_thread?rev=0cf96c7708dfb97ccf3da50347e25edcf75d6937#0cf96c7708dfb97ccf3da50347e25edcf75d6937" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" dependencies = [ "futures", "js-sys", @@ -9095,7 +9356,7 @@ dependencies = [ "chat_ui", "common", "device", - "gpui", + "gpui-pre", "instant", "log", "nostr-connect", @@ -9176,15 +9437,17 @@ checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xim-ctext" version = "0.3.0" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac61a7062c40f3c37b6e82eeeef835d5cc7824b632a72784a89b3963c33284c" dependencies = [ "encoding_rs", ] [[package]] name = "xim-parser" -version = "0.2.1" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcee45f89572d5a65180af3a84e7ddb24f5ea690a6d3aa9de231281544dd7b7" dependencies = [ "bitflags 2.13.2", ] @@ -9213,6 +9476,17 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever", +] + [[package]] name = "xmlwriter" version = "0.1.0" @@ -9374,14 +9648,15 @@ dependencies = [ [[package]] name = "zed-font-kit" version = "0.14.1-zed" -source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3898e450f36f852edda72e3f985c34426042c4951790b23b107f93394f9bff5" dependencies = [ "bitflags 2.13.2", "byteorder", "core-foundation 0.10.1", "core-graphics 0.24.0", "core-text", - "dirs 6.0.0", + "dirs", "dwrote", "float-ord", "freetype-sys", @@ -9395,59 +9670,11 @@ dependencies = [ "yeslogic-fontconfig-sys", ] -[[package]] -name = "zed-reqwest" -version = "0.12.15-zed" -source = "git+https://github.com/zed-industries/reqwest.git?rev=33bc764aa15ff7b200bf7c93bd96e24878d53e14#33bc764aa15ff7b200bf7c93bd96e24878d53e14" -dependencies = [ - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "ipnet", - "js-sys", - "log", - "mime", - "mime_guess", - "once_cell", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-native-certs", - "rustls-pemfile", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "system-configuration", - "tokio", - "tokio-rustls", - "tokio-socks", - "tokio-util", - "tower", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "windows-registry 0.4.0", -] - [[package]] name = "zed-scap" version = "0.0.8-zed" -source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b338d705ae33a43ca00287c11129303a7a0aa57b101b72a1c08c863f698ac8" dependencies = [ "anyhow", "cocoa 0.25.0", @@ -9468,7 +9695,8 @@ dependencies = [ [[package]] name = "zed-xim" version = "0.4.0-zed" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642" dependencies = [ "ahash", "hashbrown 0.14.5", @@ -9584,39 +9812,12 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" -[[package]] -name = "zlog" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" -dependencies = [ - "anyhow", - "chrono", - "collections", - "log", -] - [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "ztracing" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" -dependencies = [ - "tracing", - "tracing-subscriber", - "zlog", - "ztracing_macro", -] - -[[package]] -name = "ztracing_macro" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#4b47ceb9d328035bde5ef99009e674d9fee07fb7" - [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 391680ca..8ced0833 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,14 +9,21 @@ edition = "2024" publish = false [workspace.dependencies] -# GPUI -gpui = { git = "https://github.com/zed-industries/zed" } -gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] } -gpui_linux = { git = "https://github.com/zed-industries/zed" } -gpui_windows = { git = "https://github.com/zed-industries/zed" } -gpui_macos = { git = "https://github.com/zed-industries/zed" } -gpui_tokio = { git = "https://github.com/zed-industries/zed" } -reqwest_client = { git = "https://github.com/zed-industries/zed" } +# GPUI. The `gpui-pre` family is upstream zed's gpui republished unchanged, so these +# aliases keep every `use gpui::..` site as it is while moving off the zed git pin. +gpui = { package = "gpui-pre", version = "0.3.5" } +gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] } +gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" } +gpui_windows = { package = "gpui-pre-windows", version = "0.3.5" } +gpui_macos = { package = "gpui-pre-macos", version = "0.3.5" } +gpui_web = { package = "gpui-pre-web", version = "0.3.5" } +gpui_util = { package = "gpui-pre-util", version = "0.3.5" } +sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" } +reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" } +gpui_tokio = { path = "crates/gpui_tokio" } + +# Unstyled behavior, state, and infrastructure from GPUI Kit +gpui-base = "0.6.1" # Nostr nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/gpui_tokio/Cargo.toml b/crates/gpui_tokio/Cargo.toml new file mode 100644 index 00000000..34d943fa --- /dev/null +++ b/crates/gpui_tokio/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "gpui_tokio" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +gpui.workspace = true +gpui_util.workspace = true +tokio = { version = "1", features = ["rt", "rt-multi-thread"] } diff --git a/crates/gpui_tokio/src/lib.rs b/crates/gpui_tokio/src/lib.rs new file mode 100644 index 00000000..7c2fff3f --- /dev/null +++ b/crates/gpui_tokio/src/lib.rs @@ -0,0 +1,102 @@ +//! Vendored from zed's `crates/gpui_tokio` (Apache-2.0) because the `gpui-pre` family +//! does not republish it, and `nostr-sdk`'s reqwest client needs a Tokio runtime. + +use std::future::Future; + +use gpui::{App, AppContext, Global, ReadGlobal, Task}; +use gpui_util::defer; +pub use tokio::task::JoinError; + +/// Initializes the Tokio wrapper using a new Tokio runtime with 2 worker threads. +/// +/// If you need more threads (or access to the runtime outside of GPUI), you can create the runtime +/// yourself and pass a Handle to `init_from_handle`. +pub fn init(cx: &mut App) { + let runtime = tokio::runtime::Builder::new_multi_thread() + // Since we now have two executors, let's try to keep our footprint small + .worker_threads(2) + .enable_all() + .build() + .expect("Failed to initialize Tokio"); + + let handle = runtime.handle().clone(); + cx.set_global(GlobalTokio { + owned_runtime: Some(runtime), + handle, + }); +} + +/// Initializes the Tokio wrapper using a Tokio runtime handle. +pub fn init_from_handle(cx: &mut App, handle: tokio::runtime::Handle) { + cx.set_global(GlobalTokio { + owned_runtime: None, + handle, + }); +} + +struct GlobalTokio { + owned_runtime: Option, + handle: tokio::runtime::Handle, +} + +impl Global for GlobalTokio {} + +impl Drop for GlobalTokio { + fn drop(&mut self) { + if let Some(runtime) = self.owned_runtime.take() { + runtime.shutdown_background(); + } + } +} + +pub struct Tokio {} + +impl Tokio { + /// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task + /// Note that the Tokio task will be cancelled if the GPUI task is dropped + pub fn spawn(cx: &C, f: Fut) -> Task> + where + C: AppContext, + Fut: Future + Send + 'static, + R: Send + 'static, + { + cx.read_global(|tokio: &GlobalTokio, cx| { + let join_handle = tokio.handle.spawn(f); + let abort_handle = join_handle.abort_handle(); + let cancel = defer(move || { + abort_handle.abort(); + }); + cx.background_spawn(async move { + let result = join_handle.await; + drop(cancel); + result + }) + }) + } + + /// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task + /// Note that the Tokio task will be cancelled if the GPUI task is dropped + pub fn spawn_result(cx: &C, f: Fut) -> Task> + where + C: AppContext, + Fut: Future> + Send + 'static, + R: Send + 'static, + { + cx.read_global(|tokio: &GlobalTokio, cx| { + let join_handle = tokio.handle.spawn(f); + let abort_handle = join_handle.abort_handle(); + let cancel = defer(move || { + abort_handle.abort(); + }); + cx.background_spawn(async move { + let result = join_handle.await?; + drop(cancel); + result + }) + }) + } + + pub fn handle(cx: &App) -> tokio::runtime::Handle { + GlobalTokio::global(cx).handle.clone() + } +} diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index 80e07c12..314f8d0e 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -6,6 +6,7 @@ publish.workspace = true [dependencies] gpui.workspace = true +gpui-base.workspace = true anyhow.workspace = true log.workspace = true serde.workspace = true diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 830901ac..778a996e 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -46,6 +46,64 @@ pub fn init(cx: &mut App) { Theme::sync_scrollbar_appearance(cx); } +/// Mirror the active coop theme into the `gpui-base` global theme. +/// +/// Base paints a few things from its own tokens -- the focus ring, the wash +/// behind selected text, scrollbars, and overlay backdrops -- so the two +/// globals have to agree or those details drift away from the palette. +/// +/// Only roles base can act on are projected. Radius, spacing, typography sizes, +/// shadows, and scrollbar geometry keep their base defaults: coop has a single +/// `radius`/`radius_lg`/`font_size` where base has six-point scales, so any +/// mapping would be invented rather than derived. Revisit when a base component +/// is actually rendered. +/// +/// This is a no-op before the coop theme global exists; [`Theme::change`] is the +/// authoritative hook that keeps the projection current. +pub fn sync_base(cx: &mut App) { + let Some(theme) = cx.try_global::() else { + return; + }; + + let appearance = if theme.mode.is_dark() { + gpui_base::ThemeAppearance::Dark + } else { + gpui_base::ThemeAppearance::Light + }; + let scrollbar_mode = match theme.scrollbar_mode { + ScrollbarMode::Scrolling => gpui_base::ScrollbarMode::Scrolling, + ScrollbarMode::Hover => gpui_base::ScrollbarMode::Hover, + ScrollbarMode::Always => gpui_base::ScrollbarMode::Always, + }; + let colors = theme.colors; + let font_family = theme.font_family.clone(); + + let base = gpui_base::Theme::global_mut(cx); + base.appearance = appearance; + base.scrollbar = base.scrollbar.clone().with_mode(scrollbar_mode); + base.tokens.typography.sans = font_family; + + let tokens = &mut base.tokens.colors; + tokens.background = colors.background; + tokens.foreground = colors.text; + tokens.surface = colors.surface_background; + tokens.surface_foreground = colors.text; + tokens.primary = colors.element_background; + tokens.primary_foreground = colors.element_foreground; + tokens.secondary = colors.secondary_background; + tokens.secondary_foreground = colors.secondary_foreground; + tokens.muted = colors.ghost_element_background_alt; + tokens.muted_foreground = colors.text_muted; + tokens.accent = colors.ghost_element_hover; + tokens.accent_foreground = colors.text; + tokens.destructive = colors.danger_background; + tokens.destructive_foreground = colors.danger_foreground; + tokens.border = colors.border; + tokens.input = colors.border; + tokens.ring = colors.ring; + tokens.selection = colors.selection; +} + pub trait ActiveTheme { fn theme(&self) -> &Theme; } @@ -183,6 +241,9 @@ impl Theme { if let Some(window) = window { window.refresh(); } + + // Keep the base-layer projection in step with the coop palette + sync_base(cx); } } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index ce16ba98..2a612f33 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -9,6 +9,7 @@ common = { path = "../common" } theme = { path = "../theme" } gpui.workspace = true +gpui-base.workspace = true instant.workspace = true serde.workspace = true smallvec.workspace = true @@ -21,7 +22,7 @@ uuid = "1.10" regex = "1" lsp-types = "0.97.0" ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] } -sum_tree = { git = "https://github.com/zed-industries/zed" } +sum_tree.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] smol.workspace = true diff --git a/crates/ui/src/checkbox.rs b/crates/ui/src/checkbox.rs deleted file mode 100644 index 1ce8e19e..00000000 --- a/crates/ui/src/checkbox.rs +++ /dev/null @@ -1,312 +0,0 @@ -use std::rc::Rc; -use instant::Duration; - -use gpui::prelude::FluentBuilder as _; -use gpui::{ - div, px, relative, rems, svg, Animation, AnimationExt, AnyElement, App, Div, ElementId, - InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, - StatefulInteractiveElement, StyleRefinement, Styled, Window, -}; -use theme::ActiveTheme; - -use crate::icon::IconNamed; -use crate::{v_flex, Disableable, IconName, Selectable, Sizable, Size, StyledExt as _}; - -/// A Checkbox element. -#[allow(clippy::type_complexity)] -#[derive(IntoElement)] -pub struct Checkbox { - id: ElementId, - base: Div, - style: StyleRefinement, - label: Option, - children: Vec, - checked: bool, - disabled: bool, - size: Size, - tab_stop: bool, - tab_index: isize, - on_click: Option>, -} - -impl Checkbox { - /// Create a new Checkbox with the given id. - pub fn new(id: impl Into) -> Self { - Self { - id: id.into(), - base: div(), - style: StyleRefinement::default(), - label: None, - children: Vec::new(), - checked: false, - disabled: false, - size: Size::default(), - on_click: None, - tab_stop: true, - tab_index: 0, - } - } - - /// Set the label for the checkbox. - pub fn label(mut self, label: impl Into) -> Self { - self.label = Some(label.into()); - self - } - - /// Set the checked state for the checkbox. - pub fn checked(mut self, checked: bool) -> Self { - self.checked = checked; - self - } - - /// Set the click handler for the checkbox. - /// - /// The `&bool` parameter indicates the new checked state after the click. - pub fn on_click(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { - self.on_click = Some(Rc::new(handler)); - self - } - - /// Set the tab stop for the checkbox, default is true. - pub fn tab_stop(mut self, tab_stop: bool) -> Self { - self.tab_stop = tab_stop; - self - } - - /// Set the tab index for the checkbox, default is 0. - pub fn tab_index(mut self, tab_index: isize) -> Self { - self.tab_index = tab_index; - self - } - - #[allow(clippy::type_complexity)] - fn handle_click( - on_click: &Option>, - checked: bool, - window: &mut Window, - cx: &mut App, - ) { - let new_checked = !checked; - if let Some(f) = on_click { - (f)(&new_checked, window, cx); - } - } -} - -impl InteractiveElement for Checkbox { - fn interactivity(&mut self) -> &mut gpui::Interactivity { - self.base.interactivity() - } -} -impl StatefulInteractiveElement for Checkbox {} - -impl Styled for Checkbox { - fn style(&mut self) -> &mut gpui::StyleRefinement { - &mut self.style - } -} - -impl Disableable for Checkbox { - fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } -} - -impl Selectable for Checkbox { - fn selected(self, selected: bool) -> Self { - self.checked(selected) - } - - fn is_selected(&self) -> bool { - self.checked - } -} - -impl ParentElement for Checkbox { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements); - } -} - -impl Sizable for Checkbox { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -pub(crate) fn checkbox_check_icon( - id: ElementId, - size: Size, - checked: bool, - disabled: bool, - window: &mut Window, - cx: &mut App, -) -> impl IntoElement { - let toggle_state = window.use_keyed_state(id, cx, |_, _| checked); - - let color = if disabled { - cx.theme().text.opacity(0.5) - } else { - cx.theme().text - }; - - svg() - .absolute() - .top_px() - .left_px() - .map(|this| match size { - Size::XSmall => this.size_2(), - Size::Small => this.size_2p5(), - Size::Medium => this.size_3(), - Size::Large => this.size_3p5(), - _ => this.size_3(), - }) - .text_color(color) - .map(|this| match checked { - true => this.path(IconName::Check.path()), - _ => this, - }) - .map(|this| { - if !disabled && checked != *toggle_state.read(cx) { - let duration = Duration::from_secs_f64(0.25); - cx.spawn({ - let toggle_state = toggle_state.clone(); - async move |cx| { - cx.background_executor().timer(duration).await; - toggle_state.update(cx, |this, _| *this = checked); - } - }) - .detach(); - - this.with_animation( - ElementId::NamedInteger("toggle".into(), checked as u64), - Animation::new(Duration::from_secs_f64(0.25)), - move |this, delta| { - this.opacity(if checked { 1.0 * delta } else { 1.0 - delta }) - }, - ) - .into_any_element() - } else { - this.into_any_element() - } - }) -} - -impl RenderOnce for Checkbox { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let focus_handle = window - .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) - .read(cx) - .clone(); - - let checked = self.checked; - let radius = cx.theme().radius.min(px(4.)); - - let border_color = if checked { - cx.theme().border_focused - } else { - cx.theme().border - }; - - let color = if self.disabled { - border_color.opacity(0.5) - } else { - border_color - }; - - div().child( - self.base - .id(self.id.clone()) - .when(!self.disabled, |this| { - this.track_focus( - &focus_handle - .tab_stop(self.tab_stop) - .tab_index(self.tab_index), - ) - }) - .h_flex() - .gap_2() - .items_start() - .line_height(relative(1.)) - .text_color(cx.theme().text) - .map(|this| match self.size { - Size::XSmall => this.text_xs(), - Size::Small => this.text_sm(), - Size::Medium => this.text_base(), - Size::Large => this.text_lg(), - _ => this, - }) - .when(self.disabled, |this| this.text_color(cx.theme().text_muted)) - .rounded(cx.theme().radius * 0.5) - .refine_style(&self.style) - .child( - div() - .relative() - .map(|this| match self.size { - Size::XSmall => this.size_3(), - Size::Small => this.size_3p5(), - Size::Medium => this.size_4(), - Size::Large => this.size(rems(1.125)), - _ => this.size_4(), - }) - .flex_shrink_0() - .border_1() - .border_color(color) - .rounded(radius) - .when(cx.theme().shadow && !self.disabled, |this| this.shadow_xs()) - .map(|this| match checked { - false => this.bg(cx.theme().background), - _ => this.bg(color), - }) - .child(checkbox_check_icon( - self.id, - self.size, - checked, - self.disabled, - window, - cx, - )), - ) - .when(self.label.is_some() || !self.children.is_empty(), |this| { - this.child( - v_flex() - .w_full() - .line_height(relative(1.2)) - .gap_1() - .map(|this| { - if let Some(label) = self.label { - this.child( - div() - .size_full() - .text_color(cx.theme().text) - .when(self.disabled, |this| { - this.text_color(cx.theme().text_muted) - }) - .line_height(relative(1.)) - .child(label), - ) - } else { - this - } - }) - .children(self.children), - ) - }) - .on_mouse_down(gpui::MouseButton::Left, |_, window, _| { - // Avoid focus on mouse down. - window.prevent_default(); - }) - .when(!self.disabled, |this| { - this.on_click({ - let on_click = self.on_click.clone(); - move |_, window, cx| { - window.prevent_default(); - Self::handle_click(&on_click, checked, window, cx); - } - }) - }), - ) - } -} diff --git a/crates/ui/src/element_ext.rs b/crates/ui/src/element_ext.rs deleted file mode 100644 index 90de5ec6..00000000 --- a/crates/ui/src/element_ext.rs +++ /dev/null @@ -1,27 +0,0 @@ -use gpui::{canvas, App, Bounds, ParentElement, Pixels, Styled as _, Window}; - -/// A trait to extend [`gpui::Element`] with additional functionality. -pub trait ElementExt: ParentElement + Sized { - /// Add a prepaint callback to the element. - /// - /// This is a helper method to get the bounds of the element after paint. - /// - /// The first argument is the bounds of the element in pixels. - /// - /// See also [`gpui::canvas`]. - fn on_prepaint(self, f: F) -> Self - where - F: FnOnce(Bounds, &mut Window, &mut App) + 'static, - { - self.child( - canvas( - move |bounds, window, cx| f(bounds, window, cx), - |_, _, _, _| {}, - ) - .absolute() - .size_full(), - ) - } -} - -impl ElementExt for T {} diff --git a/crates/ui/src/event.rs b/crates/ui/src/event.rs deleted file mode 100644 index 5cd2035c..00000000 --- a/crates/ui/src/event.rs +++ /dev/null @@ -1,21 +0,0 @@ -use gpui::{App, ClickEvent, InteractiveElement, Stateful, Window}; - -pub trait InteractiveElementExt: InteractiveElement { - /// Set the listener for a double click event. - fn on_double_click( - mut self, - listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self - where - Self: Sized, - { - self.interactivity().on_click(move |event, window, cx| { - if event.click_count() == 2 { - listener(event, window, cx); - } - }); - self - } -} - -impl InteractiveElementExt for Stateful {} diff --git a/crates/ui/src/focusable.rs b/crates/ui/src/focusable.rs deleted file mode 100644 index 7ecd8439..00000000 --- a/crates/ui/src/focusable.rs +++ /dev/null @@ -1,39 +0,0 @@ -use gpui::{Context, FocusHandle, Window}; - -/// A trait for views that can cycle focus between its children. -/// -/// This will provide a default implementation for the `cycle_focus` method that will cycle focus. -/// -/// You should implement the `cycle_focus_handles` method to return a list of focus handles that -/// should be cycled, and the cycle will follow the order of the list. -pub trait FocusableCycle { - /// Returns a list of focus handles that should be cycled. - fn cycle_focus_handles(&self, window: &mut Window, cx: &mut Context) -> Vec - where - Self: Sized; - - /// Cycles focus between the focus handles returned by `cycle_focus_handles`. - /// If `is_next` is `true`, it will cycle to the next focus handle, otherwise it will cycle to prev. - fn cycle_focus(&self, is_next: bool, window: &mut Window, cx: &mut Context) - where - Self: Sized, - { - let focused_handle = window.focused(cx); - let handles = self.cycle_focus_handles(window, cx); - let handles = if is_next { - handles - } else { - handles.into_iter().rev().collect() - }; - - let fallback_handle = handles[0].clone(); - let target_focus_handle = handles - .into_iter() - .skip_while(|handle| Some(handle) != focused_handle.as_ref()) - .nth(1) - .unwrap_or(fallback_handle); - - target_focus_handle.focus(window, cx); - cx.stop_propagation(); - } -} diff --git a/crates/ui/src/index_path.rs b/crates/ui/src/index_path.rs deleted file mode 100644 index 987412eb..00000000 --- a/crates/ui/src/index_path.rs +++ /dev/null @@ -1,69 +0,0 @@ -use std::fmt::{Debug, Display}; - -use gpui::ElementId; - -/// Represents an index path in a list, which consists of a section index, -/// -/// The default values for section, row, and column are all set to 0. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct IndexPath { - /// The section index. - pub section: usize, - /// The item index in the section. - pub row: usize, - /// The column index. - pub column: usize, -} - -impl From for ElementId { - fn from(path: IndexPath) -> Self { - ElementId::Name(format!("index-path({},{},{})", path.section, path.row, path.column).into()) - } -} - -impl Display for IndexPath { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "IndexPath(section: {}, row: {}, column: {})", - self.section, self.row, self.column - ) - } -} - -impl IndexPath { - /// Create a new index path with the specified section and row. - /// - /// The `section` is set to 0 by default. - /// The `column` is set to 0 by default. - pub fn new(row: usize) -> Self { - IndexPath { - section: 0, - row, - ..Default::default() - } - } - - /// Set the section for the index path. - pub fn section(mut self, section: usize) -> Self { - self.section = section; - self - } - - /// Set the row for the index path. - pub fn row(mut self, row: usize) -> Self { - self.row = row; - self - } - - /// Set the column for the index path. - pub fn column(mut self, column: usize) -> Self { - self.column = column; - self - } - - /// Check if the self is equal to the given index path (Same section and row). - pub fn eq_row(&self, index: IndexPath) -> bool { - self.section == index.section && self.row == index.row - } -} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 2cadb085..114becd9 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,8 +1,5 @@ -pub use element_ext::ElementExt; -pub use event::InteractiveElementExt; -pub use focusable::FocusableCycle; +pub use gpui_base::{ElementExt, IndexPath, InteractiveElementExt}; pub use icon::*; -pub use index_path::IndexPath; pub use kbd::*; pub use root::{Root, window_paddings}; pub use styled::*; @@ -15,14 +12,12 @@ pub mod actions; pub mod animation; pub mod avatar; pub mod button; -pub mod checkbox; pub mod divider; pub mod dock; pub mod group_box; pub mod history; pub mod indicator; pub mod input; -pub mod list; pub mod menu; pub mod modal; pub mod notification; @@ -34,11 +29,7 @@ pub mod switch; pub mod tab; pub mod tooltip; -mod element_ext; -mod event; -mod focusable; mod icon; -mod index_path; mod kbd; mod root; mod styled; @@ -50,8 +41,9 @@ mod window_ext; /// This must be called before using any of the UI components. /// You can initialize the UI module at your application's entry point. pub fn init(cx: &mut gpui::App) { + gpui_base::init(cx); + theme::sync_base(cx); input::init(cx); - list::init(cx); modal::init(cx); popover::init(cx); menu::init(cx); diff --git a/crates/ui/src/list/cache.rs b/crates/ui/src/list/cache.rs deleted file mode 100644 index 3de7a8c1..00000000 --- a/crates/ui/src/list/cache.rs +++ /dev/null @@ -1,221 +0,0 @@ -use std::rc::Rc; - -use gpui::{App, Pixels, Size}; - -use crate::IndexPath; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum RowEntry { - Entry(IndexPath), - SectionHeader(usize), - SectionFooter(usize), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(crate) struct MeasuredEntrySize { - pub(crate) item_size: Size, - pub(crate) section_header_size: Size, - pub(crate) section_footer_size: Size, -} - -impl RowEntry { - #[inline] - #[allow(unused)] - pub(crate) fn is_section_header(&self) -> bool { - matches!(self, RowEntry::SectionHeader(_)) - } - - pub(crate) fn eq_index_path(&self, path: &IndexPath) -> bool { - match self { - RowEntry::Entry(index_path) => index_path == path, - RowEntry::SectionHeader(_) | RowEntry::SectionFooter(_) => false, - } - } - - #[allow(unused)] - pub(crate) fn index(&self) -> IndexPath { - match self { - RowEntry::Entry(index_path) => *index_path, - RowEntry::SectionHeader(ix) => IndexPath::default().section(*ix), - RowEntry::SectionFooter(ix) => IndexPath::default().section(*ix), - } - } - - #[inline] - #[allow(unused)] - pub(crate) fn is_section_footer(&self) -> bool { - matches!(self, RowEntry::SectionFooter(_)) - } - - #[inline] - pub(crate) fn is_entry(&self) -> bool { - matches!(self, RowEntry::Entry(_)) - } - - #[inline] - #[allow(unused)] - pub(crate) fn section_ix(&self) -> Option { - match self { - RowEntry::SectionHeader(ix) | RowEntry::SectionFooter(ix) => Some(*ix), - _ => None, - } - } -} - -#[derive(Default, Clone)] -pub(crate) struct RowsCache { - /// Only have section's that have rows. - pub(crate) entities: Rc>, - pub(crate) items_count: usize, - /// The sections, the item is number of rows in each section. - pub(crate) sections: Rc>, - pub(crate) entries_sizes: Rc>>, - measured_size: MeasuredEntrySize, -} - -impl RowsCache { - pub(crate) fn get(&self, flatten_ix: usize) -> Option { - self.entities.get(flatten_ix).cloned() - } - - /// Returns the number of flattened rows (Includes header, item, footer). - pub(crate) fn len(&self) -> usize { - self.entities.len() - } - - /// Return the number of items in the cache. - pub(crate) fn items_count(&self) -> usize { - self.items_count - } - - /// Returns the index of the Entry with given path in the flattened rows. - pub(crate) fn position_of(&self, path: &IndexPath) -> Option { - self.entities - .iter() - .position(|p| p.is_entry() && p.eq_index_path(path)) - } - - /// Return prev row, if the row is the first in the first section, goes to the last row. - /// - /// Empty rows section are skipped. - pub(crate) fn prev(&self, path: Option) -> IndexPath { - let path = path.unwrap_or_default(); - let Some(pos) = self.position_of(&path) else { - return self - .entities - .iter() - .rfind(|entry| entry.is_entry()) - .map(|entry| entry.index()) - .unwrap_or_default(); - }; - - if let Some(path) = self - .entities - .iter() - .take(pos) - .rev() - .find(|entry| entry.is_entry()) - .map(|entry| entry.index()) - { - path - } else { - self.entities - .iter() - .rfind(|entry| entry.is_entry()) - .map(|entry| entry.index()) - .unwrap_or_default() - } - } - - /// Returns the next row, if the row is the last in the last section, goes to the first row. - /// - /// Empty rows section are skipped. - pub(crate) fn next(&self, path: Option) -> IndexPath { - let Some(mut path) = path else { - return IndexPath::default(); - }; - - let Some(pos) = self.position_of(&path) else { - return self - .entities - .iter() - .find(|entry| entry.is_entry()) - .map(|entry| entry.index()) - .unwrap_or_default(); - }; - - if let Some(next_path) = self - .entities - .iter() - .skip(pos + 1) - .find(|entry| entry.is_entry()) - .map(|entry| entry.index()) - { - path = next_path; - } else { - path = self - .entities - .iter() - .find(|entry| entry.is_entry()) - .map(|entry| entry.index()) - .unwrap_or_default() - } - - path - } - - pub(crate) fn prepare_if_needed( - &mut self, - sections_count: usize, - measured_size: MeasuredEntrySize, - cx: &App, - rows_count_f: F, - ) where - F: Fn(usize, &App) -> usize, - { - let mut new_sections = vec![]; - for section_ix in 0..sections_count { - new_sections.push(rows_count_f(section_ix, cx)); - } - - let need_update = new_sections != *self.sections || self.measured_size != measured_size; - - if !need_update { - return; - } - - let mut entries_sizes = vec![]; - let mut total_items_count = 0; - self.measured_size = measured_size; - self.sections = Rc::new(new_sections); - self.entities = Rc::new( - self.sections - .iter() - .enumerate() - .flat_map(|(section, items_count)| { - total_items_count += items_count; - let mut children = vec![]; - if *items_count == 0 { - return children; - } - - children.push(RowEntry::SectionHeader(section)); - entries_sizes.push(measured_size.section_header_size); - for row in 0..*items_count { - children.push(RowEntry::Entry(IndexPath { - section, - row, - ..Default::default() - })); - entries_sizes.push(measured_size.item_size); - } - children.push(RowEntry::SectionFooter(section)); - entries_sizes.push(measured_size.section_footer_size); - children - }) - .collect(), - ); - self.entries_sizes = Rc::new(entries_sizes); - self.items_count = total_items_count; - } -} diff --git a/crates/ui/src/list/delegate.rs b/crates/ui/src/list/delegate.rs deleted file mode 100644 index 2899d2ff..00000000 --- a/crates/ui/src/list/delegate.rs +++ /dev/null @@ -1,171 +0,0 @@ -use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Task, Window}; -use theme::ActiveTheme; - -use crate::list::loading::Loading; -use crate::list::ListState; -use crate::{h_flex, Icon, IconName, IndexPath, Selectable}; - -/// A delegate for the List. -#[allow(unused)] -pub trait ListDelegate: Sized + 'static { - type Item: Selectable + IntoElement; - - /// When Query Input change, this method will be called. - /// You can perform search here. - fn perform_search( - &mut self, - query: &str, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - Task::ready(()) - } - - /// Return the number of sections in the list, default is 1. - /// - /// Min value is 1. - fn sections_count(&self, cx: &App) -> usize { - 1 - } - - /// Return the number of items in the section at the given index. - /// - /// NOTE: Only the sections with items_count > 0 will be rendered. If the section has 0 items, - /// the section header and footer will also be skipped. - fn items_count(&self, section: usize, cx: &App) -> usize; - - /// Render the item at the given index. - /// - /// Return None will skip the item. - /// - /// NOTE: Every item should have same height. - fn render_item( - &mut self, - ix: IndexPath, - window: &mut Window, - cx: &mut Context>, - ) -> Option; - - /// Render the section header at the given index, default is None. - /// - /// NOTE: Every header should have same height. - fn render_section_header( - &mut self, - section: usize, - window: &mut Window, - cx: &mut Context>, - ) -> Option { - None:: - } - - /// Render the section footer at the given index, default is None. - /// - /// NOTE: Every footer should have same height. - fn render_section_footer( - &mut self, - section: usize, - window: &mut Window, - cx: &mut Context>, - ) -> Option { - None:: - } - - /// Return a Element to show when list is empty. - fn render_empty( - &mut self, - window: &mut Window, - cx: &mut Context>, - ) -> impl IntoElement { - h_flex() - .size_full() - .justify_center() - .text_color(cx.theme().text_muted.opacity(0.6)) - .child(Icon::new(IconName::Inbox).size_12()) - .into_any_element() - } - - /// Returns Some(AnyElement) to render the initial state of the list. - /// - /// This can be used to show a view for the list before the user has - /// interacted with it. - /// - /// For example: The last search results, or the last selected item. - /// - /// Default is None, that means no initial state. - fn render_initial( - &mut self, - window: &mut Window, - cx: &mut Context>, - ) -> Option { - None - } - - /// Returns the loading state to show the loading view. - fn loading(&self, cx: &App) -> bool { - false - } - - /// Returns a Element to show when loading, default is built-in Skeleton - /// loading view. - fn render_loading( - &mut self, - window: &mut Window, - cx: &mut Context>, - ) -> impl IntoElement { - Loading - } - - /// Set the selected index, just store the ix, don't confirm. - fn set_selected_index( - &mut self, - ix: Option, - window: &mut Window, - cx: &mut Context>, - ); - - /// Set the index of the item that has been right clicked. - fn set_right_clicked_index( - &mut self, - ix: Option, - window: &mut Window, - cx: &mut Context>, - ) { - } - - /// Set the confirm and give the selected index, - /// this is means user have clicked the item or pressed Enter. - /// - /// This will always to `set_selected_index` before confirm. - fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { - } - - /// Cancel the selection, e.g.: Pressed ESC. - fn cancel(&mut self, window: &mut Window, cx: &mut Context>) {} - - /// Return true to enable load more data when scrolling to the bottom. - /// - /// Default: false - fn has_more(&self, cx: &App) -> bool { - false - } - - /// Returns a threshold value (n entities), of course, - /// when scrolling to the bottom, the remaining number of rows - /// triggers `load_more`. - /// - /// This should smaller than the total number of first load rows. - /// - /// Default: 20 entities (section header, footer and row) - fn load_more_threshold(&self) -> usize { - 20 - } - - /// Load more data when the table is scrolled to the bottom. - /// - /// This will performed in a background task. - /// - /// This is always called when the table is near the bottom, - /// so you must check if there is more data to load or lock - /// the loading state. - fn load_more(&mut self, window: &mut Window, cx: &mut Context>) {} -} diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs deleted file mode 100644 index 8b2d4d67..00000000 --- a/crates/ui/src/list/list.rs +++ /dev/null @@ -1,747 +0,0 @@ -use std::ops::Range; - -use gpui::prelude::FluentBuilder; -use gpui::{ - App, AppContext, AvailableSpace, ClickEvent, Context, DefiniteLength, EdgesRefinement, Entity, - EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length, - ListSizingBehavior, MouseButton, ParentElement, Render, RenderOnce, ScrollStrategy, - SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, - UniformListScrollHandle, Window, div, px, size, uniform_list, -}; -use instant::Duration; -use theme::ActiveTheme; - -use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; -use crate::input::{Input, InputEvent, InputState}; -use crate::list::ListDelegate; -use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache}; -use crate::scroll::{Scrollbar, ScrollbarHandle}; -use crate::{Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt, v_flex}; - -pub(crate) fn init(cx: &mut App) { - let context: Option<&str> = Some("List"); - cx.bind_keys([ - KeyBinding::new("escape", Cancel, context), - KeyBinding::new("enter", Confirm { secondary: false }, context), - KeyBinding::new("secondary-enter", Confirm { secondary: true }, context), - KeyBinding::new("up", SelectUp, context), - KeyBinding::new("down", SelectDown, context), - ]); -} - -#[derive(Clone)] -pub enum ListEvent { - /// Move to select item. - Select(IndexPath), - /// Click on item or pressed Enter. - Confirm(IndexPath), - /// Pressed ESC to deselect the item. - Cancel, -} - -struct ListOptions { - size: Size, - scrollbar_visible: bool, - search_placeholder: Option, - max_height: Option, - paddings: EdgesRefinement, -} - -impl Default for ListOptions { - fn default() -> Self { - Self { - size: Size::default(), - scrollbar_visible: true, - max_height: None, - search_placeholder: None, - paddings: EdgesRefinement::default(), - } - } -} - -/// The state for List. -/// -/// List required all items has the same height. -pub struct ListState { - pub(crate) focus_handle: FocusHandle, - pub(crate) query_input: Entity, - options: ListOptions, - delegate: D, - last_query: Option, - scroll_handle: UniformListScrollHandle, - rows_cache: RowsCache, - selected_index: Option, - item_to_measure_index: IndexPath, - deferred_scroll_to_index: Option<(IndexPath, ScrollStrategy)>, - mouse_right_clicked_index: Option, - reset_on_cancel: bool, - searchable: bool, - selectable: bool, - _search_task: Task<()>, - _load_more_task: Task<()>, - _query_input_subscription: Subscription, -} - -impl ListState -where - D: ListDelegate, -{ - pub fn new(delegate: D, window: &mut Window, cx: &mut Context) -> Self { - let query_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search...")); - let _query_input_subscription = - cx.subscribe_in(&query_input, window, Self::on_query_input_event); - - Self { - focus_handle: cx.focus_handle(), - options: ListOptions::default(), - delegate, - rows_cache: RowsCache::default(), - query_input, - last_query: None, - selected_index: None, - selectable: true, - searchable: false, - item_to_measure_index: IndexPath::default(), - deferred_scroll_to_index: None, - mouse_right_clicked_index: None, - scroll_handle: UniformListScrollHandle::new(), - reset_on_cancel: true, - _search_task: Task::ready(()), - _load_more_task: Task::ready(()), - _query_input_subscription, - } - } - - /// Sets whether the list is searchable, default is `false`. - /// - /// When `true`, there will be a search input at the top of the list. - pub fn searchable(mut self, searchable: bool) -> Self { - self.searchable = searchable; - self - } - - pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context) { - self.searchable = searchable; - cx.notify(); - } - - /// Sets whether the list is selectable, default is true. - pub fn selectable(mut self, selectable: bool) -> Self { - self.selectable = selectable; - self - } - - /// Sets whether the list is selectable, default is true. - pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context) { - self.selectable = selectable; - cx.notify(); - } - - pub fn delegate(&self) -> &D { - &self.delegate - } - - pub fn delegate_mut(&mut self) -> &mut D { - &mut self.delegate - } - - /// Focus the list, if the list is searchable, focus the search input. - pub fn focus(&mut self, window: &mut Window, cx: &mut App) { - self.focus_handle(cx).focus(window, cx); - } - - /// Return true if either the list or the search input is focused. - #[allow(dead_code)] - pub(crate) fn is_focused(&self, window: &Window, cx: &App) -> bool { - self.focus_handle.is_focused(window) || self.query_input.focus_handle(cx).is_focused(window) - } - - /// Set the selected index of the list, - /// this will also scroll to the selected item. - pub(crate) fn _set_selected_index( - &mut self, - ix: Option, - window: &mut Window, - cx: &mut Context, - ) { - if !self.selectable { - return; - } - - self.selected_index = ix; - self.delegate.set_selected_index(ix, window, cx); - self.scroll_to_selected_item(window, cx); - } - - /// Set the selected index of the list, - /// this method will not scroll to the selected item. - pub fn set_selected_index( - &mut self, - ix: Option, - window: &mut Window, - cx: &mut Context, - ) { - self.selected_index = ix; - self.delegate.set_selected_index(ix, window, cx); - } - - pub fn selected_index(&self) -> Option { - self.selected_index - } - - /// Set the index of the item that has been right clicked. - pub fn set_right_clicked_index( - &mut self, - ix: Option, - window: &mut Window, - cx: &mut Context, - ) { - self.mouse_right_clicked_index = ix; - self.delegate.set_right_clicked_index(ix, window, cx); - } - - /// Returns the index of the item that has been right clicked. - pub fn right_clicked_index(&self) -> Option { - self.mouse_right_clicked_index - } - - /// Set a specific list item for measurement. - pub fn set_item_to_measure_index( - &mut self, - ix: IndexPath, - _: &mut Window, - cx: &mut Context, - ) { - self.item_to_measure_index = ix; - cx.notify(); - } - - /// Scroll to the item at the given index. - pub fn scroll_to_item( - &mut self, - ix: IndexPath, - strategy: ScrollStrategy, - _: &mut Window, - cx: &mut Context, - ) { - if ix.section == 0 && ix.row == 0 { - // If the item is the first item, scroll to the top. - let mut offset = self.scroll_handle.offset(); - offset.y = px(0.); - self.scroll_handle.set_offset(offset); - cx.notify(); - return; - } - self.deferred_scroll_to_index = Some((ix, strategy)); - cx.notify(); - } - - /// Get scroll handle - pub fn scroll_handle(&self) -> &UniformListScrollHandle { - &self.scroll_handle - } - - pub fn scroll_to_selected_item(&mut self, _: &mut Window, cx: &mut Context) { - if let Some(ix) = self.selected_index { - self.deferred_scroll_to_index = Some((ix, ScrollStrategy::Top)); - cx.notify(); - } - } - - fn on_query_input_event( - &mut self, - state: &Entity, - event: &InputEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - InputEvent::Change => { - let text = state.read(cx).value(); - let text = text.trim().to_string(); - if Some(&text) == self.last_query.as_ref() { - return; - } - - self.set_searching(true, window, cx); - - let search = self.delegate.perform_search(&text, window, cx); - - if self.rows_cache.len() > 0 { - self._set_selected_index(Some(IndexPath::default()), window, cx); - } else { - self._set_selected_index(None, window, cx); - } - - let executor = cx.background_executor().clone(); - self._search_task = cx.spawn_in(window, async move |this, window| { - search.await; - - _ = this.update_in(window, |this, _, _| { - this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); - this.last_query = Some(text); - }); - - // Always wait 100ms to avoid flicker - executor.timer(Duration::from_millis(100)).await; - - _ = this.update_in(window, |this, window, cx| { - this.set_searching(false, window, cx); - }); - }); - } - InputEvent::PressEnter { secondary, .. } => self.on_action_confirm( - &Confirm { - secondary: *secondary, - }, - window, - cx, - ), - _ => {} - } - } - - fn set_searching(&mut self, searching: bool, _window: &mut Window, cx: &mut Context) { - self.query_input - .update(cx, |input, cx| input.set_loading(searching, cx)); - } - - /// Dispatch delegate's `load_more` method when the - /// visible range is near the end. - fn load_more_if_need( - &mut self, - entities_count: usize, - visible_end: usize, - window: &mut Window, - cx: &mut Context, - ) { - // FIXME: Here need void sections items count. - - let threshold = self.delegate.load_more_threshold(); - // Securely handle subtract logic to prevent attempt - // to subtract with overflow - if visible_end >= entities_count.saturating_sub(threshold) { - if !self.delegate.has_more(cx) { - return; - } - - self._load_more_task = cx.spawn_in(window, async move |view, cx| { - _ = view.update_in(cx, |view, window, cx| { - view.delegate.load_more(window, cx); - }); - }); - } - } - - #[allow(dead_code)] - pub(crate) fn reset_on_cancel(mut self, reset: bool) -> Self { - self.reset_on_cancel = reset; - self - } - - fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - cx.propagate(); - if self.reset_on_cancel { - self._set_selected_index(None, window, cx); - } - - self.delegate.cancel(window, cx); - cx.emit(ListEvent::Cancel); - cx.notify(); - } - - fn on_action_confirm( - &mut self, - confirm: &Confirm, - window: &mut Window, - cx: &mut Context, - ) { - if self.rows_cache.len() == 0 { - return; - } - - let Some(ix) = self.selected_index else { - return; - }; - - self.delegate - .set_selected_index(self.selected_index, window, cx); - self.delegate.confirm(confirm.secondary, window, cx); - cx.emit(ListEvent::Confirm(ix)); - cx.notify(); - } - - fn select_item(&mut self, ix: IndexPath, window: &mut Window, cx: &mut Context) { - if !self.selectable { - return; - } - - self.selected_index = Some(ix); - self.delegate.set_selected_index(Some(ix), window, cx); - self.scroll_to_selected_item(window, cx); - cx.emit(ListEvent::Select(ix)); - cx.notify(); - } - - pub(crate) fn on_action_select_prev( - &mut self, - _: &SelectUp, - window: &mut Window, - cx: &mut Context, - ) { - if self.rows_cache.len() == 0 { - return; - } - - let prev_ix = self.rows_cache.prev(self.selected_index); - self.select_item(prev_ix, window, cx); - } - - pub(crate) fn on_action_select_next( - &mut self, - _: &SelectDown, - window: &mut Window, - cx: &mut Context, - ) { - if self.rows_cache.len() == 0 { - return; - } - - let next_ix = self.rows_cache.next(self.selected_index); - self.select_item(next_ix, window, cx); - } - - fn prepare_items_if_needed(&mut self, window: &mut Window, cx: &mut Context) { - let sections_count = self.delegate.sections_count(cx).max(1); - let mut measured_size = MeasuredEntrySize::default(); - - // Measure the item_height and section header/footer height. - let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent); - measured_size.item_size = self - .render_list_item(self.item_to_measure_index, window, cx) - .into_any_element() - .layout_as_root(available_space, window, cx); - - if let Some(mut el) = self - .delegate - .render_section_header(0, window, cx) - .map(|r| r.into_any_element()) - { - measured_size.section_header_size = el.layout_as_root(available_space, window, cx); - } - if let Some(mut el) = self - .delegate - .render_section_footer(0, window, cx) - .map(|r| r.into_any_element()) - { - measured_size.section_footer_size = el.layout_as_root(available_space, window, cx); - } - - self.rows_cache - .prepare_if_needed(sections_count, measured_size, cx, |section_ix, cx| { - self.delegate.items_count(section_ix, cx) - }); - } - - fn render_list_item( - &mut self, - ix: IndexPath, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let selectable = self.selectable; - let selected = self.selected_index.map(|s| s.eq_row(ix)).unwrap_or(false); - let mouse_right_clicked = self - .mouse_right_clicked_index - .map(|s| s.eq_row(ix)) - .unwrap_or(false); - let id = SharedString::from(format!("list-item-{}", ix)); - - div() - .id(id) - .w_full() - .relative() - .overflow_hidden() - .children(self.delegate.render_item(ix, window, cx).map(|item| { - item.selected(selected) - .secondary_selected(mouse_right_clicked) - })) - .when(selectable, |this| { - this.on_click(cx.listener(move |this, e: &ClickEvent, window, cx| { - this.set_right_clicked_index(None, window, cx); - this.selected_index = Some(ix); - this.on_action_confirm( - &Confirm { - secondary: e.modifiers().secondary(), - }, - window, - cx, - ); - })) - .on_mouse_down( - MouseButton::Right, - cx.listener(move |this, _, window, cx| { - this.set_right_clicked_index(Some(ix), window, cx); - cx.notify(); - }), - ) - }) - } - - fn render_items( - &mut self, - items_count: usize, - entities_count: usize, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let rows_cache = self.rows_cache.clone(); - let scrollbar_visible = self.options.scrollbar_visible; - let scroll_handle = self.scroll_handle.clone(); - - v_flex() - .flex_grow_1() - .relative() - .size_full() - .when_some(self.options.max_height, |this, h| this.max_h(h)) - .overflow_hidden() - .when(items_count == 0, |this| { - this.child(self.delegate.render_empty(window, cx)) - }) - .when(items_count > 0, { - |this| { - this.child( - uniform_list( - "virtual-list", - rows_cache.items_count(), - cx.processor(move |this, range: Range, window, cx| { - this.load_more_if_need(entities_count, range.end, window, cx); - - // NOTE: Here the v_virtual_list would not able to have gap_y, - // because the section header, footer is always have rendered as a empty child item, - // even the delegate give a None result. - - range - .map(|ix| { - let Some(entry) = rows_cache.get(ix) else { - return div(); - }; - - div().children(match entry { - RowEntry::Entry(index) => Some( - this.render_list_item(index, window, cx) - .into_any_element(), - ), - RowEntry::SectionHeader(section_ix) => this - .delegate_mut() - .render_section_header(section_ix, window, cx) - .map(|r| r.into_any_element()), - RowEntry::SectionFooter(section_ix) => this - .delegate_mut() - .render_section_footer(section_ix, window, cx) - .map(|r| r.into_any_element()), - }) - }) - .collect::>() - }), - ) - .when(self.options.max_height.is_some(), |this| { - this.with_sizing_behavior(ListSizingBehavior::Infer) - }) - .track_scroll(&scroll_handle) - .into_any_element(), - ) - } - }) - .when(scrollbar_visible, |this| { - this.child(Scrollbar::vertical(&scroll_handle)) - }) - } -} - -impl Focusable for ListState -where - D: ListDelegate, -{ - fn focus_handle(&self, cx: &App) -> FocusHandle { - if self.searchable { - self.query_input.focus_handle(cx) - } else { - self.focus_handle.clone() - } - } -} -impl EventEmitter for ListState where D: ListDelegate {} -impl Render for ListState -where - D: ListDelegate, -{ - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.prepare_items_if_needed(window, cx); - - // Scroll to the selected item if it is set. - if let Some((ix, strategy)) = self.deferred_scroll_to_index.take() - && let Some(item_ix) = self.rows_cache.position_of(&ix) - { - self.scroll_handle.scroll_to_item(item_ix, strategy); - } - - let loading = self.delegate().loading(cx); - let query_input = if self.searchable { - // sync placeholder - if let Some(placeholder) = &self.options.search_placeholder { - self.query_input.update(cx, |input, cx| { - input.set_placeholder(placeholder.clone(), window, cx); - }); - } - Some(self.query_input.clone()) - } else { - None - }; - - let loading_view = if loading { - Some(self.delegate.render_loading(window, cx).into_any_element()) - } else { - None - }; - let initial_view = if let Some(input) = &query_input { - if input.read(cx).value().is_empty() { - self.delegate.render_initial(window, cx) - } else { - None - } - } else { - None - }; - let items_count = self.rows_cache.items_count(); - let entities_count = self.rows_cache.len(); - let mouse_right_clicked_index = self.mouse_right_clicked_index; - - v_flex() - .key_context("List") - .id("list-state") - .track_focus(&self.focus_handle) - .size_full() - .relative() - .overflow_hidden() - .when_some(query_input, |this, input| { - this.child( - div() - .map(|this| match self.options.size { - Size::Small => this.px_1p5(), - _ => this.px_2(), - }) - .border_b_1() - .border_color(cx.theme().border) - .child( - Input::new(&input) - .with_size(self.options.size) - .appearance(false) - .cleanable(true) - .p_0() - .prefix( - Icon::new(IconName::Search).text_color(cx.theme().text_muted), - ), - ), - ) - }) - .when(!loading, |this| { - this.on_action(cx.listener(Self::on_action_cancel)) - .on_action(cx.listener(Self::on_action_confirm)) - .on_action(cx.listener(Self::on_action_select_next)) - .on_action(cx.listener(Self::on_action_select_prev)) - .map(|this| { - if let Some(view) = initial_view { - this.child(view) - } else { - this.child(self.render_items(items_count, entities_count, window, cx)) - } - }) - // Click out to cancel right clicked row - .when(mouse_right_clicked_index.is_some(), |this| { - this.on_mouse_down_out(cx.listener(|this, _, window, cx| { - this.set_right_clicked_index(None, window, cx); - cx.notify(); - })) - }) - }) - .children(loading_view) - } -} - -/// The List element. -#[derive(IntoElement)] -pub struct List { - state: Entity>, - style: StyleRefinement, - options: ListOptions, -} - -impl List -where - D: ListDelegate + 'static, -{ - /// Create a new List element with the given ListState entity. - pub fn new(state: &Entity>) -> Self { - Self { - state: state.clone(), - style: StyleRefinement::default(), - options: ListOptions::default(), - } - } - - /// Set whether the scrollbar is visible, default is `true`. - pub fn scrollbar_visible(mut self, visible: bool) -> Self { - self.options.scrollbar_visible = visible; - self - } - - /// Sets the placeholder text for the search input. - pub fn search_placeholder(mut self, placeholder: impl Into) -> Self { - self.options.search_placeholder = Some(placeholder.into()); - self - } -} - -impl Styled for List -where - D: ListDelegate + 'static, -{ - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl Sizable for List -where - D: ListDelegate + 'static, -{ - fn with_size(mut self, size: impl Into) -> Self { - self.options.size = size.into(); - self - } -} - -impl RenderOnce for List -where - D: ListDelegate + 'static, -{ - fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement { - // Take paddings, max_height to options, and clear them from style, - // because they would be applied to the inner virtual list. - self.options.paddings = self.style.padding.clone(); - self.options.max_height = self.style.max_size.height; - self.style.padding = EdgesRefinement::default(); - self.style.max_size.height = None; - - self.state.update(cx, |state, _| { - state.options = self.options; - }); - - div() - .id("list") - .size_full() - .refine_style(&self.style) - .child(self.state.clone()) - } -} diff --git a/crates/ui/src/list/list_item.rs b/crates/ui/src/list/list_item.rs deleted file mode 100644 index d2d872a7..00000000 --- a/crates/ui/src/list/list_item.rs +++ /dev/null @@ -1,226 +0,0 @@ -use gpui::prelude::FluentBuilder as _; -use gpui::{ - div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, - MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _, - StyleRefinement, Styled, Window, -}; -use smallvec::SmallVec; -use theme::ActiveTheme; - -use crate::{h_flex, Disableable, Icon, Selectable, Sizable as _, StyledExt}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum ListItemMode { - #[default] - Entry, - Separator, -} - -impl ListItemMode { - #[inline] - fn is_separator(&self) -> bool { - matches!(self, ListItemMode::Separator) - } -} - -#[derive(IntoElement)] -pub struct ListItem { - base: Stateful
, - mode: ListItemMode, - style: StyleRefinement, - disabled: bool, - selected: bool, - secondary_selected: bool, - confirmed: bool, - check_icon: Option, - #[allow(clippy::type_complexity)] - on_click: Option>, - #[allow(clippy::type_complexity)] - on_mouse_enter: Option>, - #[allow(clippy::type_complexity)] - suffix: Option AnyElement + 'static>>, - children: SmallVec<[AnyElement; 2]>, -} - -impl ListItem { - pub fn new(id: impl Into) -> Self { - let id: ElementId = id.into(); - Self { - mode: ListItemMode::Entry, - base: h_flex().id(id), - style: StyleRefinement::default(), - disabled: false, - selected: false, - secondary_selected: false, - confirmed: false, - on_click: None, - on_mouse_enter: None, - check_icon: None, - suffix: None, - children: SmallVec::new(), - } - } - - /// Set this list item to as a separator, it not able to be selected. - pub fn separator(mut self) -> Self { - self.mode = ListItemMode::Separator; - self - } - - /// Set to show check icon, default is None. - pub fn check_icon(mut self, icon: impl Into) -> Self { - self.check_icon = Some(icon.into()); - self - } - - /// Set ListItem as the selected item style. - pub fn selected(mut self, selected: bool) -> Self { - self.selected = selected; - self - } - - /// Set ListItem as the confirmed item style, it will show a check icon. - pub fn confirmed(mut self, confirmed: bool) -> Self { - self.confirmed = confirmed; - self - } - - pub fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } - - /// Set the suffix element of the input field, for example a clear button. - pub fn suffix(mut self, builder: F) -> Self - where - F: Fn(&mut Window, &mut App) -> E + 'static, - E: IntoElement, - { - self.suffix = Some(Box::new(move |window, cx| { - builder(window, cx).into_any_element() - })); - self - } - - pub fn on_click( - mut self, - handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_click = Some(Box::new(handler)); - self - } - - pub fn on_mouse_enter( - mut self, - handler: impl Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_mouse_enter = Some(Box::new(handler)); - self - } -} - -impl Disableable for ListItem { - fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } -} - -impl Selectable for ListItem { - fn selected(mut self, selected: bool) -> Self { - self.selected = selected; - self - } - - fn is_selected(&self) -> bool { - self.selected - } - - fn secondary_selected(mut self, selected: bool) -> Self { - self.secondary_selected = selected; - self - } -} - -impl Styled for ListItem { - fn style(&mut self) -> &mut gpui::StyleRefinement { - &mut self.style - } -} - -impl ParentElement for ListItem { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements); - } -} - -impl RenderOnce for ListItem { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let is_active = self.confirmed || self.selected; - - let corner_radii = self.style.corner_radii.clone(); - - let _selected_style = StyleRefinement { - corner_radii, - ..Default::default() - }; - - let is_selectable = !(self.disabled || self.mode.is_separator()); - - self.base - .relative() - .gap_x_1() - .py_1() - .px_3() - .text_base() - .text_color(cx.theme().text) - .relative() - .items_center() - .justify_between() - .refine_style(&self.style) - .when(is_selectable, |this| { - this.when_some(self.on_click, |this, on_click| this.on_click(on_click)) - .when_some(self.on_mouse_enter, |this, on_mouse_enter| { - this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx)) - }) - .when(!is_active, |this| { - this.hover(|this| this.bg(cx.theme().ghost_element_hover)) - }) - }) - .when(!is_selectable, |this| { - this.text_color(cx.theme().text_muted) - }) - .child( - h_flex() - .w_full() - .items_center() - .justify_between() - .gap_x_1() - .child(div().w_full().children(self.children)) - .when_some(self.check_icon, |this, icon| { - this.child( - div() - .w_5() - .items_center() - .justify_center() - .when(self.confirmed, |this| { - this.child(icon.small().text_color(cx.theme().text_muted)) - }), - ) - }), - ) - .when_some(self.suffix, |this, suffix| this.child(suffix(window, cx))) - .map(|this| { - if is_selectable && (self.selected || self.secondary_selected) { - let bg = if self.selected { - cx.theme().ghost_element_active - } else { - cx.theme().ghost_element_background - }; - this.bg(bg) - } else { - this - } - }) - } -} diff --git a/crates/ui/src/list/loading.rs b/crates/ui/src/list/loading.rs deleted file mode 100644 index 9ad64d02..00000000 --- a/crates/ui/src/list/loading.rs +++ /dev/null @@ -1,34 +0,0 @@ -use gpui::{IntoElement, ParentElement as _, RenderOnce, Styled}; - -use super::ListItem; -use crate::skeleton::Skeleton; -use crate::v_flex; - -#[derive(IntoElement)] -pub struct Loading; - -#[derive(IntoElement)] -struct LoadingItem; - -impl RenderOnce for LoadingItem { - fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl IntoElement { - ListItem::new("skeleton").disabled(true).child( - v_flex() - .gap_1p5() - .overflow_hidden() - .child(Skeleton::new().h_5().w_48().max_w_full()) - .child(Skeleton::new().secondary().h_3().w_64().max_w_full()), - ) - } -} - -impl RenderOnce for Loading { - fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl IntoElement { - v_flex() - .py_2p5() - .gap_3() - .child(LoadingItem) - .child(LoadingItem) - .child(LoadingItem) - } -} diff --git a/crates/ui/src/list/mod.rs b/crates/ui/src/list/mod.rs deleted file mode 100644 index 11105c10..00000000 --- a/crates/ui/src/list/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -pub(crate) mod cache; -mod delegate; -#[allow(clippy::module_inception)] -mod list; -mod list_item; -mod loading; -mod separator_item; - -pub use delegate::*; -pub use list::*; -pub use list_item::*; -pub use separator_item::*; -use serde::{Deserialize, Serialize}; - -/// Settings for List. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ListSettings { - /// Whether to use active highlight style on ListItem, default - pub active_highlight: bool, -} - -impl Default for ListSettings { - fn default() -> Self { - Self { - active_highlight: true, - } - } -} diff --git a/crates/ui/src/list/separator_item.rs b/crates/ui/src/list/separator_item.rs deleted file mode 100644 index b419a4e0..00000000 --- a/crates/ui/src/list/separator_item.rs +++ /dev/null @@ -1,50 +0,0 @@ -use gpui::{AnyElement, ParentElement, RenderOnce, StyleRefinement}; -use smallvec::SmallVec; - -use crate::list::ListItem; -use crate::{Selectable, StyledExt}; - -pub struct ListSeparatorItem { - style: StyleRefinement, - children: SmallVec<[AnyElement; 2]>, -} - -impl ListSeparatorItem { - pub fn new() -> Self { - Self { - style: StyleRefinement::default(), - children: SmallVec::new(), - } - } -} - -impl Default for ListSeparatorItem { - fn default() -> Self { - Self::new() - } -} - -impl ParentElement for ListSeparatorItem { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements); - } -} - -impl Selectable for ListSeparatorItem { - fn selected(self, _: bool) -> Self { - self - } - - fn is_selected(&self) -> bool { - false - } -} - -impl RenderOnce for ListSeparatorItem { - fn render(self, _: &mut gpui::Window, _: &mut gpui::App) -> impl gpui::IntoElement { - ListItem::new("separator") - .refine_style(&self.style) - .children(self.children) - .disabled(true) - } -} diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index 92c42469..da7482b4 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -1,4 +1,5 @@ use gpui::{App, DefiniteLength, Div, Edges, Pixels, Refineable, StyleRefinement, Styled, div, px}; +pub use gpui_base::component_traits::{Collapsible, Disableable, Selectable}; use serde::{Deserialize, Serialize}; use theme::ActiveTheme; @@ -110,26 +111,6 @@ impl From for Size { } } -/// A trait for defining element that can be selected. -pub trait Selectable: Sized { - /// Set the selected state of the element. - fn selected(self, selected: bool) -> Self; - - /// Returns true if the element is selected. - fn is_selected(&self) -> bool; - - /// Set is the element mouse right clicked, default do nothing. - fn secondary_selected(self, _: bool) -> Self { - self - } -} - -/// A trait for defining element that can be disabled. -pub trait Disableable { - /// Set the disabled state of the element. - fn disabled(self, disabled: bool) -> Self; -} - /// A trait for setting the size of an element. pub trait Sizable: Sized { /// Set the ui::Size of this element. @@ -267,9 +248,3 @@ impl StyleSized for T { } } } - -/// A trait for defining element that can be collapsed. -pub trait Collapsible { - fn collapsed(self, collapsed: bool) -> Self; - fn is_collapsed(&self) -> bool; -} diff --git a/docs/gpui-base-migration.md b/docs/gpui-base-migration.md index 09bca188..781572d1 100644 --- a/docs/gpui-base-migration.md +++ b/docs/gpui-base-migration.md @@ -9,15 +9,28 @@ The facts below were checked against `gpui-base 0.6.1` (crates.io), the `gpui-ki repository at `main`, and this workspace's `Cargo.lock` (zed at `4b47ceb`, 2026-09-17). Line counts come from `wc -l` under `crates/ui/src`. +## Status + +- **Phase 0: landed.** Manifest only; no Rust changed. The API drift across the + three days between the snapshot and the old pin turned out to be purely + additive, so nothing had to be fixed. +- **Phase 1: landed.** Base is wired in, `sync_base` is in place, and 1,945 lines + of dead weight are gone. `history.rs` moved to phase 2 once it turned out its + only consumer is `input/state.rs`. No dependency became unused, so the pruning + step is a no-op (four dependencies were already unused before this work). +- **Phases 2-5: not started.** +- One pre-existing, unrelated breakage was found; see + [A pre-existing wasm blocker](#a-pre-existing-wasm-blocker). + ## The two facts that shape the work **GPUI still comes from upstream — addressed as the `gpui-pre` package.** `gpui-base` declares its GPUI dependency as `gpui = { package = "gpui-pre", version = "0.3.1" }`: the crate in the graph is the published package `gpui-pre`, and `gpui` is only the name -used in code. That package is upstream zed's gpui (a snapshot of `zed@d89e9c2`) -republished unchanged, so nothing is forked and there is no source to align. Coop -currently pins zed's git repository at `4b47ceb` (2026-09-17), roughly four days ahead -of that snapshot. +used in code. That package is upstream zed's gpui (a snapshot of `zed@d89e9c2`, +published 2026-09-14) republished unchanged, so nothing is forked and there is no +source to align. Coop previously pinned zed's git repository at `4b47ceb` +(2026-09-17), a few days ahead of that snapshot. The two cannot be mixed. Zed's git `gpui` and the `gpui-pre` package are different crates, so `App`, `Window`, `Entity`, and elements from one are not the other's types, @@ -39,9 +52,10 @@ deeper than `ui::::`: | `notification`, `avatar`, `menu`, `scroll`, `group_box`, `indicator`, `switch`, `modal`, `tooltip` | 12 | 16 | | `list`, `checkbox`, `popover`, `resizable`, `skeleton`, `tab`, `divider` (module), `history`, `animation`, `actions` | 0 references | 0 | -`ui::list` and `ui::checkbox` have no consumers at all; the message list in -`crates/chat_ui` uses GPUI's own `list::ListState`. The modules with zero external -references still serve as internal machinery for `dock`, `menu`, `modal`, and `input`. +`ui::list` and `ui::checkbox` had no consumers at all — the message list in +`crates/chat_ui` uses GPUI's own `list::ListState` — so phase 1 deleted both. The other +modules with zero external references still serve as internal machinery for `dock`, +`menu`, `modal`, and `input`. The consequence: this is not a rewrite of an app-facing library. Most of the work is deleting internals and re-expressing a few thousand lines of presentation over base @@ -59,15 +73,36 @@ primitives. - `gpui-component` is not adopted. It is a complete, styled visual language, and taking it would replace the design system rather than preserve it. -Two `Theme` types will exist — `theme::Theme` and `gpui_base::Theme` — as separate GPUI +Two `Theme` types exist — `theme::Theme` and `gpui_base::Theme` — as separate GPUI globals. Coop's stays the application-facing one. Base's is touched in exactly one -place: a `theme::sync_base(cx)` that projects coop's colors into -`gpui_base::Theme::global_mut(cx).tokens` (`SemanticThemeTokens`: colors, radius, -typography, shadow) plus `ThemeAppearance`, `ScrollbarTheme`, and `ResizableTheme`. It -runs from `ui::init` and on every theme change. This is needed because base paints a -few things itself — the focus ring from `FocusableExt`, text selection under glyphs, -scrollbars, resize handles, and the dialog backdrop — and those should follow coop's -palette rather than base's default. +place: `theme::sync_base(cx)`, called from `ui::init` and from `Theme::change` so that +it re-runs on every theme change. It is a no-op before coop's theme global exists, +which is the case when `ui::init` runs ahead of `theme::init`; `Theme::change` is the +hook that actually keeps the projection current. + +It projects the color roles base can act on — the focus ring, the wash under selected +text, scrollbars, and overlay backdrops — and nothing else: + +| `gpui_base::ColorTokens` | coop `ThemeColors` | +| --- | --- | +| `background` / `foreground` | `background` / `text` | +| `surface` / `surface_foreground` | `surface_background` / `text` | +| `primary` / `primary_foreground` | `element_background` / `element_foreground` | +| `secondary` / `secondary_foreground` | `secondary_background` / `secondary_foreground` | +| `muted` / `muted_foreground` | `ghost_element_background_alt` / `text_muted` | +| `accent` / `accent_foreground` | `ghost_element_hover` / `text` | +| `destructive` / `destructive_foreground` | `danger_background` / `danger_foreground` | +| `border` / `input` | `border` | +| `ring` | `ring` | +| `selection` | `selection` | + +It also sets `ThemeAppearance` from coop's mode, `ScrollbarTheme`'s mode from coop's +`scrollbar_mode`, and `TypographyTokens::sans` from coop's `font_family`. Radius, +spacing, typography sizes, shadows, and scrollbar geometry keep their base defaults: +coop has a single `radius`/`radius_lg`/`font_size` where base has six-point scales, so +any mapping would be invented rather than derived. `ResizableTheme` needs nothing — +base's documented `None` fallback already resolves to `border` at rest and `ring` while +dragging, both of which are projected. ## What each module becomes @@ -85,8 +120,9 @@ palette rather than base's default. | `button.rs` | 626 | Skin: base behavior plus coop's existing variant tables | `Button`, `StateStyle` | | `switch.rs` | 287 | Skin | `Switch`, `SwitchTrack`, `SwitchThumb` | | `avatar.rs` | 141 | Skin | `Avatar`, `AvatarImage`, `AvatarFallback` | -| `history.rs`, `index_path.rs`, `element_ext.rs`, `event.rs`, `focusable.rs` | 340 | Delete | `History`/`UndoHistory`, `IndexPath`, `ElementExt`, `InteractiveElementExt`, `FocusableExt`, `FocusTrapElement` | -| `styled.rs`, `actions.rs`, `animation.rs` | 305 | Keep `ui::StyledExt`, `Size`, and `Sizable` as the app's import; base's `h_flex`/`v_flex` helpers are identical (`flex_row` + `items_center`) and can be delegated to | `styled`, `StateStyle` | +| `history.rs` | 184 | Defer to phase 2 | `UndoHistory`, not `History`: base's `History` is navigation (back/forward), while `UndoHistory` is the grouped undo/redo with `max_undos`, `group_interval`, `start_grouping`/`end_grouping`, and `set_ignoring` in place of the fork's `pub(crate) ignore` field. Its only consumer is `input/state.rs`, which phase 2 replaces | +| `index_path.rs`, `element_ext.rs`, `event.rs`, `focusable.rs` | 156 | Delete | `IndexPath`, `ElementExt`, `InteractiveElementExt`. `FocusableCycle` has no counterpart — base's `FocusableExt` is a different concept (whether a component draws a focus ring) — so it is dropped rather than re-based | +| `styled.rs`, `actions.rs`, `animation.rs` | 305 | Keep `ui::StyledExt`, `Size`, and `Sizable` as the app's import. `Selectable`, `Disableable`, and `Collapsible` now come from `gpui_base::component_traits`; the local three-line `h_flex`/`v_flex` wrappers stay rather than delegating to base's identical ones | `styled`, `StateStyle` | | `icon.rs`, `kbd.rs`, `divider.rs`, `skeleton.rs`, `group_box.rs`, `indicator.rs` | 1,023 | Keep; no base equivalent, these are the design system | — | | `menu/` | 2,208 | Keep; base has no menu. Optional later: re-base anchoring and dismissal on `Popup`/`Positioner` | `Popup` (optional) | | `dock/` + `tab/` | 3,356 | Keep for now; see phase 5 | base dock (different contract) | @@ -100,58 +136,99 @@ The workspace manifest's GPUI entries become: ```toml [workspace.dependencies] -gpui = { package = "gpui-pre", version = "0.3.5" } -gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] } -gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" } -gpui_windows = { package = "gpui-pre-windows", version = "0.3.5" } -gpui_macos = { package = "gpui-pre-macos", version = "0.3.5" } -gpui_web = { package = "gpui-pre-web", version = "0.3.5" } -reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" } -sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" } +gpui = { package = "gpui-pre", version = "0.3.5" } +gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] } +gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" } +gpui_windows = { package = "gpui-pre-windows", version = "0.3.5" } +gpui_macos = { package = "gpui-pre-macos", version = "0.3.5" } +gpui_web = { package = "gpui-pre-web", version = "0.3.5" } +gpui_util = { package = "gpui-pre-util", version = "0.3.5" } +reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" } +sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" } +gpui_tokio = { path = "crates/gpui_tokio" } gpui-base = "0.6.1" ``` Because of the `package =` alias, `use gpui::…` and `use gpui_platform::…` keep -compiling unchanged. `gpui_web` moves from `web/Cargo.toml` into the workspace table -with the rest. +compiling unchanged. The aliases match the ones `gpui-pre` uses internally, and +`gpui_web` moved out of `web/Cargo.toml` into this table with the rest. The only alternative — leaving the workspace on zed's git `gpui` and redirecting `gpui-base`'s dependency to it — means vendoring `gpui-base` and owning its source. That is a fork, and this plan deliberately avoids it. -`gpui_tokio` is the one missing piece: longbridge does not republish it, and -`crates/state` uses it in three places (`init`, `spawn`, `spawn_result`). Either vendor -zed's small crate into the workspace, or drop it for `cx.background_spawn`. Decide in -phase 0. +`gpui_tokio` is the one crate in the family longbridge does not republish. `crates/state` +uses it to run `browser-signer-proxy` and `nostr-blossom` work, and the nostr client's +reqwest backend needs a Tokio reactor, so the runtime cannot be dropped for +`cx.background_spawn`. It is vendored verbatim from zed at `4b47ceb` into +`crates/gpui_tokio` (Apache-2.0, ~100 lines), which is the smallest change that keeps +the existing behaviour. `gpui-base` and `gpui-pre` move together on minor versions (`0.6.x` requires `0.3.x`); bump both in the same change. ## Phases -### Phase 0 — move `gpui` onto the `gpui-pre` package (manifest only) +### Phase 0 — move `gpui` onto the `gpui-pre` package (manifest only) — landed -Point the workspace's GPUI entries at the published `gpui-pre` crates and fix whatever -the four days of API drift between `4b47ceb` and `zed@d89e9c2` broke. There is no GPUI -source to align, patch, or vendor. Confirm that the entry points coop calls still exist -in 0.3.5: `gpui_platform::application()`, `web_init()`, and `single_threaded_web()`. +Point the workspace's GPUI entries at the published `gpui-pre` crates. There is no GPUI +source to align, patch, or vendor. -Exit criteria: `cargo check` passes for `desktop` and for -`cargo check -p coop_web --target wasm32-unknown-unknown`, and the drift fixes are -listed in the pull request. The change rewrites the dependency graph, so it stays in a -pull request of its own. +**No drift had to be fixed.** The gap between the snapshot (`zed@d89e9c2`) and the old +pin (`4b47ceb`) is 67 commits, but only 16 touch the GPUI crates, and the public surface +only gained names: `ShapedLineCursor`, `MissingGlyphSink`, `MissingGlyph`, +`FallbackFontClass`, `MEASUREMENT_VERSION`, dynamic font installation, and inspector +registration. Nothing coop used was removed or changed shape, so every `use gpui::…` +compiled unchanged. The three entry points coop calls — +`gpui_platform::application()`, `gpui_platform::web_init()`, and +`gpui_platform::single_threaded_web()` — all exist in 0.3.5. -### Phase 1 — Wire base, delete dead weight (no visual change) +Exit criteria: `cargo check` passes for `desktop`, and the wasm criterion is blocked by +a pre-existing bug unrelated to GPUI — see +[A pre-existing wasm blocker](#a-pre-existing-wasm-blocker). `cargo check -p theme -p ui +--target wasm32-unknown-unknown`, which covers everything this migration touches, +passes. The change rewrites the dependency graph, so it stays in a pull request of its +own. -Add `gpui-base`, make `ui::init` call `gpui_base::init(cx)` followed by -`theme::sync_base(cx)`, and re-export the base utilities the app already imports under -their current names (`ElementExt`, `InteractiveElementExt`, `IndexPath`, `History`, -`Disableable`, `Selectable`). Delete `checkbox.rs` and `list/`, which have no -consumers, along with `history.rs`, `index_path.rs`, `element_ext.rs`, and `event.rs` -once base supplies them. Drop the dependencies this leaves unused. +### Phase 1 — Wire base, delete dead weight (no visual change) — landed -Exit criteria: no diff outside `crates/ui` and `crates/theme`, the app launches, and -switching the theme still restyles everything. +What changed: + +- `crates/ui` and `crates/theme` take `gpui-base`. +- `ui::init` calls `gpui_base::init(cx)` then `theme::sync_base(cx)`; the `list::init(cx)` + call went with `list/`. +- `ui`'s crate root re-exports `ElementExt`, `IndexPath`, and `InteractiveElementExt` + from `gpui_base`, so existing `use ui::{…}` sites are unchanged. In particular + `chat_ui`'s `.on_double_click(…)` is served by base's `InteractiveElementExt`, which + is the same implementation as the fork's. +- `ui::styled` no longer defines `Selectable`, `Disableable`, or `Collapsible`; it + re-exports them from `gpui_base::component_traits`. All three are signature-identical + to the fork's, so the `impl` blocks in `avatar`, `button`, `input`, and the rest + compile untouched. The path is `component_traits` rather than the crate root because + `gpui_base::Collapsible` is base's *component* of that name, not the trait. +- Deleted: `checkbox.rs` (312), `list/` (1,477), `index_path.rs` (69), + `element_ext.rs` (27), `event.rs` (21), `focusable.rs` (39) — 1,945 lines, with no + external consumers and a base counterpart for everything except `FocusableCycle`. + +Two corrections this phase produced: + +- **`history.rs` moved to phase 2.** It maps to base's `UndoHistory`, not `History`: + base's `History` is navigation (back/forward), while `UndoHistory` is the grouped + undo/redo. Swapping it means editing `input/state.rs` — six `ignore` writes become + `set_ignoring`, and `Change` loses its `HistoryItem` impl — which is phase 2's file. +- **No dependency became unused.** `ropey`, `sum_tree`, `lsp-types`, `tree-sitter`, + `regex`, `unicode-segmentation`, `uuid`, and `instant` are all still used by `input/` + and `history.rs`, and the deleted files used none of the others, so pruning happens in + phase 2. Separately, four dependencies — `common`, `anyhow`, `itertools`, and `smol` — + were already unreferenced anywhere in `crates/ui/src` *before* this change. They are + left alone here because removing them is unrelated to the migration. + +Exit criteria: no diff outside `crates/ui` and `crates/theme` — met; the only files +touched are the two manifests, `ui/src/lib.rs`, `ui/src/styled.rs`, and +`theme/src/lib.rs`. `cargo check` and `cargo build` both pass with no warnings, and +`theme` and `ui` still compile for `wasm32-unknown-unknown`. The remaining part of the +acceptance — launching the app and walking the settings dialog and chat panel — has to +be done by hand and has not been run. ### Phase 2 — `input/` (the largest single win, ~6.9k lines) @@ -213,8 +290,10 @@ menu positioning and dismissal on base `Popup`/`Positioner` is optional and late There is no UI test suite to lean on, so each phase gets the same treatment: -- `cargo check` at the workspace root, plus - `cargo check -p coop_web --target wasm32-unknown-unknown` for the web target. +- `cargo check` and `cargo build` at the workspace root. +- `cargo check -p theme -p ui --target wasm32-unknown-unknown`. The web target cannot + be checked end to end until the pre-existing blocker below is fixed, so the migrated + crates are checked directly. - Launch the app and walk the surfaces the phase touched. The settings dialog is the densest single smoke surface (Button, GroupBox, Switch, Input, DropdownMenu, PopupMenuItem), followed by the chat panel and the sidebar. @@ -223,6 +302,22 @@ There is no UI test suite to lean on, so each phase gets the same treatment: because base has no equivalent (`set_loading` is the known candidate), list it in the pull request. +### A pre-existing wasm blocker + +`cargo check -p coop_web --target wasm32-unknown-unknown` fails while compiling +`errno 0.3.14`, which refuses `wasm32-unknown-unknown`. The path is +`coop_web → workspace → browser-signer-proxy → smol → async-io → rustix → errno`, none +of which involves GPUI. `crates/workspace/Cargo.toml` declares `browser-signer-proxy`, +but nothing under `crates/workspace/src` references it; the crate is only used by +`crates/state`, where it is already gated `#[cfg(not(target_arch = "wasm32"))]`. + +Every version on that path (`errno 0.3.14`, `rustix 1.1.5`, `async-io 2.6.0`, +`smol 2.0.2`) is identical before and after phase 0, and no file on it is part of this +work, so the web build was already broken. The remedy is deleting that one stale +dependency line, but that is unrelated to the migration and is deliberately left out. +Until it is done, read the wasm exit criterion for phases 1-4 as "`theme` and `ui` +compile for `wasm32-unknown-unknown`". + ## Risks and non-goals - **Snapshot lag.** The `gpui-pre` package is a republished snapshot, so it trails zed @@ -236,21 +331,24 @@ There is no UI test suite to lean on, so each phase gets the same treatment: - **Two `Theme` globals.** Confine `gpui_base::Theme` to `theme::sync_base` and `crates/ui` internals; application code keeps using `theme::ActiveTheme`. Avoid importing both `Theme` types into one file. -- **`gpui_tokio`** has to be vendored or dropped (phase 0). +- **`gpui_tokio` is vendored, not ours.** `crates/gpui_tokio` is zed's crate kept + verbatim at `crates/gpui_tokio/src/lib.rs` because the `gpui-pre` family does not + publish it and the nostr client needs a Tokio reactor. Re-sync or delete it if + longbridge ever ships an equivalent. - **Non-goals:** adopting `gpui-component`, migrating dock/tab/menu, rewriting the self-contained pieces (`icon`, `kbd`, `divider`, `skeleton`, `group_box`, `indicator`), and changing any color, radius, or spacing value. ## Pull request sequence -| PR | Content | Touches outside `crates/ui` | -| --- | --- | --- | -| 1 | Phase 0: move `gpui` to the `gpui-pre` package, fix drift | `Cargo.toml`, possibly `crates/state` | -| 2 | Phase 1: base wiring, `sync_base`, deletions | none | -| 3 | Phase 2: input | none, or the named gaps above | -| 4 | Phase 3: popover, modal, notification, tooltip | none | -| 5–10 | Phase 4: one leaf module each | none | -| later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | +| PR | Content | Touches outside `crates/ui` | Status | +| --- | --- | --- | --- | +| 1 | Phase 0: `gpui` moves to the `gpui-pre` package, `gpui_tokio` vendored | root `Cargo.toml`, `Cargo.lock`, `web/Cargo.toml`, new `crates/gpui_tokio`; `crates/state` needed no edit | landed | +| 2 | Phase 1: base wiring, `sync_base`, deletions | `crates/theme` | landed | +| 3 | Phase 2: input, plus `history.rs` → `UndoHistory` and the `ropey`/`sum_tree`/… pruning | none, or the named gaps | not started | +| 4 | Phase 3: popover, modal, notification, tooltip | none | not started | +| 5–10 | Phase 4: one leaf module each | none | not started | +| later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | not started | The end state: the application keeps its design system and its call sites, `crates/ui` shrinks by roughly half, and the parts that are genuinely hard — text editing, diff --git a/web/Cargo.toml b/web/Cargo.toml index 69597762..724ec6ae 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -21,7 +21,7 @@ person = { path = "../crates/person" } gpui.workspace = true gpui_platform.workspace = true -gpui_web = { git = "https://github.com/zed-industries/zed" } +gpui_web.workspace = true log.workspace = true instant = { workspace = true, features = ["wasm-bindgen"] } -- 2.54.0 From 75a25a9a9d4bf157cb23dff06f50ef8a5637996f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 17 Sep 2026 16:12:31 +0700 Subject: [PATCH 03/10] migrate input --- Cargo.lock | 32 - crates/chat_ui/src/lib.rs | 8 +- crates/ui/Cargo.toml | 6 - crates/ui/src/history.rs | 184 -- crates/ui/src/input/blink_cursor.rs | 96 - crates/ui/src/input/change.rs | 39 - crates/ui/src/input/cursor.rs | 53 - .../ui/src/input/display_map/display_map.rs | 172 -- crates/ui/src/input/display_map/mod.rs | 7 - .../ui/src/input/display_map/text_wrapper.rs | 582 ----- crates/ui/src/input/display_map/wrap_map.rs | 172 -- crates/ui/src/input/element.rs | 1642 ------------- crates/ui/src/input/indent.rs | 269 --- crates/ui/src/input/input.rs | 281 +-- crates/ui/src/input/mask_pattern.rs | 409 ---- crates/ui/src/input/mod.rs | 22 +- crates/ui/src/input/mode.rs | 145 -- crates/ui/src/input/movement.rs | 264 --- crates/ui/src/input/rope_ext.rs | 456 ---- crates/ui/src/input/selection.rs | 140 -- crates/ui/src/input/state.rs | 2085 ----------------- crates/ui/src/lib.rs | 2 - crates/ui/src/root.rs | 8 - crates/ui/src/window_ext.rs | 15 - crates/workspace/src/panels/profile.rs | 9 +- crates/workspace/src/sidebar/mod.rs | 25 +- docs/gpui-base-migration.md | 112 +- 27 files changed, 219 insertions(+), 7016 deletions(-) delete mode 100644 crates/ui/src/history.rs delete mode 100644 crates/ui/src/input/blink_cursor.rs delete mode 100644 crates/ui/src/input/change.rs delete mode 100644 crates/ui/src/input/cursor.rs delete mode 100644 crates/ui/src/input/display_map/display_map.rs delete mode 100644 crates/ui/src/input/display_map/mod.rs delete mode 100644 crates/ui/src/input/display_map/text_wrapper.rs delete mode 100644 crates/ui/src/input/display_map/wrap_map.rs delete mode 100644 crates/ui/src/input/element.rs delete mode 100644 crates/ui/src/input/indent.rs delete mode 100644 crates/ui/src/input/mask_pattern.rs delete mode 100644 crates/ui/src/input/mode.rs delete mode 100644 crates/ui/src/input/movement.rs delete mode 100644 crates/ui/src/input/rope_ext.rs delete mode 100644 crates/ui/src/input/selection.rs delete mode 100644 crates/ui/src/input/state.rs diff --git a/Cargo.lock b/Cargo.lock index d8ad9f99..f5062a40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7072,12 +7072,6 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" -[[package]] -name = "streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" - [[package]] name = "strict-num" version = "0.1.1" @@ -7872,26 +7866,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "tree-sitter" -version = "0.26.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17ebdd3a5a7e28a1890b876fdbd0c3c0fe0a6336cffaa104f11b9f720c9daa29" -dependencies = [ - "cc", - "regex", - "regex-syntax", - "serde_json", - "streaming-iterator", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-language" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" - [[package]] name = "try-lock" version = "0.2.5" @@ -7957,19 +7931,13 @@ dependencies = [ "common", "gpui-base", "gpui-pre", - "gpui-pre-sum-tree", "instant", "itertools 0.13.0", "log", - "lsp-types", - "regex", - "ropey", "serde", "smallvec", "smol", "theme", - "tree-sitter", - "unicode-segmentation", "uuid", ] diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index ecf0efd1..977aef6b 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -29,7 +29,7 @@ use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::input::{Input, InputEvent, InputState}; +use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use ui::menu::DropdownMenu; use ui::notification::Notification; use ui::scroll::Scrollbar; @@ -85,7 +85,7 @@ pub struct ChatPanel { reports_by_id: Arc>>>, /// Chat input state - input: Entity, + input: Entity, /// Subject input state subject_input: Entity, @@ -142,7 +142,7 @@ impl ChatPanel { // Define input state let input = cx.new(|cx| { - InputState::new(window, cx) + TextareaState::new(window, cx) .placeholder(format!("Message {}", name)) .auto_grow(1, 20) .clean_on_escape() @@ -2108,7 +2108,7 @@ impl Render for ChatPanel { this.upload(window, cx); })), ) - .child(Input::new(&self.input).appearance(false).flex_1()) + .child(Textarea::new(&self.input).appearance(false).flex_1()) .child( h_flex() .pl_1() diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 2a612f33..7ce646ef 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -17,13 +17,7 @@ anyhow.workspace = true itertools.workspace = true log.workspace = true -unicode-segmentation = "1.12.0" uuid = "1.10" -regex = "1" -lsp-types = "0.97.0" -ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] } -sum_tree.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] smol.workspace = true -tree-sitter = "0.26" diff --git a/crates/ui/src/history.rs b/crates/ui/src/history.rs deleted file mode 100644 index 56785f0e..00000000 --- a/crates/ui/src/history.rs +++ /dev/null @@ -1,184 +0,0 @@ -use std::fmt::Debug; -use instant::{Duration, Instant}; - -/// A HistoryItem represents a single change in the history. -/// It must implement Clone and PartialEq to be used in the History. -pub trait HistoryItem: Clone + PartialEq { - fn version(&self) -> usize; - fn set_version(&mut self, version: usize); -} - -/// The History is used to keep track of changes to a model and to allow undo and redo operations. -/// -/// This is now used in Input for undo/redo operations. You can also use this in -/// your own models to keep track of changes, for example to track the tab -/// history for prev/next features. -/// -/// ## Use cases -/// -/// - Undo/redo operations in Input -/// - Tracking tab history for prev/next features -#[derive(Debug)] -pub struct History { - undos: Vec, - redos: Vec, - last_changed_at: Instant, - version: usize, - pub(crate) ignore: bool, - max_undos: usize, - group_interval: Option, - grouping: bool, - unique: bool, -} - -impl History -where - I: HistoryItem, -{ - pub fn new() -> Self { - Self { - undos: Default::default(), - redos: Default::default(), - ignore: false, - last_changed_at: Instant::now(), - version: 0, - max_undos: 1000, - group_interval: None, - grouping: false, - unique: false, - } - } - - /// Set the maximum number of undo steps to keep, defaults to 1000. - pub fn max_undos(mut self, max_undos: usize) -> Self { - self.max_undos = max_undos; - self - } - - /// Set the history to be unique, defaults to false. - /// If set to true, the history will only keep unique changes. - pub fn unique(mut self) -> Self { - self.unique = true; - self - } - - /// Set the interval in milliseconds to group changes, defaults to None. - pub fn group_interval(mut self, group_interval: Duration) -> Self { - self.group_interval = Some(group_interval); - self - } - - /// Start grouping changes, this will prevent the version from being incremented until `end_grouping` is called. - pub fn start_grouping(&mut self) { - self.grouping = true; - } - - /// End grouping changes, this will allow the version to be incremented again. - pub fn end_grouping(&mut self) { - self.grouping = false; - } - - /// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago. - fn inc_version(&mut self) -> usize { - let t = Instant::now(); - if !self.grouping && Some(self.last_changed_at.elapsed()) > self.group_interval { - self.version += 1; - } - - self.last_changed_at = t; - self.version - } - - /// Get the current version number. - pub fn version(&self) -> usize { - self.version - } - - /// Push a new change to the history. - pub fn push(&mut self, item: I) { - let version = self.inc_version(); - - if self.undos.len() >= self.max_undos { - self.undos.remove(0); - } - - if self.unique { - self.undos.retain(|c| *c != item); - self.redos.retain(|c| *c != item); - } - - let mut item = item; - item.set_version(version); - self.undos.push(item); - } - - /// Get the undo stack. - pub fn undos(&self) -> &Vec { - &self.undos - } - - /// Get the redo stack. - pub fn redos(&self) -> &Vec { - &self.redos - } - - /// Clear the undo and redo stacks. - pub fn clear(&mut self) { - self.undos.clear(); - self.redos.clear(); - } - - /// Undo the last change and return the changes that were undone. - pub fn undo(&mut self) -> Option> { - if let Some(first_change) = self.undos.pop() { - let mut changes = vec![first_change.clone()]; - // pick the next all changes with the same version - while self - .undos - .iter() - .filter(|c| c.version() == first_change.version()) - .count() - > 0 - { - let change = self.undos.pop().unwrap(); - changes.push(change); - } - - self.redos.extend(changes.clone()); - Some(changes) - } else { - None - } - } - - /// Redo the last undone change and return the changes that were redone. - pub fn redo(&mut self) -> Option> { - if let Some(first_change) = self.redos.pop() { - let mut changes = vec![first_change.clone()]; - // pick the next all changes with the same version - while self - .redos - .iter() - .filter(|c| c.version() == first_change.version()) - .count() - > 0 - { - let change = self.redos.pop().unwrap(); - changes.push(change); - } - self.undos.extend(changes.clone()); - Some(changes) - } else { - None - } - } -} - -impl Default for History -where - I: HistoryItem, -{ - fn default() -> Self { - Self::new() - } -} diff --git a/crates/ui/src/input/blink_cursor.rs b/crates/ui/src/input/blink_cursor.rs deleted file mode 100644 index a0c2c979..00000000 --- a/crates/ui/src/input/blink_cursor.rs +++ /dev/null @@ -1,96 +0,0 @@ -use instant::Duration; - -use gpui::{Context, Pixels, Task, px}; - -static INTERVAL: Duration = Duration::from_millis(500); -static PAUSE_DELAY: Duration = Duration::from_millis(300); - -// On Windows, Linux, we should use integer to avoid blurry cursor. -#[cfg(not(target_os = "macos"))] -pub(super) const CURSOR_WIDTH: Pixels = px(2.); -#[cfg(target_os = "macos")] -pub(super) const CURSOR_WIDTH: Pixels = px(1.5); - -/// To manage the Input cursor blinking. -/// -/// It will start blinking with a interval of 500ms. -/// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint. -/// -/// The input painter will check if this in visible state, then it will draw the cursor. -pub(crate) struct BlinkCursor { - visible: bool, - paused: bool, - epoch: usize, - - _task: Task<()>, -} - -impl BlinkCursor { - pub fn new() -> Self { - Self { - visible: false, - paused: false, - epoch: 0, - _task: Task::ready(()), - } - } - - /// Start the blinking - pub fn start(&mut self, cx: &mut Context) { - self.blink(self.epoch, cx); - } - - pub fn stop(&mut self, cx: &mut Context) { - self.epoch = 0; - cx.notify(); - } - - fn next_epoch(&mut self) -> usize { - self.epoch += 1; - self.epoch - } - - fn blink(&mut self, epoch: usize, cx: &mut Context) { - if self.paused || epoch != self.epoch { - self.visible = true; - return; - } - - self.visible = !self.visible; - cx.notify(); - - // Schedule the next blink - let epoch = self.next_epoch(); - self._task = cx.spawn(async move |this, cx| { - cx.background_executor().timer(INTERVAL).await; - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| this.blink(epoch, cx)); - } - }); - } - - pub fn visible(&self) -> bool { - // Keep showing the cursor if paused - self.paused || self.visible - } - - /// Pause the blinking, and delay 500ms to resume the blinking. - pub fn pause(&mut self, cx: &mut Context) { - self.paused = true; - self.visible = true; - cx.notify(); - - // delay 500ms to start the blinking - let epoch = self.next_epoch(); - self._task = cx.spawn(async move |this, cx| { - cx.background_executor().timer(PAUSE_DELAY).await; - - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| { - this.paused = false; - this.blink(epoch, cx); - }); - } - }); - } -} diff --git a/crates/ui/src/input/change.rs b/crates/ui/src/input/change.rs deleted file mode 100644 index bd771a9a..00000000 --- a/crates/ui/src/input/change.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::fmt::Debug; - -use crate::{history::HistoryItem, input::Selection}; - -#[derive(Debug, PartialEq, Clone)] -pub struct Change { - pub(crate) old_range: Selection, - pub(crate) old_text: String, - pub(crate) new_range: Selection, - pub(crate) new_text: String, - version: usize, -} - -impl Change { - pub fn new( - old_range: impl Into, - old_text: &str, - new_range: impl Into, - new_text: &str, - ) -> Self { - Self { - old_range: old_range.into(), - old_text: old_text.to_string(), - new_range: new_range.into(), - new_text: new_text.to_string(), - version: 0, - } - } -} - -impl HistoryItem for Change { - fn version(&self) -> usize { - self.version - } - - fn set_version(&mut self, version: usize) { - self.version = version; - } -} diff --git a/crates/ui/src/input/cursor.rs b/crates/ui/src/input/cursor.rs deleted file mode 100644 index e25a7df8..00000000 --- a/crates/ui/src/input/cursor.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::ops::{Range, RangeBounds}; - -/// A selection in the text, represented by start and end byte indices. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] -pub struct Selection { - pub start: usize, - pub end: usize, -} - -impl Selection { - pub fn new(start: usize, end: usize) -> Self { - Self { start, end } - } - - pub fn len(&self) -> usize { - self.end.saturating_sub(self.start) - } - - pub fn is_empty(&self) -> bool { - self.start == self.end - } - - /// Clears the selection, setting start and end to 0. - pub fn clear(&mut self) { - self.start = 0; - self.end = 0; - } - - /// Checks if the given offset is within the selection range. - pub fn contains(&self, offset: usize) -> bool { - offset >= self.start && offset < self.end - } -} - -impl From> for Selection { - fn from(value: Range) -> Self { - Self::new(value.start, value.end) - } -} -impl From for Range { - fn from(value: Selection) -> Self { - value.start..value.end - } -} -impl RangeBounds for Selection { - fn start_bound(&self) -> std::ops::Bound<&usize> { - std::ops::Bound::Included(&self.start) - } - - fn end_bound(&self) -> std::ops::Bound<&usize> { - std::ops::Bound::Excluded(&self.end) - } -} diff --git a/crates/ui/src/input/display_map/display_map.rs b/crates/ui/src/input/display_map/display_map.rs deleted file mode 100644 index affdfa30..00000000 --- a/crates/ui/src/input/display_map/display_map.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::ops::Range; - -use gpui::{App, Font, Pixels}; -use ropey::Rope; - -use super::text_wrapper::{LineItem, WrapDisplayPoint}; -use super::wrap_map::WrapMap; -use crate::input::Point as TreeSitterPoint; - -/// DisplayMap is the main interface for Input coordinate mapping. -pub struct DisplayMap { - wrap_map: WrapMap, -} - -impl DisplayMap { - pub fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { - Self { - wrap_map: WrapMap::new(font, font_size, wrap_width), - } - } - - /// Get total number of display rows (same as wrap rows without folding) - #[inline] - pub fn display_row_count(&self) -> usize { - self.wrap_map.wrap_row_count() - } - - /// Get the buffer line for a given display row - pub fn display_row_to_buffer_line(&self, display_row: usize) -> usize { - self.wrap_map.wrap_row_to_buffer_line(display_row) - } - - /// Get the display row range for a buffer line: [start, end) - pub fn buffer_line_to_display_row_range(&self, line: usize) -> Option> { - let range = self.wrap_map.buffer_line_to_wrap_row_range(line); - if range.is_empty() { None } else { Some(range) } - } - - /// Check if a buffer line is completely hidden (never true without folding) - #[inline] - pub fn is_buffer_line_hidden(&self, _line: usize) -> bool { - false - } - - /// All wrap rows are visible since there's no folding. - #[inline] - pub fn folded_ranges(&self) -> &[()] { - &[] - } - - /// Adjust folds for edit (no-op without folding) - pub fn adjust_folds_for_edit( - &mut self, - _old_text: &Rope, - _range: &Range, - _new_text: &str, - ) { - // No-op: no folding - } - - /// Update text (incremental or full) - pub fn on_text_changed( - &mut self, - changed_text: &Rope, - range: &Range, - new_text: &Rope, - cx: &mut App, - ) { - self.wrap_map - .on_text_changed(changed_text, range, new_text, cx); - } - - /// Update layout parameters (wrap width or font) - pub fn on_layout_changed(&mut self, wrap_width: Option, cx: &mut App) { - self.wrap_map.on_layout_changed(wrap_width, cx); - } - - /// Set font parameters - pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { - self.wrap_map.set_font(font, font_size, cx); - } - - /// Ensure text is prepared (initializes wrapper if needed) - pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) { - self.wrap_map.ensure_text_prepared(text, cx); - } - - /// Initialize with text - pub fn set_text(&mut self, text: &Rope, cx: &mut App) { - self.wrap_map.set_text(text, cx); - } - - /// Convert byte offset to wrap display point (with soft wrap info). - #[inline] - pub(crate) fn offset_to_wrap_display_point(&self, offset: usize) -> WrapDisplayPoint { - self.wrap_map.wrapper().offset_to_display_point(offset) - } - - /// Convert wrap display point to byte offset. - #[inline] - pub(crate) fn wrap_display_point_to_offset(&self, point: WrapDisplayPoint) -> usize { - self.wrap_map.wrapper().display_point_to_offset(point) - } - - /// Convert wrap display point to TreeSitterPoint (buffer line/col). - #[inline] - pub(crate) fn wrap_display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint { - self.wrap_map.wrapper().display_point_to_point(point) - } - - /// Since there's no folding, wrap row == display row. - #[inline] - pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option { - if wrap_row < self.wrap_row_count() { - Some(wrap_row) - } else { - None - } - } - - /// Since there's no folding, nearest visible row is the row itself. - #[inline] - pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize { - wrap_row.min(self.wrap_row_count().saturating_sub(1)) - } - - /// Since there's no folding, display row == wrap row. - #[inline] - pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option { - if display_row < self.wrap_row_count() { - Some(display_row) - } else { - None - } - } - - /// Get the longest row index (by byte length). - #[inline] - pub(crate) fn longest_row(&self) -> usize { - self.wrap_map.wrapper().longest_row.row - } - - /// Get access to line items (for rendering) - #[inline] - pub(crate) fn lines(&self) -> &[LineItem] { - self.wrap_map.lines() - } - - /// Get the rope text - #[inline] - pub fn text(&self) -> &Rope { - self.wrap_map.text() - } - - /// Calculate how many wrap rows of a buffer line are visible - #[inline] - pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize { - self.wrap_map.visible_wrap_row_count_for_buffer_line(line) - } - - /// Get the wrap row count - #[inline] - pub fn wrap_row_count(&self) -> usize { - self.wrap_map.wrap_row_count() - } - - /// Get the buffer line count (logical lines) - #[inline] - pub fn buffer_line_count(&self) -> usize { - self.wrap_map.buffer_line_count() - } -} diff --git a/crates/ui/src/input/display_map/mod.rs b/crates/ui/src/input/display_map/mod.rs deleted file mode 100644 index 486b3e59..00000000 --- a/crates/ui/src/input/display_map/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[allow(clippy::module_inception)] -mod display_map; -mod text_wrapper; -mod wrap_map; - -pub use self::display_map::DisplayMap; -pub(crate) use self::text_wrapper::LineLayout; diff --git a/crates/ui/src/input/display_map/text_wrapper.rs b/crates/ui/src/input/display_map/text_wrapper.rs deleted file mode 100644 index dadb1336..00000000 --- a/crates/ui/src/input/display_map/text_wrapper.rs +++ /dev/null @@ -1,582 +0,0 @@ -use std::ops::Range; - -use gpui::{ - App, Font, Half, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px, - size, -}; -use ropey::Rope; -use smallvec::SmallVec; - -use crate::input::{LastLayout, Point as TreeSitterPoint, RopeExt, WhitespaceIndicators}; - -/// A line with soft wrapped lines info. -#[derive(Debug, Clone)] -pub(crate) struct LineItem { - /// The original line text, without end `\n`. - line: Rope, - /// The soft wrapped lines relative byte range (0..line.len) of this line (Include first line). - /// - /// Not contains the line end `\n`. - pub(crate) wrapped_lines: Vec>, -} - -impl LineItem { - /// Get the bytes length of this line. - #[inline] - pub(crate) fn len(&self) -> usize { - self.line.len() - } - - /// Get number of soft wrapped lines of this line (include the first line). - #[inline] - pub(crate) fn lines_len(&self) -> usize { - self.wrapped_lines.len() - } -} - -#[derive(Debug, Default)] -pub(crate) struct LongestRow { - /// The 0-based row index. - pub row: usize, - /// The bytes length of the longest line. - pub len: usize, -} - -/// Used to prepare the text with soft wrap to be get lines to displayed in the Editor. -/// -/// After use lines to calculate the scroll size of the Editor. -pub(crate) struct TextWrapper { - text: Rope, - /// Total wrapped lines (Inlucde the first line), value is start and end index of the line. - soft_lines: usize, - font: Font, - font_size: Pixels, - /// If is none, it means the text is not wrapped - wrap_width: Option, - /// The longest (row, bytes len) in characters, used to calculate the horizontal scroll width. - pub(crate) longest_row: LongestRow, - /// The lines by split \n - pub(crate) lines: Vec, - - _initialized: bool, -} - -#[allow(unused)] -impl TextWrapper { - pub(crate) fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { - Self { - text: Rope::new(), - font, - font_size, - wrap_width, - soft_lines: 0, - longest_row: LongestRow::default(), - lines: Vec::new(), - _initialized: false, - } - } - - #[inline] - pub(crate) fn set_default_text(&mut self, text: &Rope) { - self.text = text.clone(); - } - - /// Get reference to the rope text. - #[inline] - pub(crate) fn text(&self) -> &Rope { - &self.text - } - - /// Get the total number of lines including wrapped lines. - #[inline] - pub(crate) fn len(&self) -> usize { - self.soft_lines - } - - /// Get the line item by row index. - #[inline] - pub(crate) fn line(&self, row: usize) -> Option<&LineItem> { - self.lines.get(row) - } - - pub(crate) fn set_wrap_width(&mut self, wrap_width: Option, cx: &mut App) { - if wrap_width == self.wrap_width { - return; - } - - self.wrap_width = wrap_width; - self.update_all(&self.text.clone(), cx); - } - - pub(crate) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { - if self.font.eq(&font) && self.font_size == font_size { - return; - } - - self.font = font; - self.font_size = font_size; - self.update_all(&self.text.clone(), cx); - } - - pub(crate) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) -> bool { - if self._initialized { - return false; - } - self._initialized = true; - self.update_all(text, cx); - true - } - - /// Update the text wrapper and recalculate the wrapped lines. - /// - /// If the `text` is the same as the current text, do nothing. - /// - /// - `changed_text`: The text [`Rope`] that has changed. - /// - `range`: The `selected_range` before change. - /// - `new_text`: The inserted text. - /// - `force`: Whether to force the update, if false, the update will be skipped if the text is the same. - /// - `cx`: The application context. - pub(crate) fn update( - &mut self, - changed_text: &Rope, - range: &Range, - new_text: &Rope, - cx: &mut App, - ) { - let mut line_wrapper = cx - .text_system() - .line_wrapper(self.font.clone(), self.font_size); - self._update( - changed_text, - range, - new_text, - &mut |line_str, wrap_width| { - line_wrapper - .wrap_line(&[LineFragment::text(line_str)], wrap_width) - .collect() - }, - ); - } - - fn _update( - &mut self, - changed_text: &Rope, - range: &Range, - new_text: &Rope, - wrap_line: &mut F, - ) where - F: FnMut(&str, Pixels) -> Vec, - { - // Remove the old changed lines. - let start_row = self.text.offset_to_point(range.start).row; - let start_row = start_row.min(self.lines.len().saturating_sub(1)); - let end_row = self.text.offset_to_point(range.end).row; - let end_row = end_row.min(self.lines.len().saturating_sub(1)); - let rows_range = start_row..=end_row; - - if rows_range.contains(&self.longest_row.row) { - self.longest_row = LongestRow::default(); - } - - let mut longest_row_ix = self.longest_row.row; - let mut longest_row_len = self.longest_row.len; - - // To add the new lines. - let new_start_row = changed_text.offset_to_point(range.start).row; - let new_start_offset = changed_text.line_start_offset(new_start_row); - let new_end_row = changed_text - .offset_to_point(range.start + new_text.len()) - .row; - let new_end_offset = changed_text.line_end_offset(new_end_row); - let new_range = new_start_offset..new_end_offset; - - let mut new_lines = vec![]; - let wrap_width = self.wrap_width; - - // line not contains `\n`. - for (ix, line) in Rope::from(changed_text.slice(new_range)) - .iter_lines() - .enumerate() - { - let line_str = line.to_string(); - let mut wrapped_lines = vec![]; - let mut prev_boundary_ix = 0; - - if line_str.len() > longest_row_len { - longest_row_ix = new_start_row + ix; - longest_row_len = line_str.len(); - } - - // If wrap_width is Pixels::MAX, skip wrapping to disable word wrap - if let Some(wrap_width) = wrap_width { - // Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty. - for boundary in wrap_line(&line_str, wrap_width) { - wrapped_lines.push(prev_boundary_ix..boundary.ix); - prev_boundary_ix = boundary.ix; - } - } - - // Reset of the line - if !line_str[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 { - wrapped_lines.push(prev_boundary_ix..line.len()); - } - - new_lines.push(LineItem { - line: Rope::from(line), - wrapped_lines, - }); - } - - if self.lines.is_empty() { - self.lines = new_lines; - } else { - self.lines.splice(rows_range, new_lines); - } - - self.text = changed_text.clone(); - self.soft_lines = self.lines.iter().map(|l| l.lines_len()).sum(); - self.longest_row = LongestRow { - row: longest_row_ix, - len: longest_row_len, - } - } - - /// Update the text wrapper and recalculate the wrapped lines. - /// - /// If the `text` is the same as the current text, do nothing. - fn update_all(&mut self, text: &Rope, cx: &mut App) { - self.update(text, &(0..text.len()), text, cx); - } - - /// Return display point (with soft wrap) from the given byte offset in the text. - /// - /// Panics if the `offset` is out of bounds. - pub(crate) fn offset_to_display_point(&self, offset: usize) -> WrapDisplayPoint { - let row = self.text.offset_to_point(offset).row; - let start = self.text.line_start_offset(row); - let line = &self.lines[row]; - - let mut wrapped_row = self - .lines - .iter() - .take(row) - .map(|l| l.lines_len()) - .sum::(); - - let local_offset = offset.saturating_sub(start); - for (ix, range) in line.wrapped_lines.iter().enumerate() { - if range.contains(&local_offset) { - return WrapDisplayPoint::new( - wrapped_row + ix, - ix, - local_offset.saturating_sub(range.start), - ); - } - } - - // Otherwise return the eof of the line. - let last_range = line.wrapped_lines.last().unwrap_or(&(0..0)); - let ix = line.lines_len().saturating_sub(1); - - WrapDisplayPoint::new(wrapped_row + ix, ix, last_range.len()) - } - - /// Return byte offset in the text from the given display point (with soft wrap). - /// - /// Panics if the `point.row` is out of bounds. - pub(crate) fn display_point_to_offset(&self, point: WrapDisplayPoint) -> usize { - let mut wrapped_row = 0; - for (row, line) in self.lines.iter().enumerate() { - if wrapped_row + line.lines_len() > point.row { - let line_start = self.text.line_start_offset(row); - let local_row = point.row.saturating_sub(wrapped_row); - if let Some(range) = line.wrapped_lines.get(local_row) { - return line_start + (range.start + point.column).min(range.end); - } else { - // If not found, return the end of the line. - return line_start + line.len(); - } - } - - wrapped_row += line.lines_len(); - } - - self.text.len() - } - - pub(crate) fn display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint { - let offset = self.display_point_to_offset(point); - self.text.offset_to_point(offset) - } - - pub(crate) fn point_to_display_point(&self, point: TreeSitterPoint) -> WrapDisplayPoint { - let offset = self.text.point_to_offset(point); - self.offset_to_display_point(offset) - } -} - -/// A display point within the soft-wrapped text. -/// -/// This represents a position in the text after soft-wrapping, -/// with an additional `local_row` field tracking the wrap line -/// within the original buffer line. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct WrapDisplayPoint { - /// The 0-based soft wrapped row index in the text. - pub row: usize, - /// The 0-based row index in local line (include first line). - /// - /// This value only valid when return from [`TextWrapper::offset_to_display_point`], otherwise it will be ignored. - pub local_row: usize, - /// The 0-based column byte index in the display line (with soft wrap). - pub column: usize, -} - -impl WrapDisplayPoint { - pub fn new(row: usize, local_row: usize, column: usize) -> Self { - Self { - row, - local_row, - column, - } - } -} - -/// The layout info of a line with soft wrapped lines. -pub(crate) struct LineLayout { - /// Total bytes length of this line. - len: usize, - /// The soft wrapped lines of this line (Include the first line). - pub(crate) wrapped_lines: SmallVec<[ShapedLine; 1]>, - pub(crate) longest_width: Pixels, - pub(crate) whitespace_indicators: Option, - /// Whitespace indicators: (line_index, x_position, is_tab) - pub(crate) whitespace_chars: Vec<(usize, Pixels, bool)>, -} - -impl LineLayout { - pub(crate) fn new() -> Self { - Self { - len: 0, - longest_width: px(0.), - wrapped_lines: SmallVec::new(), - whitespace_chars: Vec::new(), - whitespace_indicators: None, - } - } - - pub(crate) fn lines(mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) -> Self { - self.set_wrapped_lines(wrapped_lines); - self - } - - pub(crate) fn set_wrapped_lines(&mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) { - self.len = wrapped_lines.iter().map(|l| l.len).sum(); - let width = wrapped_lines - .iter() - .map(|l| l.width) - .max() - .unwrap_or_default(); - self.longest_width = width; - self.wrapped_lines = wrapped_lines; - } - - pub(crate) fn with_whitespaces(mut self, indicators: Option) -> Self { - self.whitespace_indicators = indicators; - let Some(indicators) = self.whitespace_indicators.as_ref() else { - return self; - }; - - let space_indicator_offset = indicators.space.width.half(); - - for (line_index, wrapped_line) in self.wrapped_lines.iter().enumerate() { - for (relative_offset, c) in wrapped_line.text.char_indices() { - if matches!(c, ' ' | '\t') { - let is_tab = c == '\t'; - let start_x = wrapped_line.x_for_index(relative_offset); - let end_x = wrapped_line.x_for_index(relative_offset + c.len_utf8()); - // Center the indicator in the actual character's space - let x_position = if c == ' ' { - (start_x + end_x).half() - space_indicator_offset - } else { - start_x - }; - - self.whitespace_chars.push((line_index, x_position, is_tab)); - } - } - } - self - } - - #[inline] - pub(crate) fn len(&self) -> usize { - self.len - } - - /// Get the position (x, y) for the given index in this line layout. - /// - /// - The `offset` is a local byte index in this line layout. - /// - When `line_end_affinity` is true, an offset at a soft wrap boundary is placed at - /// the end of the current visual line rather than the start of the next one. - /// - The return value is relative to the top-left corner of this line layout, start from (0, 0) - pub(crate) fn position_for_index( - &self, - offset: usize, - last_layout: &LastLayout, - line_end_affinity: bool, - ) -> Option> { - let mut acc_len = 0; - let mut offset_y = px(0.); - - let x_offset = last_layout.alignment_offset(self.longest_width); - - for (i, line) in self.wrapped_lines.iter().enumerate() { - let is_last = i + 1 == self.wrapped_lines.len(); - - let matches = if line.len == 0 { - // Empty visual lines still own their boundary offset. - offset == acc_len - } else if is_last || line_end_affinity { - // Inclusive: cursor can sit at end of this visual line. - offset >= acc_len && offset <= acc_len + line.len - } else { - // Exclusive: boundary offset belongs to the next visual line. - offset >= acc_len && offset < acc_len + line.len - }; - - if matches { - let x = line.x_for_index(offset.saturating_sub(acc_len)) + x_offset; - return Some(point(x, offset_y)); - } - - // Always advance by actual line length. The last line gets +1 so the - // cursor can be placed after the final character. - acc_len += if is_last { line.len + 1 } else { line.len }; - offset_y += last_layout.line_height; - } - - None - } - - /// Get the closest index for the given x in this line layout. - pub(crate) fn closest_index_for_x(&self, x: Pixels, last_layout: &LastLayout) -> usize { - let mut acc_len = 0; - let x_offset = last_layout.alignment_offset(self.longest_width); - let x = x - x_offset; - - for (i, line) in self.wrapped_lines.iter().enumerate() { - let is_last = i + 1 == self.wrapped_lines.len(); - if x <= line.width { - let mut ix = line.closest_index_for_x(x); - if !is_last && ix == line.text.len() { - // For soft wrap line, we can't put the cursor at the end of the line. - let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0); - ix = ix.saturating_sub(c_len); - } - - return acc_len + ix; - } - acc_len += line.text.len(); - } - - acc_len - } - - /// Get the index for the given position (x, y) in this line layout. - /// - /// The `pos` is relative to the top-left corner of this line layout, start from (0, 0) - /// The return value is a local byte index in this line layout, start from 0. - pub(crate) fn closest_index_for_position( - &self, - pos: Point, - last_layout: &LastLayout, - ) -> Option { - let mut offset = 0; - let mut line_top = px(0.); - let x_offset = last_layout.alignment_offset(self.longest_width); - for (i, line) in self.wrapped_lines.iter().enumerate() { - let is_last = i + 1 == self.wrapped_lines.len(); - let line_bottom = line_top + last_layout.line_height; - if pos.y >= line_top && pos.y < line_bottom { - let mut ix = line.closest_index_for_x(pos.x - x_offset); - if !is_last && ix == line.text.len() { - // For soft wrap line, we can't put the cursor at the end of the line. - let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0); - ix = ix.saturating_sub(c_len); - } - return Some(offset + ix); - } - - offset += line.text.len(); - line_top = line_bottom; - } - - None - } - - pub(crate) fn index_for_position( - &self, - pos: Point, - last_layout: &LastLayout, - ) -> Option { - let mut offset = 0; - let mut line_top = px(0.); - let x_offset = last_layout.alignment_offset(self.longest_width); - for line in self.wrapped_lines.iter() { - let line_bottom = line_top + last_layout.line_height; - if pos.y >= line_top && pos.y < line_bottom { - let ix = line.index_for_x(pos.x - x_offset)?; - return Some(offset + ix); - } - - offset += line.text.len(); - line_top = line_bottom; - } - - None - } - - pub(crate) fn size(&self, line_height: Pixels) -> Size { - size(self.longest_width, self.wrapped_lines.len() * line_height) - } - - pub(crate) fn paint( - &self, - pos: Point, - line_height: Pixels, - text_align: TextAlign, - align_width: Option, - window: &mut Window, - cx: &mut App, - ) { - for (ix, line) in self.wrapped_lines.iter().enumerate() { - _ = line.paint( - pos + point(px(0.), ix * line_height), - line_height, - text_align, - align_width, - window, - cx, - ); - } - - // Paint whitespace indicators - if let Some(indicators) = self.whitespace_indicators.as_ref() { - for (line_index, x_position, is_tab) in &self.whitespace_chars { - let invisible = if *is_tab { - indicators.tab.clone() - } else { - indicators.space.clone() - }; - - let origin = point( - pos.x + *x_position, - pos.y + *line_index as f32 * line_height, - ); - - _ = invisible.paint(origin, line_height, text_align, align_width, window, cx); - } - } - } -} diff --git a/crates/ui/src/input/display_map/wrap_map.rs b/crates/ui/src/input/display_map/wrap_map.rs deleted file mode 100644 index ac422fc3..00000000 --- a/crates/ui/src/input/display_map/wrap_map.rs +++ /dev/null @@ -1,172 +0,0 @@ -/// WrapMap: Soft-wrapping layer (Buffer → Wrap rows). -/// -/// This module wraps the existing TextWrapper and provides: -/// - BufferPoint ↔ WrapPoint mapping -/// - Efficient buffer_line → wrap_row queries via prefix sum cache -/// - Incremental updates when text or layout changes -use std::ops::Range; - -use gpui::{App, Font, Pixels}; -use ropey::Rope; - -use super::text_wrapper::{LineItem, TextWrapper}; - -/// WrapMap manages soft-wrapping and provides buffer ↔ wrap coordinate mapping. -pub struct WrapMap { - /// The underlying text wrapper (reuses existing implementation) - wrapper: TextWrapper, - - /// Prefix sum cache: buffer_line_starts[line] = first wrap_row for buffer line `line` - /// This allows O(1) lookup of buffer_line → wrap_row - buffer_line_starts: Vec, - - /// Cached line count from last rebuild - cached_line_count: usize, - - /// Cached total wrap row count from last rebuild. - /// Used together with `cached_line_count` to detect if the cache is stale. - /// When soft wrap changes a line's wrap count without changing buffer line count, - /// this catches the staleness. - cached_wrap_row_count: usize, -} - -impl WrapMap { - pub fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { - Self { - wrapper: TextWrapper::new(font, font_size, wrap_width), - buffer_line_starts: Vec::new(), - cached_line_count: 0, - cached_wrap_row_count: 0, - } - } - - /// Get total number of wrap rows (visual rows after soft-wrapping) - #[inline] - pub fn wrap_row_count(&self) -> usize { - self.wrapper.len() - } - - /// Get total number of buffer lines (logical lines) - #[inline] - pub fn buffer_line_count(&self) -> usize { - self.wrapper.lines.len() - } - - /// Get the buffer line for a given wrap row - pub fn wrap_row_to_buffer_line(&self, wrap_row: usize) -> usize { - if wrap_row >= self.wrap_row_count() { - return self.buffer_line_count().saturating_sub(1); - } - - // Binary search in prefix sum cache - match self.buffer_line_starts.binary_search(&wrap_row) { - Ok(line) => line, - Err(insert_pos) => insert_pos.saturating_sub(1), - } - } - - /// Get the first wrap row for a given buffer line - pub fn buffer_line_to_first_wrap_row(&self, line: usize) -> usize { - if line >= self.buffer_line_starts.len() { - return self.wrap_row_count(); - } - self.buffer_line_starts[line] - } - - /// Get the wrap row range for a buffer line: [start, end) - pub fn buffer_line_to_wrap_row_range(&self, line: usize) -> Range { - let start = self.buffer_line_to_first_wrap_row(line); - let end = if line + 1 < self.buffer_line_starts.len() { - self.buffer_line_starts[line + 1] - } else { - self.wrap_row_count() - }; - start..end - } - - /// Update text (incremental or full) - pub fn on_text_changed( - &mut self, - changed_text: &Rope, - range: &Range, - new_text: &Rope, - cx: &mut App, - ) { - self.wrapper.update(changed_text, range, new_text, cx); - self.rebuild_cache(); - } - - /// Update layout parameters (wrap width or font) - pub fn on_layout_changed(&mut self, wrap_width: Option, cx: &mut App) { - self.wrapper.set_wrap_width(wrap_width, cx); - self.rebuild_cache(); - } - - /// Set font parameters - pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { - self.wrapper.set_font(font, font_size, cx); - self.rebuild_cache(); - } - - /// Ensure text is prepared (initializes wrapper if needed) - pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) -> bool { - let did_initialize = self.wrapper.prepare_if_need(text, cx); - if did_initialize { - self.rebuild_cache(); - } - did_initialize - } - - /// Initialize with text - pub fn set_text(&mut self, text: &Rope, cx: &mut App) { - self.wrapper.set_default_text(text); - self.wrapper.prepare_if_need(text, cx); - self.rebuild_cache(); - } - - /// Rebuild the prefix sum cache: buffer_line_starts - fn rebuild_cache(&mut self) { - let line_count = self.wrapper.lines.len(); - let wrap_row_count = self.wrapper.len(); - - // Skip if nothing changed: both buffer line count and total wrap row count must match. - if line_count == self.cached_line_count - && wrap_row_count == self.cached_wrap_row_count - && !self.buffer_line_starts.is_empty() - { - return; - } - - self.buffer_line_starts.clear(); - - let mut wrap_row = 0; - for line_item in &self.wrapper.lines { - self.buffer_line_starts.push(wrap_row); - wrap_row += line_item.lines_len(); - } - - self.cached_line_count = line_count; - self.cached_wrap_row_count = wrap_row_count; - } - - /// Get access to the underlying wrapper (for rendering/hit-testing) - pub(crate) fn wrapper(&self) -> &TextWrapper { - &self.wrapper - } - - /// Get access to line items (for rendering) - pub(crate) fn lines(&self) -> &[LineItem] { - &self.wrapper.lines - } - - /// Get the rope text - pub fn text(&self) -> &Rope { - self.wrapper.text() - } - - /// Calculate how many wrap rows of a buffer line are visible. - /// Without folding, all wrap rows are visible. - pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize { - self.buffer_line_to_wrap_row_range(line).len() - } -} diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs deleted file mode 100644 index a19c27f2..00000000 --- a/crates/ui/src/input/element.rs +++ /dev/null @@ -1,1642 +0,0 @@ -use std::ops::Range; -use std::rc::Rc; - -use gpui::{ - AnyElement, App, Bounds, Corners, Edges, Element, ElementId, ElementInputHandler, Entity, - GlobalElementId, Half, Hsla, IntoElement, LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent, - Path, Pixels, Point, Position, SharedString, Size, Style, TextAlign, TextRun, TextStyle, - UnderlineStyle, Window, fill, point, px, relative, size, -}; -use ropey::Rope; -use smallvec::SmallVec; -use theme::ActiveTheme; - -use super::mode::InputMode; -use super::{InputState, LastLayout, WhitespaceIndicators}; -use crate::Root; -use crate::input::RopeExt as _; -use crate::input::blink_cursor::CURSOR_WIDTH; -use crate::input::display_map::LineLayout; -use crate::scroll::Scrollbar; - -const BOTTOM_MARGIN_ROWS: usize = 3; -pub(super) const RIGHT_MARGIN: Pixels = px(10.); - -#[derive(Clone, Copy, Debug, PartialEq)] -struct EditorScrollbarLayout { - bounds: Bounds, - scroll_size: Size, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub(super) struct EditorScrollbarSnapshot { - layout: EditorScrollbarLayout, - cursor_scroll_offset: Point, - soft_wrap: bool, -} - -impl EditorScrollbarSnapshot { - fn new( - input_bounds: Bounds, - last_layout: &LastLayout, - scroll_size: Size, - cursor_scroll_offset: Point, - state: &InputState, - ) -> Self { - Self { - layout: EditorScrollbarLayout::new( - input_bounds, - last_layout.line_number_width, - scroll_size, - state.editor_scrollbar_paddings.get(), - ), - cursor_scroll_offset, - soft_wrap: state.soft_wrap, - } - } -} - -impl EditorScrollbarLayout { - fn new( - input_bounds: Bounds, - line_number_width: Pixels, - scroll_size: Size, - paddings: Edges, - ) -> Self { - let left = if line_number_width == px(0.) { - px(0.) - } else { - paddings.left + line_number_width - }; - - Self { - bounds: Bounds::new( - point( - input_bounds.origin.x + left, - input_bounds.origin.y - paddings.top, - ), - size( - input_bounds.size.width - left + paddings.right, - input_bounds.size.height + paddings.top + paddings.bottom, - ), - ), - scroll_size: size( - scroll_size.width - left + paddings.right + RIGHT_MARGIN, - scroll_size.height, - ), - } - } -} - -pub(super) struct EditorScrollbar { - state: Entity, -} - -impl EditorScrollbar { - pub(super) fn new(state: Entity) -> Self { - Self { state } - } -} - -impl IntoElement for EditorScrollbar { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for EditorScrollbar { - type PrepaintState = Option; - type RequestLayoutState = (); - - fn id(&self) -> Option { - Some("editor-scrollbar".into()) - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let style = Style { - position: Position::Absolute, - size: Size { - width: relative(1.).into(), - height: relative(1.).into(), - }, - ..Default::default() - }; - (window.request_layout(style, [], cx), ()) - } - - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - let state = self.state.read(cx); - let snapshot = state.editor_scrollbar_snapshot.get()?; - let scroll_handle = state.scroll_handle.clone(); - - if scroll_handle.offset() != snapshot.cursor_scroll_offset { - scroll_handle.set_offset(snapshot.cursor_scroll_offset); - } - - let mut scrollbar = if !snapshot.soft_wrap { - Scrollbar::new(&scroll_handle) - } else { - Scrollbar::vertical(&scroll_handle) - } - .scroll_size(snapshot.layout.scroll_size) - .into_any_element(); - - scrollbar.prepaint_as_root( - snapshot.layout.bounds.origin, - snapshot.layout.bounds.size.into(), - window, - cx, - ); - Some(scrollbar) - } - - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - if let Some(scrollbar) = prepaint.as_mut() { - scrollbar.paint(window, cx); - } - } -} - -fn clamp_auto_grow_vertical_scroll_offset( - mode: &InputMode, - scroll_top: Pixels, - scroll_height: Pixels, - input_height: Pixels, -) -> Pixels { - if mode.is_auto_grow() { - scroll_top.clamp((input_height - scroll_height).min(px(0.)), px(0.)) - } else { - scroll_top - } -} - -use super::MASK_CHAR; - -/// Convert a byte offset in the original text to a byte offset in the masked display string. -/// -/// The masked string consists of `MASK_CHAR` repeated once per character in the original text. -/// Since `MASK_CHAR` may be multi-byte in UTF-8, the byte offset in the masked string is -/// `char_index * MASK_CHAR.len_utf8()`. -fn masked_display_offset(text: &Rope, original_offset: usize) -> usize { - text.offset_to_char_index(original_offset) * MASK_CHAR.len_utf8() -} - -pub(super) struct TextElement { - pub(crate) state: Entity, - placeholder: SharedString, -} - -impl TextElement { - pub(super) fn new(state: Entity) -> Self { - Self { - state, - placeholder: SharedString::default(), - } - } - - /// Set the placeholder text of the input field. - pub fn placeholder(mut self, placeholder: impl Into) -> Self { - self.placeholder = placeholder.into(); - self - } - - fn paint_mouse_listeners(&mut self, window: &mut Window, _: &mut App) { - window.on_mouse_event({ - let state = self.state.clone(); - - move |event: &MouseMoveEvent, _, window, cx| { - if event.pressed_button == Some(MouseButton::Left) { - state.update(cx, |state, cx| { - state.on_drag_move(event, window, cx); - }); - } - } - }); - - window.on_mouse_event({ - let state = self.state.clone(); - move |_: &MouseUpEvent, phase, _, cx| { - if !phase.bubble() { - return; - } - - // Stop auto-scroll when mouse up, and also stop selecting. - state.update(cx, |state, _| { - state.selecting = false; - }); - } - }); - } - - /// Returns the: - /// - /// - cursor bounds - /// - scroll offset - /// - current row index (No only the visible lines, but all lines) - /// - /// This method also will update for track scroll to cursor. - fn layout_cursor( - &self, - last_layout: &LastLayout, - bounds: &mut Bounds, - scroll_size: Size, - _: &mut Window, - cx: &mut App, - ) -> (Option>, Point) { - let state = self.state.read(cx); - - let line_height = last_layout.line_height; - let visible_range = &last_layout.visible_range; - let lines = &last_layout.lines; - let line_number_width = last_layout.line_number_width; - - let mut selected_range = state.selected_range; - - if let Some(ime_marked_range) = &state.ime_marked_range { - selected_range = (ime_marked_range.end..ime_marked_range.end).into(); - } - let is_selected_all = selected_range.len() == state.text.len(); - - let mut cursor = state.cursor(); - if state.masked { - selected_range.start = masked_display_offset(&state.text, selected_range.start); - selected_range.end = masked_display_offset(&state.text, selected_range.end); - cursor = masked_display_offset(&state.text, cursor); - } - - let mut scroll_offset = state.scroll_handle.offset(); - let mut cursor_bounds = None; - - // If the input has a fixed height (Otherwise is auto-grow), we need to add a bottom margin to the input. - let top_bottom_margin = - if state.mode.is_auto_grow() || visible_range.len() < BOTTOM_MARGIN_ROWS * 8 { - line_height - } else { - BOTTOM_MARGIN_ROWS * line_height - }; - - // The cursor corresponds to the current cursor position in the text no only the line. - let mut cursor_pos = None; - let mut cursor_start = None; - let mut cursor_end = None; - - let mut prev_lines_offset = 0; - let mut offset_y = px(0.); - let buffer_lines = state.display_map.lines(); - let visible_buffer_lines = &last_layout.visible_buffer_lines; - let mut vi = 0; // index into visible_buffer_lines / lines - for (ix, wrap_line) in buffer_lines.iter().enumerate() { - let line_origin = point(px(0.), offset_y); - - // break loop if all cursor positions are found - if cursor_pos.is_some() && cursor_start.is_some() && cursor_end.is_some() { - break; - } - - // Check if this buffer line has a LineLayout in the compact lines vec - let line_layout = if vi < visible_buffer_lines.len() && visible_buffer_lines[vi] == ix { - let l = &lines[vi]; - vi += 1; - Some(l) - } else { - None - }; - - if let Some(line) = line_layout { - if cursor_pos.is_none() { - let offset = cursor.saturating_sub(prev_lines_offset); - if let Some(pos) = - line.position_for_index(offset, last_layout, state.cursor_line_end_affinity) - { - cursor_pos = Some(line_origin + pos); - } - } - if cursor_start.is_none() { - let offset = selected_range.start.saturating_sub(prev_lines_offset); - if let Some(pos) = line.position_for_index(offset, last_layout, false) { - cursor_start = Some(line_origin + pos); - } - } - if cursor_end.is_none() { - let offset = selected_range.end.saturating_sub(prev_lines_offset); - if let Some(pos) = line.position_for_index(offset, last_layout, false) { - cursor_end = Some(line_origin + pos); - } - } - - offset_y += line.size(line_height).height; - // +1 for the last `\n` - prev_lines_offset += wrap_line.len() + 1; - } else { - // Not visible (before visible range or hidden/folded). - // Just increase the offset_y and prev_lines_offset for scroll tracking. - if prev_lines_offset >= cursor && cursor_pos.is_none() { - cursor_pos = Some(line_origin); - } - if prev_lines_offset >= selected_range.start && cursor_start.is_none() { - cursor_start = Some(line_origin); - } - if prev_lines_offset >= selected_range.end && cursor_end.is_none() { - cursor_end = Some(line_origin); - } - - let visible_wrap_rows = - state.display_map.visible_wrap_row_count_for_buffer_line(ix); - offset_y += line_height * visible_wrap_rows; - // +1 for the last `\n` - prev_lines_offset += wrap_line.len() + 1; - } - } - - if let (Some(cursor_pos), Some(cursor_start), Some(cursor_end)) = - (cursor_pos, cursor_start, cursor_end) - { - let selection_changed = state.last_selected_range != Some(selected_range); - - if selection_changed && !is_selected_all { - // For Right alignment use 0 margin: cursor is clamped to bounds separately, - // so we never scroll the text for cursor-at-edge, avoiding a first-click jump. - let safety_margin = match last_layout.text_align { - TextAlign::Left => RIGHT_MARGIN, - TextAlign::Right => px(0.), - TextAlign::Center => CURSOR_WIDTH, - }; - - scroll_offset.x = if scroll_offset.x + cursor_pos.x - > (bounds.size.width - line_number_width - safety_margin) - { - // cursor is out of right - bounds.size.width - line_number_width - safety_margin - cursor_pos.x - } else if scroll_offset.x + cursor_pos.x < px(0.) { - // cursor is out of left - scroll_offset.x - cursor_pos.x - } else { - scroll_offset.x - }; - - // If we change the scroll_offset.y, GPUI will render and trigger the next run loop. - // So, here we just adjust offset by `line_height` for move smooth. - scroll_offset.y = - if scroll_offset.y + cursor_pos.y > bounds.size.height - top_bottom_margin { - // cursor is out of bottom - scroll_offset.y - line_height - } else if scroll_offset.y + cursor_pos.y < top_bottom_margin { - // cursor is out of top - (scroll_offset.y + line_height).min(px(0.)) - } else { - scroll_offset.y - }; - - // For selection to move scroll - if state.selection_reversed { - if scroll_offset.x + cursor_start.x < px(0.) { - // selection start is out of left - scroll_offset.x = -cursor_start.x; - } - if scroll_offset.y + cursor_start.y < px(0.) { - // selection start is out of top - scroll_offset.y = -cursor_start.y; - } - } else { - // TODO: Consider to remove this part, - // maybe is not necessary (But selection_reversed is needed). - if scroll_offset.x + cursor_end.x <= px(0.) { - // selection end is out of left - scroll_offset.x = -cursor_end.x; - } - if scroll_offset.y + cursor_end.y <= px(0.) { - // selection end is out of top - scroll_offset.y = -cursor_end.y; - } - } - } - - // cursor bounds - let cursor_height = match state.size { - crate::Size::Large => 1., - crate::Size::Small => 0.75, - _ => 0.85, - } * line_height; - - // For Right alignment, clamp cursor within the right edge of bounds so it - // stays visible without having to shift the text via scroll_offset. - let cursor_x = bounds.left() + cursor_pos.x + line_number_width + scroll_offset.x; - let cursor_x = if last_layout.text_align == TextAlign::Right { - cursor_x.min(bounds.right() - CURSOR_WIDTH) - } else { - cursor_x - }; - cursor_bounds = Some(Bounds::new( - point( - cursor_x, - bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.), - ), - size(CURSOR_WIDTH, cursor_height), - )); - } - - if let Some(deferred_scroll_offset) = state.deferred_scroll_offset { - scroll_offset = deferred_scroll_offset; - } - scroll_offset.y = clamp_auto_grow_vertical_scroll_offset( - &state.mode, - scroll_offset.y, - scroll_size.height, - bounds.size.height, - ); - - bounds.origin += scroll_offset; - - (cursor_bounds, scroll_offset) - } - - /// Layout the match range to a Path. - pub(crate) fn layout_match_range( - range: Range, - last_layout: &LastLayout, - bounds: &Bounds, - ) -> Option> { - if range.is_empty() { - return None; - } - - if range.start < last_layout.visible_range_offset.start - || range.end > last_layout.visible_range_offset.end - { - return None; - } - - let line_height = last_layout.line_height; - let visible_top = last_layout.visible_top; - let lines = &last_layout.lines; - let line_number_width = last_layout.line_number_width; - - let start_ix = range.start; - let end_ix = range.end; - - // Start from visible_top (which already accounts for all lines before visible range) - let mut offset_y = visible_top; - let mut line_corners = vec![]; - - // Iterate only over visible (non-hidden) buffer lines - for (prev_lines_offset, line) in last_layout - .visible_line_byte_offsets - .iter() - .zip(lines.iter()) - { - let prev_lines_offset = *prev_lines_offset; - let line_size = line.size(line_height); - let line_wrap_width = line_size.width; - - let line_origin = point(px(0.), offset_y); - - let line_cursor_start = line.position_for_index( - start_ix.saturating_sub(prev_lines_offset), - last_layout, - false, - ); - let line_cursor_end = line.position_for_index( - end_ix.saturating_sub(prev_lines_offset), - last_layout, - false, - ); - - if line_cursor_start.is_some() || line_cursor_end.is_some() { - let start = line_cursor_start - .unwrap_or_else(|| line.position_for_index(0, last_layout, false).unwrap()); - - let end = line_cursor_end.unwrap_or_else(|| { - line.position_for_index(line.len(), last_layout, false) - .unwrap() - }); - - // Split the selection into multiple items - let wrapped_lines = - (end.y / line_height).ceil() as usize - (start.y / line_height).ceil() as usize; - - let mut end_x = end.x; - if wrapped_lines > 0 { - end_x = line_wrap_width; - } - - // Ensure at least 6px width for the selection for empty lines. - end_x = end_x.max(start.x + px(6.)); - - line_corners.push(Corners { - top_left: line_origin + point(start.x, start.y), - top_right: line_origin + point(end_x, start.y), - bottom_left: line_origin + point(start.x, start.y + line_height), - bottom_right: line_origin + point(end_x, start.y + line_height), - }); - - // wrapped lines - for i in 1..=wrapped_lines { - let start = point(px(0.), start.y + i as f32 * line_height); - let mut end = point(end.x, end.y + i as f32 * line_height); - if i < wrapped_lines { - end.x = line_size.width; - } - - line_corners.push(Corners { - top_left: line_origin + point(start.x, start.y), - top_right: line_origin + point(end.x, start.y), - bottom_left: line_origin + point(start.x, start.y + line_height), - bottom_right: line_origin + point(end.x, start.y + line_height), - }); - } - } - - if line_cursor_start.is_some() && line_cursor_end.is_some() { - break; - } - - offset_y += line_size.height; - } - - let mut points = vec![]; - if line_corners.is_empty() { - return None; - } - - // Fix corners to make sure the left to right direction - for corners in &mut line_corners { - if corners.top_left.x > corners.top_right.x { - std::mem::swap(&mut corners.top_left, &mut corners.top_right); - std::mem::swap(&mut corners.bottom_left, &mut corners.bottom_right); - } - } - - for corners in &line_corners { - points.push(corners.top_right); - points.push(corners.bottom_right); - points.push(corners.bottom_left); - } - - let mut rev_line_corners = line_corners.iter().rev().peekable(); - while let Some(corners) = rev_line_corners.next() { - points.push(corners.top_left); - if let Some(next) = rev_line_corners.peek() - && next.top_left.x > corners.top_left.x - { - points.push(point(next.top_left.x, corners.top_left.y)); - } - } - - // print_points_as_svg_path(&line_corners, &points); - - let path_origin = bounds.origin + point(line_number_width, px(0.)); - let first_p = *points.first().unwrap(); - let mut builder = gpui::PathBuilder::fill(); - builder.move_to(path_origin + first_p); - for p in points.iter().skip(1) { - builder.line_to(path_origin + *p); - } - - builder.build().ok() - } - - fn layout_selections( - &self, - last_layout: &LastLayout, - bounds: &mut Bounds, - window: &mut Window, - cx: &mut App, - ) -> Option> { - let state = self.state.read(cx); - if !state.focus_handle.is_focused(window) { - return None; - } - - let mut selected_range = state.selected_range; - if let Some(ime_marked_range) = &state.ime_marked_range - && !ime_marked_range.is_empty() - { - selected_range = (ime_marked_range.end..ime_marked_range.end).into(); - } - if selected_range.is_empty() { - return None; - } - - if state.masked { - selected_range.start = masked_display_offset(&state.text, selected_range.start); - selected_range.end = masked_display_offset(&state.text, selected_range.end); - } - - let (start_ix, end_ix) = if selected_range.start < selected_range.end { - (selected_range.start, selected_range.end) - } else { - (selected_range.end, selected_range.start) - }; - - let range = start_ix.max(last_layout.visible_range_offset.start) - ..end_ix.min(last_layout.visible_range_offset.end); - - Self::layout_match_range(range, last_layout, bounds) - } - - /// Calculate the visible range of lines in the viewport. - /// - /// Returns - /// - /// - visible_range: The visible range is based on unwrapped lines (Zero based). - /// - visible_buffer_lines: Indices of non-hidden buffer lines within the visible range. - /// - visible_top: The top position of the first visible line in the scroll viewport. - fn calculate_visible_range( - &self, - state: &InputState, - line_height: Pixels, - input_height: Pixels, - ) -> (Range, Vec, Pixels) { - // Add extra rows to avoid showing empty space when scroll to bottom. - let extra_rows = 1; - let mut visible_top = px(0.); - if state.mode.is_single_line() { - return (0..1, vec![0], visible_top); - } - - let total_lines = state.display_map.wrap_row_count(); - let mut scroll_top = if let Some(deferred_scroll_offset) = state.deferred_scroll_offset { - deferred_scroll_offset.y - } else { - state.scroll_handle.offset().y - }; - - let mut visible_range = 0..total_lines; - scroll_top = clamp_auto_grow_vertical_scroll_offset( - &state.mode, - scroll_top, - line_height * total_lines, - input_height, - ); - let mut line_bottom = px(0.); - for (ix, _line) in state.display_map.lines().iter().enumerate() { - let visible_wrap_rows = state.display_map.visible_wrap_row_count_for_buffer_line(ix); - - if visible_wrap_rows == 0 { - continue; - } - - let wrapped_height = line_height * visible_wrap_rows; - line_bottom += wrapped_height; - - if line_bottom < -scroll_top { - visible_top = line_bottom - wrapped_height; - visible_range.start = ix; - } - - if line_bottom + scroll_top >= input_height { - visible_range.end = (ix + extra_rows).min(total_lines); - break; - } - } - - // Collect non-hidden buffer lines within the visible range - let mut visible_buffer_lines = Vec::with_capacity(visible_range.len()); - for ix in visible_range.start..visible_range.end { - let visible_wrap_rows = state.display_map.visible_wrap_row_count_for_buffer_line(ix); - if visible_wrap_rows > 0 { - visible_buffer_lines.push(ix); - } - } - - (visible_range, visible_buffer_lines, visible_top) - } - - /// Layout shaped lines for whitespace indicators (space and tab). - /// - /// Returns `WhitespaceIndicators` with shaped lines for space and tab characters. - fn layout_whitespace_indicators( - _state: &InputState, - text_size: Pixels, - style: &TextStyle, - window: &mut Window, - cx: &App, - ) -> Option { - // Whitespace indicators are not currently enabled. - // When re-enabled, check `state.show_whitespaces` to conditionally enable. - let _ = (text_size, style, window, cx); - None - } - - #[allow(clippy::too_many_arguments)] - fn layout_lines( - state: &InputState, - display_text: &Rope, - last_layout: &LastLayout, - font_size: Pixels, - runs: &[TextRun], - bg_segments: &[(Range, Hsla)], - whitespace_indicators: Option, - window: &mut Window, - ) -> Vec { - let is_single_line = state.mode.is_single_line(); - let buffer_lines = state.display_map.lines(); - - if is_single_line { - let shaped_line = window.text_system().shape_line( - display_text.to_string().into(), - font_size, - runs, - None, - ); - - let line_layout = LineLayout::new() - .lines(smallvec::smallvec![shaped_line]) - .with_whitespaces(whitespace_indicators); - return vec![line_layout]; - } - - // Empty to use placeholder, the placeholder is not in the wrapper map. - if state.text.len() == 0 { - let placeholder_text = display_text.to_string(); - let mut placeholder_lines = SmallVec::new(); - - for (line, line_runs) in placeholder_line_runs(&placeholder_text, runs) { - let shaped_line = window.text_system().shape_line( - line.to_string().into(), - font_size, - &line_runs, - None, - ); - placeholder_lines.push(shaped_line); - } - - // Keep placeholder lines in a single layout to stay parallel with visible_* metadata. - let line_layout = LineLayout::new() - .lines(placeholder_lines) - .with_whitespaces(whitespace_indicators); - return vec![line_layout]; - } - - let mut lines = Vec::with_capacity(last_layout.visible_buffer_lines.len()); - - for (vi, &buffer_line) in last_layout.visible_buffer_lines.iter().enumerate() { - let line_text: String = display_text.slice_line(buffer_line).into(); - let line_item = buffer_lines - .get(buffer_line) - .expect("line should exists in wrapper"); - - debug_assert_eq!(line_item.len(), line_text.len()); - - let mut wrapped_lines = SmallVec::with_capacity(1); - let line_offset = display_text.line_start_offset(buffer_line); - - for range in &line_item.wrapped_lines { - let line_runs = runs_for_range(runs, line_offset, range); - let line_runs = if bg_segments.is_empty() { - line_runs - } else { - split_runs_by_bg_segments( - last_layout.visible_line_byte_offsets[vi] + (range.start), - &line_runs, - bg_segments, - ) - }; - - let sub_line: SharedString = line_text[range.clone()].to_string().into(); - let shaped_line = window - .text_system() - .shape_line(sub_line, font_size, &line_runs, None); - - wrapped_lines.push(shaped_line); - } - - let line_layout = LineLayout::new() - .lines(wrapped_lines) - .with_whitespaces(whitespace_indicators.clone()); - lines.push(line_layout); - } - - lines - } -} - -pub(super) struct PrepaintState { - /// The lines of entire lines. - last_layout: LastLayout, - /// Size of the scrollable area by entire lines. - scroll_size: Size, - cursor_bounds: Option>, - cursor_scroll_offset: Point, - selection_path: Option>, - bounds: Bounds, -} - -impl PrepaintState { - /// Returns cursor bounds adjusted for scroll offset, if available. - fn cursor_bounds_with_scroll(&self) -> Option> { - self.cursor_bounds.map(|mut bounds| { - bounds.origin.y += self.cursor_scroll_offset.y; - bounds - }) - } -} - -impl IntoElement for TextElement { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -/// A debug function to print points as SVG path. -#[allow(unused)] -fn print_points_as_svg_path(line_corners: &Vec>, points: Vec>) { - for corners in line_corners { - println!( - "tl: ({}, {}), tr: ({}, {}), bl: ({}, {}), br: ({}, {})", - corners.top_left.as_f32() as i32, - corners.top_left.as_f32() as i32, - corners.top_right.as_f32() as i32, - corners.top_right.as_f32() as i32, - corners.bottom_left.as_f32() as i32, - corners.bottom_left.as_f32() as i32, - corners.bottom_right.as_f32() as i32, - corners.bottom_right.as_f32() as i32, - ); - } - - if !points.is_empty() { - println!( - "M{},{}", - points[0].x.as_f32() as i32, - points[0].y.as_f32() as i32 - ); - for p in points.iter().skip(1) { - println!("L{},{}", p.x.as_f32() as i32, p.y.as_f32() as i32); - } - } -} -impl Element for TextElement { - type PrepaintState = PrepaintState; - type RequestLayoutState = (); - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let state = self.state.read(cx); - let line_height = window.line_height(); - - let mut style = Style::default(); - style.size.width = relative(1.).into(); - if state.mode.is_multi_line() { - style.flex_grow = 1.0; - style.size.height = relative(1.).into(); - if state.mode.is_auto_grow() { - // Auto grow to let height match to rows, but not exceed max rows. - let rows = state.mode.max_rows().min(state.mode.rows()); - style.min_size.height = (rows * line_height).into(); - } else { - style.min_size.height = line_height.into(); - } - } else { - // For single-line inputs, the minimum height should be the line height - style.size.height = line_height.into(); - }; - - (window.request_layout(style, [], cx), ()) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - bounds: Bounds, - _request_layout: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - let style = window.text_style(); - let font = style.font(); - let text_size = style.font_size.to_pixels(window.rem_size()); - - self.state.update(cx, |state, cx| { - state.display_map.set_font(font, text_size, cx); - state.display_map.ensure_text_prepared(&state.text, cx); - }); - - let state = self.state.read(cx); - let line_height = window.line_height(); - - let (visible_range, visible_buffer_lines, visible_top) = - self.calculate_visible_range(state, line_height, bounds.size.height); - let visible_start_offset = state.text.line_start_offset(visible_range.start); - let visible_end_offset = state - .text - .line_end_offset(visible_range.end.saturating_sub(1)); - - let state = self.state.read(cx); - let multi_line = state.mode.is_multi_line(); - let text = state.text.clone(); - let is_empty = text.len() == 0; - let placeholder = self.placeholder.clone(); - - let text_style = window.text_style(); - let fg = text_style.color; - let (display_text, text_color) = if is_empty { - (&Rope::from(placeholder.as_str()), cx.theme().text_muted) - } else if state.masked { - ( - &Rope::from(MASK_CHAR.to_string().repeat(text.chars().count())), - fg, - ) - } else { - (&text, fg) - }; - - // Line numbers are not used (code editor mode removed) - let line_number_width = px(0.); - - let mut bounds = bounds; - let wrap_width = if multi_line && state.soft_wrap { - Some(bounds.size.width - line_number_width - RIGHT_MARGIN) - } else { - None - }; - - let visible_line_byte_offsets: Vec = visible_buffer_lines - .iter() - .map(|&bl| state.text.line_start_offset(bl)) - .collect(); - - // For password input (masked: true), convert byte offsets to masked display byte offsets so that - // layout_match_range and position_for_index work in the correct coordinate space. - let (visible_line_byte_offsets, visible_range_offset) = if state.masked { - let offsets = visible_line_byte_offsets - .iter() - .map(|&o| masked_display_offset(&text, o)) - .collect(); - let range_offset = masked_display_offset(&text, visible_start_offset) - ..masked_display_offset(&text, visible_end_offset); - (offsets, range_offset) - } else { - ( - visible_line_byte_offsets, - visible_start_offset..visible_end_offset, - ) - }; - - let mut last_layout = LastLayout { - visible_range, - visible_buffer_lines, - visible_line_byte_offsets, - visible_top, - visible_range_offset, - line_height, - wrap_width, - line_number_width, - lines: Rc::new(vec![]), - cursor_bounds: None, - text_align: state.text_align, - content_width: bounds.size.width, - }; - - let run = TextRun { - len: display_text.len(), - font: style.font(), - color: text_color, - background_color: None, - underline: None, - strikethrough: None, - }; - let marked_run = TextRun { - len: 0, - font: style.font(), - color: text_color, - background_color: None, - underline: Some(UnderlineStyle { - thickness: px(1.), - color: Some(text_color), - wavy: false, - }), - strikethrough: None, - }; - - let runs = if !is_empty { - vec![run] - } else if let Some(ime_marked_range) = &state.ime_marked_range { - // IME marked text - vec![ - TextRun { - len: ime_marked_range.start, - ..run.clone() - }, - TextRun { - len: ime_marked_range.end - ime_marked_range.start, - underline: marked_run.underline, - ..run.clone() - }, - TextRun { - len: display_text.len() - ime_marked_range.end, - ..run.clone() - }, - ] - .into_iter() - .filter(|run| run.len > 0) - .collect() - } else { - vec![run] - }; - - // Create shaped lines for whitespace indicators before layout - let whitespace_indicators = - Self::layout_whitespace_indicators(state, text_size, &text_style, window, cx); - - let lines = Self::layout_lines( - state, - display_text, - &last_layout, - text_size, - &runs, - &[], - whitespace_indicators, - window, - ); - - let mut longest_line_width = wrap_width.unwrap_or(px(0.)); - // 1. Single line - // 2. Multi-line with soft wrap disabled. - if state.mode.is_single_line() || !state.soft_wrap { - let longest_row = state.display_map.longest_row(); - let longest_line: SharedString = state.text.slice_line(longest_row).to_string().into(); - longest_line_width = window - .text_system() - .shape_line( - longest_line.clone(), - text_size, - &[TextRun { - len: longest_line.len(), - font: style.font(), - color: gpui::black(), - background_color: None, - underline: None, - strikethrough: None, - }], - wrap_width, - ) - .width; - } - last_layout.lines = Rc::new(lines); - - let total_wrapped_lines = state.display_map.wrap_row_count(); - let empty_bottom_height = px(0.); - - let mut scroll_size = size( - if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width { - longest_line_width + line_number_width + RIGHT_MARGIN - } else { - longest_line_width - }, - (total_wrapped_lines as f32 * line_height + empty_bottom_height) - .max(bounds.size.height), - ); - - // TODO: should be add some gap to right, to convenient to focus on boundary position - if last_layout.text_align == TextAlign::Right || last_layout.text_align == TextAlign::Center - { - scroll_size.width = longest_line_width + line_number_width; - } - - // `position_for_index` for example - // - // #### text - // - // Hello 世界,this is GPUI component. - // The GPUI Component is a collection of UI components for - // GPUI framework, including Button, Input, Checkbox, Radio, - // Dropdown, Tab, and more... - // - // wrap_width: 444px, line_height: 20px - // - // #### lines[0] - // - // | index | pos | line | - // |-------|------------------|------| - // | 5 | (37 px, 0.0) | 0 | - // | 38 | (261.7 px, 20.0) | 0 | - // | 40 | None | - | - // - // #### lines[1] - // - // | index | position | line | - // |-------|-----------------------|------| - // | 5 | (43.578125 px, 0.0) | 0 | - // | 56 | (422.21094 px, 0.0) | 0 | - // | 57 | (11.6328125 px, 20.0) | 1 | - // | 114 | (429.85938 px, 20.0) | 1 | - // | 115 | (11.3125 px, 40.0) | 2 | - - // Calculate the scroll offset to keep the cursor in view - - // Save the bounds before layout_cursor modifies bounds.origin with scroll_offset. - let input_bounds = bounds; - - let (cursor_bounds, cursor_scroll_offset) = - self.layout_cursor(&last_layout, &mut bounds, scroll_size, window, cx); - last_layout.cursor_bounds = cursor_bounds; - - let selection_path = self.layout_selections(&last_layout, &mut bounds, window, cx); - - let state = self.state.read(cx); - - state - .editor_scrollbar_snapshot - .set(Some(EditorScrollbarSnapshot::new( - input_bounds, - &last_layout, - scroll_size, - cursor_scroll_offset, - state, - ))); - - PrepaintState { - bounds, - last_layout, - scroll_size, - cursor_bounds, - cursor_scroll_offset, - selection_path, - } - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - input_bounds: Bounds, - _request_layout: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - let focus_handle = self.state.read(cx).focus_handle.clone(); - let show_cursor = self.state.read(cx).show_cursor(window, cx); - let focused = focus_handle.is_focused(window); - let bounds = prepaint.bounds; - let selected_range = self.state.read(cx).selected_range; - let text_align = prepaint.last_layout.text_align; - - window.handle_input( - &focus_handle, - ElementInputHandler::new(bounds, self.state.clone()), - cx, - ); - - // Set Root focused_input when self is focused - if focused { - let state = self.state.clone(); - if Root::read(window, cx).focused_input.as_ref() != Some(&state) { - Root::update(window, cx, |root, _, cx| { - root.focused_input = Some(state); - cx.notify(); - }); - } - } - - // And reset focused_input when next_frame start - window.on_next_frame({ - let state = self.state.clone(); - move |window, cx| { - if !focused && Root::read(window, cx).focused_input.as_ref() == Some(&state) { - Root::update(window, cx, |root, _, cx| { - root.focused_input = None; - cx.notify(); - }); - } - } - }); - - // Paint multi line text - let line_height = window.line_height(); - let origin = bounds.origin; - - let invisible_top_padding = prepaint.last_layout.visible_top; - - // Paint selections - if window.is_window_active() - && let Some(path) = prepaint.selection_path.take() - { - window.paint_path(path, cx.theme().selection); - } - - // Paint text - let mut offset_y = invisible_top_padding; - - // Keep scrollbar offset always be positive,Start from the left position - let scroll_offset = if text_align == TextAlign::Right { - (prepaint.scroll_size.width - prepaint.bounds.size.width).max(px(0.)) - } else if text_align == TextAlign::Center { - (prepaint.scroll_size.width - prepaint.bounds.size.width) - .half() - .max(px(0.)) - } else { - px(0.) - }; - - for (line, _buffer_line) in prepaint - .last_layout - .lines - .iter() - .zip(prepaint.last_layout.visible_buffer_lines.iter()) - { - let line_y = origin.y + offset_y; - let p = point( - origin.x + prepaint.last_layout.line_number_width + (scroll_offset), - line_y, - ); - - // Paint the actual line - line.paint( - p, - line_height, - text_align, - Some(prepaint.last_layout.content_width), - window, - cx, - ); - offset_y += line.size(line_height).height; - } - - // Paint blinking cursor - if focused - && show_cursor - && let Some(cursor_bounds) = prepaint.cursor_bounds_with_scroll() - { - window.paint_quad(fill(cursor_bounds, cx.theme().cursor)); - } - - self.state.update(cx, |state, cx| { - state.last_layout = Some(prepaint.last_layout.clone()); - state.last_bounds = Some(bounds); - state.last_cursor = Some(state.cursor()); - state.set_input_bounds(input_bounds, cx); - state.last_selected_range = Some(selected_range); - state.scroll_size = prepaint.scroll_size; - state.update_scroll_offset(Some(prepaint.cursor_scroll_offset), cx); - state.deferred_scroll_offset = None; - - cx.notify(); - }); - - self.paint_mouse_listeners(window, cx); - } -} - -/// Split placeholder text into display lines and trim runs to each line. -fn placeholder_line_runs<'a>( - display_text: &'a str, - runs: &[TextRun], -) -> Vec<(&'a str, Vec)> { - let mut result = Vec::new(); - let mut line_offset = 0; - - for line in display_text.split('\n') { - let line_runs = runs_for_range(runs, line_offset, &(0..line.len())); - debug_assert_eq!( - line_runs.iter().map(|run| run.len).sum::(), - line.len() - ); - result.push((line, line_runs)); - // Advance in the whole-placeholder coordinate space, including the separator. - line_offset += line.len() + 1; - } - - result -} - -/// Get the runs for the given range. -/// -/// The range is the byte range of the wrapped line. -pub(super) fn runs_for_range( - runs: &[TextRun], - line_offset: usize, - range: &Range, -) -> Vec { - let mut result = vec![]; - let range = (line_offset + range.start)..(line_offset + range.end); - let mut cursor = 0; - - for run in runs { - let run_start = cursor; - let run_end = cursor + run.len; - - if run_end <= range.start { - cursor = run_end; - continue; - } - - if run_start >= range.end { - break; - } - - let start = range.start.max(run_start) - run_start; - let end = range.end.min(run_end) - run_start; - let len = end - start; - - if len > 0 { - result.push(TextRun { len, ..run.clone() }); - } - - cursor = run_end; - } - - result -} - -fn split_runs_by_bg_segments( - start_offset: usize, - runs: &[TextRun], - bg_segments: &[(Range, Hsla)], -) -> Vec { - let mut result = vec![]; - - let mut cursor = start_offset; - for run in runs { - let mut run_start = cursor; - let run_end = cursor + run.len; - - for (bg_range, bg_color) in bg_segments { - if run_end <= bg_range.start || run_start >= bg_range.end { - continue; - } - - // Overlap exists - if run_start < bg_range.start { - // Add the part before the background range - result.push(TextRun { - len: bg_range.start - run_start, - ..run.clone() - }); - } - - // Add the overlapping part with background color - let overlap_start = run_start.max(bg_range.start); - let overlap_end = run_end.min(bg_range.end); - let text_color = if bg_color.l >= 0.5 { - gpui::black() - } else { - gpui::white() - }; - - let run_len = overlap_end.saturating_sub(overlap_start); - if run_len > 0 { - result.push(TextRun { - len: run_len, - color: text_color, - ..run.clone() - }); - - cursor = bg_range.end; - run_start = cursor; - } - } - - if run_end > cursor { - // Add the part after the background range - result.push(TextRun { - len: run_end - cursor, - ..run.clone() - }); - } - - cursor = run_end; - } - - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_editor_scrollbar_layout_uses_current_scroll_size() { - let input_bounds = Bounds::new(point(px(10.), px(20.)), size(px(300.), px(80.))); - let paddings = Edges { - top: px(2.), - right: px(3.), - bottom: px(5.), - left: px(7.), - }; - - let layout = - EditorScrollbarLayout::new(input_bounds, px(40.), size(px(1000.), px(200.)), paddings); - - assert_eq!( - layout.bounds, - Bounds::new(point(px(47.), px(18.)), size(px(266.), px(87.))) - ); - assert_eq!(layout.scroll_size, size(px(976.), px(200.))); - - let layout_without_gutter = - EditorScrollbarLayout::new(input_bounds, px(0.), size(px(500.), px(120.)), paddings); - - assert_eq!( - layout_without_gutter.bounds, - Bounds::new(point(px(10.), px(18.)), size(px(303.), px(87.))) - ); - assert_eq!(layout_without_gutter.scroll_size, size(px(513.), px(120.))); - } - - #[test] - fn test_auto_grow_scroll_offset_is_clamped_to_current_viewport() { - let mode = InputMode::auto_grow(3, 8); - - assert_eq!( - clamp_auto_grow_vertical_scroll_offset(&mode, px(-260.), px(340.), px(160.)), - px(-180.) - ); - assert_eq!( - clamp_auto_grow_vertical_scroll_offset(&mode, px(-40.), px(340.), px(160.)), - px(-40.) - ); - assert_eq!( - clamp_auto_grow_vertical_scroll_offset(&mode, px(20.), px(340.), px(160.)), - px(0.) - ); - - let plain_text = InputMode::plain_text().multi_line(true); - assert_eq!( - clamp_auto_grow_vertical_scroll_offset(&plain_text, px(-260.), px(340.), px(160.)), - px(-260.) - ); - } - - #[test] - fn test_runs_for_range() { - let run = TextRun { - len: 0, - font: gpui::font(".SystemUIFont"), - color: gpui::black(), - background_color: None, - underline: None, - strikethrough: None, - }; - - // use hello this-is-test - let runs = vec![ - // use - TextRun { - len: 3, - ..run.clone() - }, - // \s - TextRun { - len: 1, - ..run.clone() - }, - // hello - TextRun { - len: 5, - ..run.clone() - }, - // \s - TextRun { - len: 1, - ..run.clone() - }, - // this-is-test - TextRun { - len: 12, - ..run.clone() - }, - ]; - - #[track_caller] - fn assert_runs(actual: Vec, expected: &[usize]) { - let left = actual.iter().map(|run| run.len).collect::>(); - assert_eq!(left, expected); - } - - assert_runs(runs_for_range(&runs, 0, &(0..0)), &[]); - assert_runs(runs_for_range(&runs, 0, &(0..100)), &[3, 1, 5, 1, 12]); - - assert_runs(runs_for_range(&runs, 0, &(0..6)), &[3, 1, 2]); - assert_runs(runs_for_range(&runs, 0, &(1..6)), &[2, 1, 2]); - assert_runs(runs_for_range(&runs, 0, &(3..10)), &[1, 5, 1]); - assert_runs(runs_for_range(&runs, 0, &(5..8)), &[3]); - assert_runs(runs_for_range(&runs, 3, &(0..3)), &[1, 2]); - assert_runs(runs_for_range(&runs, 3, &(2..10)), &[4, 1, 3]); - assert_runs(runs_for_range(&runs, 9, &(0..8)), &[1, 7]); - } - - #[test] - fn test_placeholder_line_runs() { - let run = TextRun { - len: 0, - font: gpui::font(".SystemUIFont"), - color: gpui::black(), - background_color: None, - underline: None, - strikethrough: None, - }; - - let runs = vec![ - TextRun { - len: 2, - ..run.clone() - }, - TextRun { - len: 2, - ..run.clone() - }, - TextRun { len: 1, ..run }, - ]; - - let placeholder_runs = placeholder_line_runs("ab\n\nc", &runs); - - let lines = placeholder_runs - .iter() - .map(|(line, _)| *line) - .collect::>(); - assert_eq!(lines, vec!["ab", "", "c"]); - - let run_lengths = placeholder_runs - .iter() - .map(|(_, line_runs)| line_runs.iter().map(|run| run.len).collect::>()) - .collect::>(); - assert_eq!(run_lengths, vec![vec![2], vec![], vec![1]]); - } - - #[test] - fn test_split_runs_by_bg_segments() { - let run = TextRun { - len: 0, - font: gpui::font(".SystemUIFont"), - color: gpui::blue(), - background_color: None, - underline: None, - strikethrough: None, - }; - - let runs = vec![ - TextRun { - len: 5, - ..run.clone() - }, - TextRun { - len: 7, - ..run.clone() - }, - TextRun { - len: 24, - ..run.clone() - }, - ]; - - let bg_segments = vec![(8..12, gpui::red()), (12..18, gpui::blue())]; - let result = split_runs_by_bg_segments(5, &runs, &bg_segments); - assert_eq!( - result.iter().map(|run| run.len).collect::>(), - vec![3, 2, 2, 5, 1, 23] - ); - assert_eq!(result[0].color, gpui::blue()); - assert_eq!(result[1].color, gpui::black()); - assert_eq!(result[2].color, gpui::black()); - assert_eq!(result[3].color, gpui::black()); - assert_eq!(result[4].color, gpui::black()); - assert_eq!(result[5].color, gpui::blue()); - } -} diff --git a/crates/ui/src/input/indent.rs b/crates/ui/src/input/indent.rs deleted file mode 100644 index 2f38e49b..00000000 --- a/crates/ui/src/input/indent.rs +++ /dev/null @@ -1,269 +0,0 @@ -use gpui::{Context, EntityInputHandler, SharedString, Window}; -use ropey::RopeSlice; - -use crate::input::mode::InputMode; -use crate::input::{Indent, IndentInline, InputState, Outdent, OutdentInline}; - -#[derive(Debug, Copy, Clone)] -pub struct TabSize { - /// Default is 2 - pub tab_size: usize, - /// Set true to use `\t` as tab indent, default is false - pub hard_tabs: bool, -} - -impl Default for TabSize { - fn default() -> Self { - Self { - tab_size: 2, - hard_tabs: false, - } - } -} - -impl TabSize { - pub(super) fn to_string(self) -> SharedString { - if self.hard_tabs { - "\t".into() - } else { - " ".repeat(self.tab_size).into() - } - } - - /// Count the indent size of the line in spaces. - pub fn indent_count(&self, line: &RopeSlice) -> usize { - let mut count = 0; - for ch in line.chars() { - match ch { - '\t' => count += self.tab_size, - ' ' => count += 1, - _ => break, - } - } - - count - } -} - -impl InputState { - /// Set the tab size for the input. - /// - /// Only for [`InputMode::PlainText`] mode with multi_line. - pub fn tab_size(mut self, tab: TabSize) -> Self { - debug_assert!(self.mode.is_multi_line()); - if let InputMode::PlainText { tab: t, .. } = &mut self.mode { - *t = tab; - } - self - } - - pub(super) fn indent_inline( - &mut self, - _: &IndentInline, - window: &mut Window, - cx: &mut Context, - ) { - self.indent(false, window, cx); - } - - pub(super) fn indent_block(&mut self, _: &Indent, window: &mut Window, cx: &mut Context) { - self.indent(true, window, cx); - } - - pub(super) fn outdent_inline( - &mut self, - _: &OutdentInline, - window: &mut Window, - cx: &mut Context, - ) { - self.outdent(false, window, cx); - } - - pub(super) fn outdent_block( - &mut self, - _: &Outdent, - window: &mut Window, - cx: &mut Context, - ) { - self.outdent(true, window, cx); - } - - pub(super) fn indent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { - if !self.mode.is_indentable() { - cx.propagate(); - return; - }; - - let tab_indent = self.mode.tab_size().to_string(); - let selected_range = self.selected_range; - let mut added_len = 0; - let is_selected = !self.selected_range.is_empty(); - - if is_selected || block { - let start_offset = self.start_of_line_of_selection(window, cx); - let mut offset = start_offset; - - let selected_text = self - .text_for_range( - self.range_to_utf16(&(offset..selected_range.end)), - &mut None, - window, - cx, - ) - .unwrap_or("".into()); - - for line in selected_text.split('\n') { - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset))), - &tab_indent, - window, - cx, - ); - added_len += tab_indent.len(); - // +1 for "\n", the `\r` is included in the `line`. - offset += line.len() + tab_indent.len() + 1; - } - - if is_selected { - self.selected_range = (start_offset..selected_range.end + added_len).into(); - } else { - self.selected_range = - (selected_range.start + added_len..selected_range.end + added_len).into(); - } - } else { - // Selected none - let offset = self.selected_range.start; - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset))), - &tab_indent, - window, - cx, - ); - added_len = tab_indent.len(); - - self.selected_range = - (selected_range.start + added_len..selected_range.end + added_len).into(); - } - } - - pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { - if !self.mode.is_indentable() { - cx.propagate(); - return; - }; - - let tab_indent = self.mode.tab_size().to_string(); - let selected_range = self.selected_range; - let mut removed_len = 0; - let is_selected = !self.selected_range.is_empty(); - - if is_selected || block { - let start_offset = self.start_of_line_of_selection(window, cx); - let mut offset = start_offset; - - let selected_text = self - .text_for_range( - self.range_to_utf16(&(offset..selected_range.end)), - &mut None, - window, - cx, - ) - .unwrap_or("".into()); - - for line in selected_text.split('\n') { - if line.starts_with(tab_indent.as_ref()) { - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), - "", - window, - cx, - ); - removed_len += tab_indent.len(); - - // +1 for "\n" - offset += line.len().saturating_sub(tab_indent.len()) + 1; - } else { - offset += line.len() + 1; - } - } - - if is_selected { - self.selected_range = - (start_offset..selected_range.end.saturating_sub(removed_len)).into(); - } else { - self.selected_range = (selected_range.start.saturating_sub(removed_len) - ..selected_range.end.saturating_sub(removed_len)) - .into(); - } - } else { - // Selected none - let start_offset = self.selected_range.start; - let offset = self.start_of_line_of_selection(window, cx); - let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); - // FIXME: To improve performance - if self - .text - .slice(offset..self.text.len()) - .to_string() - .starts_with(tab_indent.as_ref()) - { - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), - "", - window, - cx, - ); - removed_len = tab_indent.len(); - let new_offset = start_offset.saturating_sub(removed_len); - self.selected_range = (new_offset..new_offset).into(); - } - } - } -} - -#[cfg(test)] -mod tests { - use ropey::RopeSlice; - - use super::TabSize; - - #[test] - fn test_tab_size() { - let tab = TabSize { - tab_size: 2, - hard_tabs: false, - }; - assert_eq!(tab.to_string(), " "); - let tab = TabSize { - tab_size: 4, - hard_tabs: false, - }; - assert_eq!(tab.to_string(), " "); - - let tab = TabSize { - tab_size: 2, - hard_tabs: true, - }; - assert_eq!(tab.to_string(), "\t"); - let tab = TabSize { - tab_size: 4, - hard_tabs: true, - }; - assert_eq!(tab.to_string(), "\t"); - } - - #[test] - fn test_tab_size_indent_count() { - let tab = TabSize { - tab_size: 4, - hard_tabs: false, - }; - assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0); - assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 2); - assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 4); - assert_eq!(tab.indent_count(&RopeSlice::from("\tabc")), 4); - assert_eq!(tab.indent_count(&RopeSlice::from(" \tabc")), 6); - assert_eq!(tab.indent_count(&RopeSlice::from(" \t abc ")), 6); - assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0); - } -} diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 5efa0a07..d9dddd32 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -1,31 +1,76 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity, Hsla, InteractiveElement as _, - IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled, - TextAlign, Window, div, px, relative, + AnyElement, App, DefiniteLength, Edges, Entity, Hsla, InteractiveElement as _, IntoElement, + MouseButton, ParentElement as _, Pixels, Rems, RenderOnce, StyleRefinement, Styled, TextAlign, + Window, div, px, relative, }; +use gpui_base::InputBase; +use gpui_base::input::{InputBaseState, InputEditorStyle, InputMode, InputModeKind, TextareaMode}; use theme::ActiveTheme; -use super::InputState; -use super::element::EditorScrollbar; use crate::button::{Button, ButtonVariants as _}; use crate::indicator::Indicator; use crate::input::clear_button; use crate::{IconName, Selectable, Sizable, Size, StyleSized, StyledExt, h_flex, v_flex}; -/// Returns `(background, foreground)` colors for input-like components. -pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) { +/// The background of an input frame, which reads muted while the input is disabled. +fn input_background(disabled: bool, cx: &App) -> Hsla { if disabled { - (cx.theme().surface_background, cx.theme().text_muted) + cx.theme().surface_background } else { - (cx.theme().elevated_surface_background, cx.theme().text) + cx.theme().elevated_surface_background } } -/// A text input element bind to an [`InputState`]. +/// The colors base paints input text with, read from the coop theme. +/// +/// Base fills in any color left transparent from its own palette, and that +/// palette is only a projection of this one, so every color coop paints with is +/// named here rather than left to resolve. +fn input_editor_style(cx: &App) -> InputEditorStyle { + let theme = cx.theme(); + InputEditorStyle { + foreground: theme.text, + muted_foreground: theme.text_muted, + background: theme.elevated_surface_background, + border: theme.border, + selection: theme.selection, + caret: theme.cursor, + ..InputEditorStyle::default() + } +} + +/// The input's own padding, resolved to pixels. +/// +/// Base applies the multi-line padding itself so that the text, the gutter, and +/// the scrollbar share one inset, and the single-line frame carries its own. +/// Both come from the same size table, resolved through the window's rem size. +fn input_paddings(size: Size, style: &StyleRefinement, window: &Window) -> Edges { + let mut probe = div().input_px(size).input_py(size).refine_style(style); + let padding = probe.style().padding.clone(); + let base_size = window.text_style().font_size; + let rem_size = window.rem_size(); + let resolve = |value: Option| { + value + .map(|value| value.to_pixels(base_size, rem_size)) + .unwrap_or(px(0.)) + }; + + Edges { + left: resolve(padding.left), + right: resolve(padding.right), + top: resolve(padding.top), + bottom: resolve(padding.bottom), + } +} + +/// A text input element bound to an [`InputState`] or a [`TextareaState`]. +/// +/// The editing kind lives on the state, so `Input::new` accepts either and +/// infers which one is rendered. #[derive(IntoElement)] -pub struct Input { - state: Entity, +pub struct Input { + state: Entity>, style: StyleRefinement, size: Size, prefix: Option, @@ -39,14 +84,17 @@ pub struct Input { selected: bool, } -impl Sizable for Input { +/// A styled multi-line text input. +pub type Textarea = Input; + +impl Sizable for Input { fn with_size(mut self, size: impl Into) -> Self { self.size = size.into(); self } } -impl Selectable for Input { +impl Selectable for Input { fn selected(mut self, selected: bool) -> Self { self.selected = selected; self @@ -57,9 +105,9 @@ impl Selectable for Input { } } -impl Input { - /// Create a new [`Input`] element bind to the [`InputState`]. - pub fn new(state: &Entity) -> Self { +impl Input { + /// Create a new [`Input`] element bind to the given state. + pub fn new(state: &Entity>) -> Self { Self { state: state.clone(), size: Size::default(), @@ -128,8 +176,7 @@ impl Input { self } - fn render_toggle_mask_button(state: &Entity, cx: &App) -> impl IntoElement { - let _masked = state.read(cx).masked; + fn render_toggle_mask_button(state: &Entity>) -> impl IntoElement { Button::new("toggle-mask") .icon(IconName::Eye) .xsmall() @@ -137,78 +184,42 @@ impl Input { .tab_stop(false) .on_click({ let state = state.clone(); - move |_, window, cx| { - state.update(cx, |state, cx| { - state.set_masked(!state.masked, window, cx); - }) - } + move |_, window, cx| state.update(cx, |state, cx| state.toggle_masked(window, cx)) }) } - - /// This method must after the refine_style. - fn render_editor( - paddings: EdgesRefinement, - input_state: &Entity, - state: &InputState, - window: &Window, - ) -> impl IntoElement { - let base_size = window.text_style().font_size; - let rem_size = window.rem_size(); - - let paddings = Edges { - left: paddings - .left - .map(|v| v.to_pixels(base_size, rem_size)) - .unwrap_or(px(0.)), - right: paddings - .right - .map(|v| v.to_pixels(base_size, rem_size)) - .unwrap_or(px(0.)), - top: paddings - .top - .map(|v| v.to_pixels(base_size, rem_size)) - .unwrap_or(px(0.)), - bottom: paddings - .bottom - .map(|v| v.to_pixels(base_size, rem_size)) - .unwrap_or(px(0.)), - }; - - state.editor_scrollbar_paddings.set(paddings); - state.editor_scrollbar_snapshot.set(None); - - v_flex().size_full().child( - div() - .relative() - .flex_1() - .child(input_state.clone()) - .child(EditorScrollbar::new(input_state.clone())), - ) - } } -impl Styled for Input { +impl Styled for Input { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } -impl RenderOnce for Input { +impl RenderOnce for Input { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { const LINE_HEIGHT: Rems = Rems(1.25); let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left); - self.state.update(cx, |state, _| { - state.disabled = self.disabled; - state.size = self.size; - // Only for single line mode - if state.mode.is_single_line() { - state.text_align = text_align; + let multi_line = self.state.read(cx).is_multi_line(); + let editor_paddings = if multi_line { + input_paddings(self.size, &self.style, window) + } else { + Edges::default() + }; + self.state.update(cx, |state, cx| { + state.set_editor_style(input_editor_style(cx)); + state.set_editor_paddings(editor_paddings); + state.set_disabled(self.disabled, cx); + if state.is_single_line() { + state.set_text_align(text_align, cx); } }); let state = self.state.read(cx); - let _focused = state.focus_handle.is_focused(window) && !state.disabled; + let presentation = state.presentation(); + let disabled = presentation.is_disabled(); + let loading = presentation.is_loading(); + let text_is_empty = state.text().len() == 0; let gap_x = match self.size { Size::Small => px(4.), @@ -216,117 +227,49 @@ impl RenderOnce for Input { _ => px(6.), }; - let (bg, _) = input_style(state.disabled, cx); + let background = input_background(disabled, cx); + let show_clear_button = + self.cleanable && state.is_editable() && !loading && !text_is_empty && !multi_line; + let has_suffix = self.suffix.is_some() || loading || self.mask_toggle || show_clear_button; let prefix = self.prefix; let suffix = self.suffix; - let show_clear_button = self.cleanable - && !state.disabled - && !state.loading - && state.text.len() > 0 - && state.mode.is_single_line(); - let has_suffix = suffix.is_some() || state.loading || self.mask_toggle || show_clear_button; + let state_entity = self.state.clone(); - div() - .id(("input", self.state.entity_id())) + InputBase::new(("input", self.state.entity_id())) .flex() - .key_context(crate::input::CONTEXT) - .track_focus(&state.focus_handle.clone()) - .tab_index(self.tab_index) - .when(!state.disabled, |this| { - this.on_action(window.listener_for(&self.state, InputState::backspace)) - .on_action(window.listener_for(&self.state, InputState::delete)) - .on_action( - window.listener_for(&self.state, InputState::delete_to_beginning_of_line), - ) - .on_action(window.listener_for(&self.state, InputState::delete_to_end_of_line)) - .on_action(window.listener_for(&self.state, InputState::delete_previous_word)) - .on_action(window.listener_for(&self.state, InputState::delete_next_word)) - .on_action(window.listener_for(&self.state, InputState::enter)) - .on_action(window.listener_for(&self.state, InputState::escape)) - .on_action(window.listener_for(&self.state, InputState::paste)) - .on_action(window.listener_for(&self.state, InputState::cut)) - .on_action(window.listener_for(&self.state, InputState::undo)) - .on_action(window.listener_for(&self.state, InputState::redo)) - .when(state.mode.is_multi_line(), |this| { - this.on_action(window.listener_for(&self.state, InputState::indent_inline)) - .on_action(window.listener_for(&self.state, InputState::outdent_inline)) - .on_action(window.listener_for(&self.state, InputState::indent_block)) - .on_action(window.listener_for(&self.state, InputState::outdent_block)) - }) - }) - .on_action(window.listener_for(&self.state, InputState::left)) - .on_action(window.listener_for(&self.state, InputState::right)) - .on_action(window.listener_for(&self.state, InputState::select_left)) - .on_action(window.listener_for(&self.state, InputState::select_right)) - .when(state.mode.is_multi_line(), |this| { - this.on_action(window.listener_for(&self.state, InputState::up)) - .on_action(window.listener_for(&self.state, InputState::down)) - .on_action(window.listener_for(&self.state, InputState::select_up)) - .on_action(window.listener_for(&self.state, InputState::select_down)) - .on_action(window.listener_for(&self.state, InputState::page_up)) - .on_action(window.listener_for(&self.state, InputState::page_down)) - }) - .on_action(window.listener_for(&self.state, InputState::select_all)) - .on_action(window.listener_for(&self.state, InputState::select_to_start_of_line)) - .on_action(window.listener_for(&self.state, InputState::select_to_end_of_line)) - .on_action(window.listener_for(&self.state, InputState::select_to_previous_word)) - .on_action(window.listener_for(&self.state, InputState::select_to_next_word)) - .on_action(window.listener_for(&self.state, InputState::home)) - .on_action(window.listener_for(&self.state, InputState::end)) - .on_action(window.listener_for(&self.state, InputState::move_to_start)) - .on_action(window.listener_for(&self.state, InputState::move_to_end)) - .on_action(window.listener_for(&self.state, InputState::move_to_previous_word)) - .on_action(window.listener_for(&self.state, InputState::move_to_next_word)) - .on_action(window.listener_for(&self.state, InputState::select_to_start)) - .on_action(window.listener_for(&self.state, InputState::select_to_end)) - .on_action(window.listener_for(&self.state, InputState::show_character_palette)) - .on_action(window.listener_for(&self.state, InputState::copy)) - .on_key_down(window.listener_for(&self.state, InputState::on_key_down)) - .on_mouse_down( - MouseButton::Left, - window.listener_for(&self.state, InputState::on_mouse_down), - ) - .on_mouse_down( - MouseButton::Right, - window.listener_for(&self.state, InputState::on_mouse_down), - ) - .on_mouse_up( - MouseButton::Left, - window.listener_for(&self.state, InputState::on_mouse_up), - ) - .on_mouse_up( - MouseButton::Right, - window.listener_for(&self.state, InputState::on_mouse_up), - ) - .on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel)) .size_full() .line_height(LINE_HEIGHT) - .input_px(self.size) - .input_py(self.size) + .when(!multi_line, |this| { + this.input_px(self.size).input_py(self.size) + }) .input_h(self.size) - .input_font_size(self.size) - .when(!self.disabled, |this| this.cursor_text()) + .when(!disabled, |this| this.cursor_text()) + .on_mouse_down(MouseButton::Left, { + let state_entity = state_entity.clone(); + move |_, window, cx| state_entity.update(cx, |state, cx| state.focus(window, cx)) + }) .items_center() - .when(state.mode.is_multi_line(), |this| { + .when(multi_line, |this| { this.h_auto() .when_some(self.height, |this, height| this.h(height)) }) .when(self.appearance, |this| { - this.bg(bg) + this.bg(background) .when(self.disabled, |this| this.opacity(0.5)) .rounded(cx.theme().radius) }) - .items_center() + .tab_index(self.tab_index) .gap(gap_x) .refine_style(&self.style) .children(prefix) - .when(state.mode.is_multi_line(), |mut this| { - let paddings = this.style().padding.clone(); - this.child(Self::render_editor(paddings, &self.state, state, window)) - }) - .when(!state.mode.is_multi_line(), |this| { - this.child(self.state.clone()) + .when(!multi_line, |this| this.child(state_entity.clone())) + .when(multi_line, |this| { + this.child( + v_flex() + .size_full() + .child(div().relative().flex_1().child(state_entity.clone())), + ) }) .when(has_suffix, |this| { this.pr_2().child( @@ -334,13 +277,13 @@ impl RenderOnce for Input { .id("suffix") .gap(gap_x) .items_center() - .when(state.loading, |this| this.child(Indicator::new())) + .when(loading, |this| this.child(Indicator::new())) .when(self.mask_toggle, |this| { - this.child(Self::render_toggle_mask_button(&self.state, cx)) + this.child(Self::render_toggle_mask_button(&state_entity)) }) .when(show_clear_button, |this| { this.child(clear_button(cx).on_click({ - let state = self.state.clone(); + let state = state_entity.clone(); move |_, window, cx| { state.update(cx, |state, cx| { state.clean(window, cx); diff --git a/crates/ui/src/input/mask_pattern.rs b/crates/ui/src/input/mask_pattern.rs deleted file mode 100644 index e78f234a..00000000 --- a/crates/ui/src/input/mask_pattern.rs +++ /dev/null @@ -1,409 +0,0 @@ -use gpui::SharedString; - -#[derive(Clone, PartialEq, Debug)] -pub enum MaskToken { - /// 0 Digit, equivalent to `[0]` - // Digit0, - /// Digit, equivalent to `[0-9]` - Digit, - /// Letter, equivalent to `[a-zA-Z]` - Letter, - /// Letter or digit, equivalent to `[a-zA-Z0-9]` - LetterOrDigit, - /// Separator - Sep(char), - /// Any character - Any, -} - -#[allow(unused)] -impl MaskToken { - /// Check if the token is any character. - pub fn is_any(&self) -> bool { - matches!(self, MaskToken::Any) - } - - /// Check if the token is a match for the given character. - /// - /// The separator is always a match any input character. - fn is_match(&self, ch: char) -> bool { - match self { - MaskToken::Digit => ch.is_ascii_digit(), - MaskToken::Letter => ch.is_ascii_alphabetic(), - MaskToken::LetterOrDigit => ch.is_ascii_alphanumeric(), - MaskToken::Any => true, - MaskToken::Sep(c) => *c == ch, - } - } - - /// Is the token a separator (Can be ignored) - fn is_sep(&self) -> bool { - matches!(self, MaskToken::Sep(_)) - } - - /// Check if the token is a number. - pub fn is_number(&self) -> bool { - matches!(self, MaskToken::Digit) - } - - pub fn placeholder(&self) -> char { - match self { - MaskToken::Sep(c) => *c, - _ => '_', - } - } - - fn mask_char(&self, ch: char) -> char { - match self { - MaskToken::Digit | MaskToken::LetterOrDigit | MaskToken::Letter => ch, - MaskToken::Sep(c) => *c, - MaskToken::Any => ch, - } - } - - fn unmask_char(&self, ch: char) -> Option { - match self { - MaskToken::Digit => Some(ch), - MaskToken::Letter => Some(ch), - MaskToken::LetterOrDigit => Some(ch), - MaskToken::Any => Some(ch), - _ => None, - } - } -} - -#[derive(Clone, Default)] -pub enum MaskPattern { - #[default] - None, - Pattern { - pattern: SharedString, - tokens: Vec, - }, - Number { - /// Group separator, e.g. "," or " " - separator: Option, - /// Number of fraction digits, e.g. 2 for 123.45 - fraction: Option, - }, -} - -impl From<&str> for MaskPattern { - fn from(pattern: &str) -> Self { - Self::new(pattern) - } -} - -impl MaskPattern { - /// Create a new mask pattern - /// - /// - `9` - Digit - /// - `A` - Letter - /// - `#` - Letter or Digit - /// - `*` - Any character - /// - other characters - Separator - /// - /// For example: - /// - /// - `(999)999-9999` - US phone number: (123)456-7890 - /// - `99999-9999` - ZIP code: 12345-6789 - /// - `AAAA-99-####` - Custom pattern: ABCD-12-3AB4 - /// - `*999*` - Custom pattern: (123) or [123] - pub fn new(pattern: &str) -> Self { - let tokens = pattern - .chars() - .map(|ch| match ch { - // '0' => MaskToken::Digit0, - '9' => MaskToken::Digit, - 'A' => MaskToken::Letter, - '#' => MaskToken::LetterOrDigit, - '*' => MaskToken::Any, - _ => MaskToken::Sep(ch), - }) - .collect(); - - Self::Pattern { - pattern: pattern.to_owned().into(), - tokens, - } - } - - #[allow(unused)] - fn tokens(&self) -> Option<&Vec> { - match self { - Self::Pattern { tokens, .. } => Some(tokens), - Self::Number { .. } => None, - Self::None => None, - } - } - - /// Create a new mask pattern with group separator, e.g. "," or " " - pub fn number(sep: Option) -> Self { - Self::Number { - separator: sep, - fraction: None, - } - } - - pub fn placeholder(&self) -> Option { - match self { - Self::Pattern { tokens, .. } => { - Some(tokens.iter().map(|token| token.placeholder()).collect()) - } - Self::Number { .. } => None, - Self::None => None, - } - } - - /// Return true if the mask pattern is None or no any pattern. - pub fn is_none(&self) -> bool { - match self { - Self::Pattern { tokens, .. } => tokens.is_empty(), - Self::Number { .. } => false, - Self::None => true, - } - } - - /// Check is the mask text is valid. - /// - /// If the mask pattern is None, always return true. - pub fn is_valid(&self, mask_text: &str) -> bool { - if self.is_none() { - return true; - } - - let mut text_index = 0; - let mask_text_chars: Vec = mask_text.chars().collect(); - match self { - Self::Pattern { tokens, .. } => { - for token in tokens { - if text_index >= mask_text_chars.len() { - break; - } - - let ch = mask_text_chars[text_index]; - if token.is_match(ch) { - text_index += 1; - } - } - text_index == mask_text.len() - } - Self::Number { separator, .. } => { - if mask_text.is_empty() { - return true; - } - - // check if the text is valid number - let mut parts = mask_text.split('.'); - let int_part = parts.next().unwrap_or(""); - let frac_part = parts.next(); - - if int_part.is_empty() { - return false; - } - - let sign_positions: Vec = int_part - .chars() - .enumerate() - .filter_map(|(i, ch)| match is_sign(&ch) { - true => Some(i), - false => None, - }) - .collect(); - - // only one sign is valid - // sign is only valid at the beginning of the string - if sign_positions.len() > 1 || sign_positions.first() > Some(&0) { - return false; - } - - // check if the integer part is valid - if !int_part.chars().enumerate().all(|(i, ch)| { - ch.is_ascii_digit() || is_sign(&ch) && i == 0 || Some(ch) == *separator - }) { - return false; - } - - // check if the fraction part is valid - if let Some(frac) = frac_part - && !frac - .chars() - .all(|ch| ch.is_ascii_digit() || Some(ch) == *separator) - { - return false; - } - - true - } - Self::None => true, - } - } - - /// Check if valid input char at the given position. - pub fn is_valid_at(&self, ch: char, pos: usize) -> bool { - if self.is_none() { - return true; - } - - match self { - Self::Pattern { tokens, .. } => { - if let Some(token) = tokens.get(pos) { - if token.is_match(ch) { - return true; - } - - if token.is_sep() { - // If next token is match, it's valid - if let Some(next_token) = tokens.get(pos + 1) - && next_token.is_match(ch) - { - return true; - } - } - } - - false - } - Self::Number { .. } => true, - Self::None => true, - } - } - - /// Format the text according to the mask pattern - /// - /// For example: - /// - /// - pattern: (999)999-999 - /// - text: 123456789 - /// - mask_text: (123)456-789 - pub fn mask(&self, text: &str) -> SharedString { - if self.is_none() { - return text.to_owned().into(); - } - - match self { - Self::Number { - separator, - fraction, - } => { - if let Some(sep) = *separator { - // Remove the existing group separator - let text = text.replace(sep, ""); - - let mut parts = text.split('.'); - let int_part = parts.next().unwrap_or(""); - - // Limit the fraction part to the given range, if not enough, pad with 0 - let frac_part = parts.next().map(|part| { - part.chars() - .take(fraction.unwrap_or(usize::MAX)) - .collect::() - }); - - // Reverse the integer part for easier grouping - let mut chars: Vec = int_part.chars().rev().collect(); - - // Removing the sign from formatting to avoid cases such as: -,123 - let maybe_signed = chars.iter().position(is_sign).map(|pos| chars.remove(pos)); - - let mut result = String::new(); - for (i, ch) in chars.iter().enumerate() { - if i > 0 && i % 3 == 0 { - result.push(sep); - } - result.push(*ch); - } - let int_with_sep: String = result.chars().rev().collect(); - - let final_str = if let Some(frac) = frac_part { - if fraction == &Some(0) { - int_with_sep - } else { - format!("{}.{}", int_with_sep, frac) - } - } else { - int_with_sep - }; - - let final_str = if let Some(sign) = maybe_signed { - format!("{}{}", sign, final_str) - } else { - final_str - }; - - return final_str.into(); - } - - text.to_owned().into() - } - Self::Pattern { tokens, .. } => { - let mut result = String::new(); - let mut text_index = 0; - let text_chars: Vec = text.chars().collect(); - for (pos, token) in tokens.iter().enumerate() { - if text_index >= text_chars.len() { - break; - } - let ch = text_chars[text_index]; - // Break if expected char is not match - if !token.is_sep() && !self.is_valid_at(ch, pos) { - break; - } - let mask_ch = token.mask_char(ch); - result.push(mask_ch); - if ch == mask_ch { - text_index += 1; - continue; - } - } - result.into() - } - Self::None => text.to_owned().into(), - } - } - - /// Extract original text from masked text - pub fn unmask(&self, mask_text: &str) -> String { - match self { - Self::Number { separator, .. } => { - if let Some(sep) = *separator { - let mut result = String::new(); - for ch in mask_text.chars() { - if ch == sep { - continue; - } - result.push(ch); - } - - if result.contains('.') { - result = result.trim_end_matches('0').to_string(); - } - return result; - } - - mask_text.to_owned() - } - Self::Pattern { tokens, .. } => { - let mut result = String::new(); - let mask_text_chars: Vec = mask_text.chars().collect(); - for (text_index, token) in tokens.iter().enumerate() { - if text_index >= mask_text_chars.len() { - break; - } - let ch = mask_text_chars[text_index]; - let unmask_ch = token.unmask_char(ch); - if let Some(ch) = unmask_ch { - result.push(ch); - } - } - result - } - Self::None => mask_text.to_owned(), - } - } -} - -#[inline] -fn is_sign(ch: &char) -> bool { - matches!(ch, '+' | '-') -} diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index de8e497d..077215f6 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -1,27 +1,7 @@ -pub(super) const MASK_CHAR: char = '*'; - -mod blink_cursor; -mod change; mod clear_button; -mod cursor; -mod display_map; -mod element; -mod indent; #[allow(clippy::module_inception)] mod input; -mod mask_pattern; -mod mode; -mod movement; -mod rope_ext; -mod selection; -mod state; pub(crate) use clear_button::*; -pub use cursor::*; -pub use display_map::DisplayMap; -pub use indent::TabSize; +pub use gpui_base::input::{InputEvent, InputState, TextareaState}; pub use input::*; -pub use mask_pattern::MaskPattern; -pub use rope_ext::{InputEdit, Point, RopeExt, RopeLines}; -pub use ropey::Rope; -pub use state::*; diff --git a/crates/ui/src/input/mode.rs b/crates/ui/src/input/mode.rs deleted file mode 100644 index 30b1f43c..00000000 --- a/crates/ui/src/input/mode.rs +++ /dev/null @@ -1,145 +0,0 @@ -use super::display_map::DisplayMap; - -#[derive(Clone)] -pub(crate) enum InputMode { - /// A plain text input mode. - PlainText { - multi_line: bool, - tab: crate::input::indent::TabSize, - rows: usize, - }, - /// An auto grow input mode. - AutoGrow { - rows: usize, - min_rows: usize, - max_rows: usize, - }, -} - -impl Default for InputMode { - fn default() -> Self { - InputMode::plain_text() - } -} - -#[allow(unused)] -impl InputMode { - /// Create a plain input mode with default settings. - pub(super) fn plain_text() -> Self { - InputMode::PlainText { - multi_line: false, - tab: crate::input::indent::TabSize::default(), - rows: 1, - } - } - - /// Create an auto grow input mode with given min and max rows. - pub(super) fn auto_grow(min_rows: usize, max_rows: usize) -> Self { - InputMode::AutoGrow { - rows: min_rows, - min_rows, - max_rows, - } - } - - pub(super) fn multi_line(mut self, multi_line: bool) -> Self { - match &mut self { - InputMode::PlainText { multi_line: ml, .. } => *ml = multi_line, - InputMode::AutoGrow { .. } => {} - } - self - } - - #[inline] - pub(super) fn is_single_line(&self) -> bool { - !self.is_multi_line() - } - - #[inline] - pub(super) fn is_auto_grow(&self) -> bool { - matches!(self, InputMode::AutoGrow { .. }) - } - - #[inline] - pub(super) fn is_multi_line(&self) -> bool { - match self { - InputMode::PlainText { multi_line, .. } => *multi_line, - InputMode::AutoGrow { max_rows, .. } => *max_rows > 1, - } - } - - pub(super) fn set_rows(&mut self, new_rows: usize) { - match self { - InputMode::PlainText { rows, .. } => { - *rows = new_rows; - } - InputMode::AutoGrow { - rows, - min_rows, - max_rows, - } => { - *rows = new_rows.clamp(*min_rows, *max_rows); - } - } - } - - pub(super) fn update_auto_grow(&mut self, display_map: &DisplayMap) { - if self.is_single_line() { - return; - } - - let wrapped_lines = display_map.wrap_row_count(); - self.set_rows(wrapped_lines); - } - - /// At least 1 row be return. - pub(super) fn rows(&self) -> usize { - if !self.is_multi_line() { - return 1; - } - - match self { - InputMode::PlainText { rows, .. } => *rows, - InputMode::AutoGrow { rows, .. } => *rows, - } - .max(1) - } - - /// At least 1 row be return. - #[allow(unused)] - pub(super) fn min_rows(&self) -> usize { - match self { - InputMode::AutoGrow { min_rows, .. } => *min_rows, - _ => 1, - } - .max(1) - } - - #[allow(unused)] - pub(super) fn max_rows(&self) -> usize { - if !self.is_multi_line() { - return 1; - } - - match self { - InputMode::AutoGrow { max_rows, .. } => *max_rows, - _ => usize::MAX, - } - } - - #[inline] - pub(super) fn is_indentable(&self) -> bool { - match self { - InputMode::PlainText { multi_line, .. } => *multi_line, - _ => false, - } - } - - #[inline] - pub(super) fn tab_size(&self) -> crate::input::indent::TabSize { - match self { - InputMode::PlainText { tab, .. } => *tab, - _ => crate::input::indent::TabSize::default(), - } - } -} diff --git a/crates/ui/src/input/movement.rs b/crates/ui/src/input/movement.rs deleted file mode 100644 index 5e6160f1..00000000 --- a/crates/ui/src/input/movement.rs +++ /dev/null @@ -1,264 +0,0 @@ -use gpui::{Context, Point, Window}; - -use crate::input::{ - InputState, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight, - MoveToEnd, MoveToNextWord, MoveToPreviousWord, MoveToStart, MoveUp, RopeExt as _, -}; - -#[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum MoveDirection { - Up, - Down, -} - -impl InputState { - /// Called after moving the cursor. Updates preferred_column if we know where the cursor now is. - pub(super) fn update_preferred_column(&mut self) { - let Some(last_layout) = &self.last_layout else { - self.preferred_column = None; - return; - }; - - let point = self.text.offset_to_point(self.cursor()); - let Some(line) = last_layout.line(point.row) else { - self.preferred_column = None; - return; - }; - - let Some(pos) = line.position_for_index(point.column, last_layout, false) else { - self.preferred_column = None; - return; - }; - - self.preferred_column = Some((pos.x, point.column)); - } - - /// Move the cursor to the given offset. - /// - /// The offset is the UTF-8 offset. - /// - /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. - pub(crate) fn move_to( - &mut self, - offset: usize, - direction: Option, - cx: &mut Context, - ) { - let offset = offset.clamp(0, self.text.len()); - self.cursor_line_end_affinity = false; - self.selected_range = (offset..offset).into(); - self.scroll_to(offset, direction, cx); - self.pause_blink_cursor(cx); - self.update_preferred_column(); - cx.notify() - } - - /// Move the cursor vertically by one line (up or down) while preserving the column if possible. - /// - /// move_lines: Number of lines to move vertically (positive for down, negative for up). - pub(super) fn move_vertical( - &mut self, - move_lines: isize, - _: &mut Window, - cx: &mut Context, - ) { - if self.mode.is_single_line() { - return; - } - let Some(last_layout) = &self.last_layout else { - return; - }; - - let offset = self.cursor(); - let was_preferred_column = self.preferred_column; - - let mut display_point = self.display_map.offset_to_wrap_display_point(offset); - - // Convert wrap row → display row (skips folded rows), move, then convert back - let current_display_row = self - .display_map - .wrap_row_to_display_row(display_point.row) - .unwrap_or_else(|| { - self.display_map - .nearest_visible_display_row(display_point.row) - }); - let max_display_row = self.display_map.display_row_count().saturating_sub(1); - let target_display_row = current_display_row - .saturating_add_signed(move_lines) - .min(max_display_row); - let target_wrap_row = self - .display_map - .display_row_to_wrap_row(target_display_row) - .unwrap_or(display_point.row); - - display_point.row = target_wrap_row; - display_point.column = 0; - let mut new_offset = self.display_map.wrap_display_point_to_offset(display_point); - - if let Some((preferred_x, column)) = was_preferred_column { - // Get display point again to update local_row. - let mut next_display_point = self.display_map.offset_to_wrap_display_point(new_offset); - next_display_point.column = 0; - let next_point = self - .display_map - .wrap_display_point_to_point(next_display_point); - let line_start_offset = self.text.line_start_offset(next_point.row); - - // If in visible range, prefer to use position to get column. - if let Some(line) = last_layout.line(next_point.row) { - if let Some(x) = line.closest_index_for_position( - Point { - x: preferred_x, - y: next_display_point.local_row * last_layout.line_height, - }, - last_layout, - ) { - new_offset = line_start_offset + x; - } - } else { - // Not in visible range, use column directly. - let max_line_len = self.text.slice_line(next_point.row).len(); - new_offset = line_start_offset + column.min(max_line_len); - } - } - - self.pause_blink_cursor(cx); - let direction = if move_lines < 0 { - MoveDirection::Up - } else { - MoveDirection::Down - }; - self.move_to(new_offset, Some(direction), cx); - // Set back the preferred_column - self.preferred_column = was_preferred_column; - cx.notify(); - } - - pub(super) fn left(&mut self, _: &MoveLeft, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - if self.selected_range.is_empty() { - self.move_to(self.previous_boundary(self.cursor()), None, cx); - } else { - self.move_to(self.selected_range.start, None, cx) - } - } - - pub(super) fn right(&mut self, _: &MoveRight, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - if self.selected_range.is_empty() { - self.move_to(self.next_boundary(self.selected_range.end), None, cx); - } else { - self.move_to(self.selected_range.end, None, cx) - } - } - - pub(super) fn up(&mut self, _action: &MoveUp, window: &mut Window, cx: &mut Context) { - if self.mode.is_single_line() { - return; - } - - if !self.selected_range.is_empty() { - self.move_to( - self.previous_boundary(self.selected_range.start.saturating_sub(1)), - Some(MoveDirection::Up), - cx, - ); - } - self.pause_blink_cursor(cx); - self.move_vertical(-1, window, cx); - } - - pub(super) fn down(&mut self, _action: &MoveDown, window: &mut Window, cx: &mut Context) { - if self.mode.is_single_line() { - return; - } - - if !self.selected_range.is_empty() { - self.move_to( - self.next_boundary(self.selected_range.end.saturating_sub(1)), - Some(MoveDirection::Down), - cx, - ); - } - - self.pause_blink_cursor(cx); - self.move_vertical(1, window, cx); - } - - pub(super) fn page_up(&mut self, _: &MovePageUp, window: &mut Window, cx: &mut Context) { - if self.mode.is_single_line() { - return; - } - - let Some(last_layout) = &self.last_layout else { - return; - }; - - let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize; - self.move_vertical(-display_lines, window, cx); - } - - pub(super) fn page_down( - &mut self, - _: &MovePageDown, - window: &mut Window, - cx: &mut Context, - ) { - if self.mode.is_single_line() { - return; - } - - let Some(last_layout) = &self.last_layout else { - return; - }; - - let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize; - self.move_vertical(display_lines, window, cx); - } - - pub(super) fn home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - let offset = self.start_of_line(); - self.move_to(offset, Some(MoveDirection::Up), cx); - } - - pub(super) fn end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - let offset = self.end_of_line(); - self.move_to(offset, Some(MoveDirection::Down), cx); - self.cursor_line_end_affinity = true; - } - - pub(super) fn move_to_start( - &mut self, - _: &MoveToStart, - _: &mut Window, - cx: &mut Context, - ) { - self.move_to(0, None, cx); - } - - pub(super) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { - self.move_to(self.text.len(), None, cx); - } - - pub(super) fn move_to_previous_word( - &mut self, - _: &MoveToPreviousWord, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.previous_start_of_word(); - self.move_to(offset, None, cx); - } - - pub(super) fn move_to_next_word( - &mut self, - _: &MoveToNextWord, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.next_end_of_word(); - self.move_to(offset, None, cx); - } -} diff --git a/crates/ui/src/input/rope_ext.rs b/crates/ui/src/input/rope_ext.rs deleted file mode 100644 index 609112a9..00000000 --- a/crates/ui/src/input/rope_ext.rs +++ /dev/null @@ -1,456 +0,0 @@ -use std::ops::Range; - -use ropey::{LineType, Rope, RopeSlice}; -use sum_tree::Bias; -#[cfg(not(target_family = "wasm"))] -pub use tree_sitter::{InputEdit, Point}; - -#[cfg(target_family = "wasm")] -/// Stub type for tree-sitter Point on WASM (tree-sitter not available). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Point { - pub row: usize, - pub column: usize, -} - -#[cfg(target_family = "wasm")] -impl Point { - pub fn new(row: usize, column: usize) -> Self { - Self { row, column } - } -} - -#[cfg(target_family = "wasm")] -/// Stub type for tree-sitter InputEdit on WASM (tree-sitter not available). -#[derive(Debug, Clone, Copy)] -pub struct InputEdit { - pub start_byte: usize, - pub old_end_byte: usize, - pub new_end_byte: usize, - pub start_position: Point, - pub old_end_position: Point, - pub new_end_position: Point, -} - -pub type Position = lsp_types::Position; - -/// An iterator over the lines of a `Rope`. -pub struct RopeLines<'a> { - rope: &'a Rope, - row: usize, - end_row: usize, -} - -impl<'a> RopeLines<'a> { - /// Create a new `RopeLines` iterator. - pub fn new(rope: &'a Rope) -> Self { - let end_row = rope.lines_len(); - Self { - row: 0, - end_row, - rope, - } - } -} -impl<'a> Iterator for RopeLines<'a> { - type Item = RopeSlice<'a>; - - #[inline] - fn next(&mut self) -> Option { - if self.row >= self.end_row { - return None; - } - - let line = self.rope.slice_line(self.row); - self.row += 1; - Some(line) - } - - #[inline] - fn nth(&mut self, n: usize) -> Option { - self.row = self.row.saturating_add(n); - self.next() - } - - #[inline] - fn size_hint(&self) -> (usize, Option) { - let len = self.end_row - self.row; - (len, Some(len)) - } -} - -impl std::iter::ExactSizeIterator for RopeLines<'_> {} -impl std::iter::FusedIterator for RopeLines<'_> {} - -/// An extension trait for [`Rope`] to provide additional utility methods. -pub trait RopeExt { - /// Start offset of the line at the given row (0-based) index. - /// - /// # Example - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// assert_eq!(rope.line_start_offset(0), 0); - /// assert_eq!(rope.line_start_offset(1), 6); - /// ``` - fn line_start_offset(&self, row: usize) -> usize; - - /// Line the end offset (including `\n`) of the line at the given row (0-based) index. - /// - /// Return the end of the rope if the row is out of bounds. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// assert_eq!(rope.line_end_offset(0), 5); // "Hello\n" - /// assert_eq!(rope.line_end_offset(1), 12); // "World\r\n" - /// ``` - fn line_end_offset(&self, row: usize) -> usize; - - /// Return a line slice at the given row (0-based) index. including `\r` if present, but not `\n`. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// assert_eq!(rope.slice_line(0).to_string(), "Hello"); - /// assert_eq!(rope.slice_line(1).to_string(), "World\r"); - /// assert_eq!(rope.slice_line(2).to_string(), "This is a test 中文"); - /// assert_eq!(rope.slice_line(6).to_string(), ""); // out of bounds - /// ``` - fn slice_line(&self, row: usize) -> RopeSlice<'_>; - - /// Return a slice of rows in the given range (0-based, end exclusive). - /// - /// If the range is out of bounds, it will be clamped to the valid range. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// assert_eq!(rope.slice_lines(0..2).to_string(), "Hello\nWorld\r"); - /// assert_eq!(rope.slice_lines(1..3).to_string(), "World\r\nThis is a test 中文"); - /// assert_eq!(rope.slice_lines(2..5).to_string(), "This is a test 中文\nRope"); - /// assert_eq!(rope.slice_lines(3..10).to_string(), "Rope"); - /// assert_eq!(rope.slice_lines(5..10).to_string(), ""); // out of bounds - /// ``` - fn slice_lines(&self, rows_range: Range) -> RopeSlice<'_>; - - /// Return an iterator over all lines in the rope. - /// - /// Each line slice includes `\r` if present, but not `\n`. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// let lines: Vec<_> = rope.iter_lines().map(|r| r.to_string()).collect(); - /// assert_eq!(lines, vec!["Hello", "World\r", "This is a test 中文", "Rope"]); - /// ``` - fn iter_lines(&self) -> RopeLines<'_>; - - /// Return the number of lines in the rope. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// assert_eq!(rope.lines_len(), 4); - /// ``` - fn lines_len(&self) -> usize; - - /// Return the length of the row (0-based) in characters, including `\r` if present, but not `\n`. - /// - /// If the row is out of bounds, return 0. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// assert_eq!(rope.line_len(0), 5); // "Hello" - /// assert_eq!(rope.line_len(1), 6); // "World\r" - /// assert_eq!(rope.line_len(2), 21); // "This is a test 中文" - /// assert_eq!(rope.line_len(4), 0); // out of bounds - /// ``` - fn line_len(&self, row: usize) -> usize; - - /// Replace the text in the given byte range with new text. - /// - /// # Panics - /// - /// - If the range is not on char boundary. - /// - If the range is out of bounds. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let mut rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// rope.replace(6..11, "Universe"); - /// assert_eq!(rope.to_string(), "Hello\nUniverse\r\nThis is a test 中文\nRope"); - /// ``` - fn replace(&mut self, range: Range, new_text: &str); - - /// Get char at the given offset (byte). - /// - /// - If the offset is in the middle of a multi-byte character will panic. - /// - If the offset is out of bounds, return None. - fn char_at(&self, offset: usize) -> Option; - - /// Get the byte offset from the given line, column [`Position`] (0-based). - /// - /// The column is in characters. - fn position_to_offset(&self, line_col: &Position) -> usize; - - /// Get the line, column [`Position`] (0-based) from the given byte offset. - /// - /// The column is in characters. - fn offset_to_position(&self, offset: usize) -> Position; - - /// Get point (row, column) from the given byte offset. - /// - /// The column is in bytes. - fn offset_to_point(&self, offset: usize) -> Point; - - /// Get byte offset from the given point (row, column). - /// - /// The column is 0-based in bytes. - fn point_to_offset(&self, point: Point) -> usize; - - /// Get the word byte range at the given byte offset (0-based). - fn word_range(&self, offset: usize) -> Option>; - - /// Get word at the given byte offset (0-based). - fn word_at(&self, offset: usize) -> String; - - /// Convert offset in UTF-16 to byte offset (0-based). - /// - /// Runs in O(log N) time. - fn offset_utf16_to_offset(&self, offset_utf16: usize) -> usize; - - /// Convert byte offset (0-based) to offset in UTF-16. - /// - /// Runs in O(log N) time. - fn offset_to_offset_utf16(&self, offset: usize) -> usize; - - /// Get a clipped offset (avoid in a char boundary). - /// - /// - If Bias::Left and inside the char boundary, return the ix - 1; - /// - If Bias::Right and in inside char boundary, return the ix + 1; - /// - Otherwise return the ix. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// use sum_tree::Bias; - /// - /// let rope = Rope::from("Hello 中文🎉 test\nRope"); - /// assert_eq!(rope.clip_offset(5, Bias::Left), 5); - /// // Inside multi-byte character '中' (3 bytes) - /// assert_eq!(rope.clip_offset(7, Bias::Left), 6); - /// assert_eq!(rope.clip_offset(7, Bias::Right), 9); - /// ``` - fn clip_offset(&self, offset: usize, bias: Bias) -> usize; - - /// Convert offset in characters to byte offset (0-based). - /// - /// Run in O(n) time. - /// - /// # Example - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("a 中文🎉 test\nRope"); - /// assert_eq!(rope.char_index_to_offset(0), 0); - /// assert_eq!(rope.char_index_to_offset(1), 1); - /// assert_eq!(rope.char_index_to_offset(3), "a 中".len()); - /// assert_eq!(rope.char_index_to_offset(5), "a 中文🎉".len()); - /// ``` - fn char_index_to_offset(&self, char_index: usize) -> usize; - - /// Convert byte offset (0-based) to offset in characters. - /// - /// Run in O(n) time. - /// - /// # Example - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("a 中文🎉 test\nRope"); - /// assert_eq!(rope.offset_to_char_index(0), 0); - /// assert_eq!(rope.offset_to_char_index(1), 1); - /// assert_eq!(rope.offset_to_char_index(3), 3); - /// assert_eq!(rope.offset_to_char_index(4), 3); - /// ``` - fn offset_to_char_index(&self, offset: usize) -> usize; -} - -impl RopeExt for Rope { - fn slice_line(&self, row: usize) -> RopeSlice<'_> { - let total_lines = self.lines_len(); - if row >= total_lines { - return self.slice(0..0); - } - - let line = self.line(row, LineType::LF); - if line.len() > 0 { - let line_end = line.len() - 1; - if line.is_char_boundary(line_end) && line.char(line_end) == '\n' { - return line.slice(..line_end); - } - } - - line - } - - fn slice_lines(&self, rows_range: Range) -> RopeSlice<'_> { - let start = self.line_start_offset(rows_range.start); - let end = self.line_end_offset(rows_range.end.saturating_sub(1)); - self.slice(start..end) - } - - fn iter_lines(&self) -> RopeLines<'_> { - RopeLines::new(self) - } - - fn line_len(&self, row: usize) -> usize { - self.slice_line(row).len() - } - - fn line_start_offset(&self, row: usize) -> usize { - self.point_to_offset(Point::new(row, 0)) - } - - fn offset_to_point(&self, offset: usize) -> Point { - let offset = self.clip_offset(offset, Bias::Left); - let row = self.byte_to_line_idx(offset, LineType::LF); - let line_start = self.line_to_byte_idx(row, LineType::LF); - let column = offset.saturating_sub(line_start); - Point::new(row, column) - } - - fn point_to_offset(&self, point: Point) -> usize { - if point.row >= self.lines_len() { - return self.len(); - } - - let line_start = self.line_to_byte_idx(point.row, LineType::LF); - line_start + point.column - } - - fn position_to_offset(&self, pos: &Position) -> usize { - let line = self.slice_line(pos.line as usize); - self.line_start_offset(pos.line as usize) - + line - .chars() - .take(pos.character as usize) - .map(|c| c.len_utf8()) - .sum::() - } - - fn offset_to_position(&self, offset: usize) -> Position { - let point = self.offset_to_point(offset); - let line = self.slice_line(point.row); - let offset = line.utf16_to_byte_idx(line.byte_to_utf16_idx(point.column)); - let character = line.slice(..offset).chars().count(); - Position::new(point.row as u32, character as u32) - } - - fn line_end_offset(&self, row: usize) -> usize { - if row > self.lines_len() { - return self.len(); - } - - self.line_start_offset(row) + self.line_len(row) - } - - fn lines_len(&self) -> usize { - self.len_lines(LineType::LF) - } - - fn char_at(&self, offset: usize) -> Option { - if offset > self.len() { - return None; - } - - self.get_char(offset).ok() - } - - fn word_range(&self, offset: usize) -> Option> { - if offset >= self.len() { - return None; - } - - let mut left = String::new(); - let offset = self.clip_offset(offset, Bias::Left); - for c in self.chars_at(offset).reversed() { - if c.is_alphanumeric() || c == '_' { - left.insert(0, c); - } else { - break; - } - } - let start = offset.saturating_sub(left.len()); - - let right = self - .chars_at(offset) - .take_while(|c| c.is_alphanumeric() || *c == '_') - .collect::(); - - let end = offset + right.len(); - - if start == end { None } else { Some(start..end) } - } - - fn word_at(&self, offset: usize) -> String { - if let Some(range) = self.word_range(offset) { - self.slice(range).to_string() - } else { - String::new() - } - } - - #[inline] - fn offset_utf16_to_offset(&self, offset_utf16: usize) -> usize { - if offset_utf16 > self.len_utf16() { - return self.len(); - } - - self.utf16_to_byte_idx(offset_utf16) - } - - #[inline] - fn offset_to_offset_utf16(&self, offset: usize) -> usize { - if offset > self.len() { - return self.len_utf16(); - } - - self.byte_to_utf16_idx(offset) - } - - fn replace(&mut self, range: Range, new_text: &str) { - let range = - self.clip_offset(range.start, Bias::Left)..self.clip_offset(range.end, Bias::Right); - self.remove(range.clone()); - self.insert(range.start, new_text); - } - - fn clip_offset(&self, offset: usize, bias: Bias) -> usize { - if offset > self.len() { - return self.len(); - } - - if self.is_char_boundary(offset) { - return offset; - } - - if bias == Bias::Left { - self.floor_char_boundary(offset) - } else { - self.ceil_char_boundary(offset) - } - } - - fn char_index_to_offset(&self, char_offset: usize) -> usize { - self.chars().take(char_offset).map(|c| c.len_utf8()).sum() - } - - fn offset_to_char_index(&self, offset: usize) -> usize { - let offset = self.clip_offset(offset, Bias::Right); - self.slice(..offset).chars().count() - } -} diff --git a/crates/ui/src/input/selection.rs b/crates/ui/src/input/selection.rs deleted file mode 100644 index ad01817f..00000000 --- a/crates/ui/src/input/selection.rs +++ /dev/null @@ -1,140 +0,0 @@ -use std::ops::Range; - -use gpui::{Context, Window}; -use ropey::Rope; -use sum_tree::Bias; - -use crate::input::{InputState, RopeExt}; - -impl InputState { - /// Select the word at the given offset on double-click. - /// - /// The offset is the UTF-8 offset. - pub(super) fn select_word(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { - let Some(range) = TextSelector::word_range(&self.text, offset) else { - return; - }; - - self.selected_range = (range.start..range.end).into(); - self.selected_word_range = Some(self.selected_range); - cx.notify() - } - - /// Select the line at the given offset on triple-click. - /// - /// The offset is the UTF-8 offset. - pub(super) fn select_line(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { - let range = TextSelector::line_range(&self.text, offset); - self.selected_range = (range.start..range.end).into(); - self.selected_word_range = None; - cx.notify() - } -} - -struct TextSelector; -impl TextSelector { - /// Select a line in the given text at the specified offset. - /// - /// The offset is the UTF-8 offset. - /// - /// Returns the start and end offsets of the selected line. - pub fn line_range(text: &Rope, offset: usize) -> Range { - let offset = text.clip_offset(offset, Bias::Left); - let row = text.offset_to_point(offset).row; - let start = text.line_start_offset(row); - let end = text.line_end_offset(row); - - start..end - } - - /// Select a word in the given text at the specified offset. - /// - /// The offset is the UTF-8 offset. - /// - /// Returns the start and end offsets of the selected word. - pub fn word_range(text: &Rope, offset: usize) -> Option> { - let offset = text.clip_offset(offset, Bias::Left); - let char = text.char_at(offset)?; - let end = offset + char.len_utf8(); - let prev_chars = text.chars_at(offset).reversed().take(128); - let next_chars = text.chars_at(end).take(128); - - Some(word_range_from_chars(offset, char, prev_chars, next_chars)) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CharType { - /// a-z, A-Z, 0-9, _ - Word, - /// '\t', ' ', '\u{00A0}' etc. - Whitespace, - /// \n, \r - Newline, - /// . , ; : ( ) [ ] { } ... or CJK characters: `汉`, `🎉` etc. - Other, -} - -impl From for CharType { - fn from(c: char) -> Self { - match c { - c if is_word_char(c) => CharType::Word, - c if c == '\n' || c == '\r' => CharType::Newline, - c if c.is_whitespace() => CharType::Whitespace, - _ => CharType::Other, - } - } -} - -impl CharType { - fn is_connectable(self, c: char) -> bool { - matches!( - (self, CharType::from(c)), - (CharType::Word, CharType::Word) | (CharType::Whitespace, CharType::Whitespace) - ) - } -} - -fn is_word_char(c: char) -> bool { - matches!(c, '_') - // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc. - || c.is_ascii_alphanumeric() - // Latin script in Unicode for French, German, Spanish, etc. - || matches!(c, '\u{00C0}'..='\u{00FF}') - || matches!(c, '\u{0100}'..='\u{017F}') - || matches!(c, '\u{0180}'..='\u{024F}') - // Cyrillic for Russian, Ukrainian, etc. - || matches!(c, '\u{0400}'..='\u{04FF}') - // Vietnamese - || matches!(c, '\u{1E00}'..='\u{1EFF}') - || matches!(c, '\u{0300}'..='\u{036F}') -} - -pub(crate) fn word_range_from_chars( - offset: usize, - c: char, - prev_chars: impl Iterator, - next_chars: impl Iterator, -) -> Range { - let char_type = CharType::from(c); - let mut start = offset; - let mut end = offset + c.len_utf8(); - - for prev in prev_chars.take(128) { - if char_type.is_connectable(prev) { - start -= prev.len_utf8(); - } else { - break; - } - } - - for next in next_chars.take(128) { - if char_type.is_connectable(next) { - end += next.len_utf8(); - } else { - break; - } - } - - start..end -} diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs deleted file mode 100644 index 429ac861..00000000 --- a/crates/ui/src/input/state.rs +++ /dev/null @@ -1,2085 +0,0 @@ -//! A text input field that allows the user to enter text. -//! -//! Based on the `Input` example from the `gpui` crate. -//! https://github.com/zed-industries/zed/blob/main/crates/gpui/examples/input.rs -use std::cell::Cell; -use std::ops::Range; -use std::rc::Rc; - -use gpui::prelude::FluentBuilder as _; -use gpui::{ - Action, App, AppContext, Bounds, ClipboardItem, Context, Edges, Entity, EntityInputHandler, - EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, - KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, - Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Styled as _, - Subscription, TextAlign, UTF16Selection, Window, actions, div, point, px, -}; -use ropey::{Rope, RopeSlice}; -use serde::Deserialize; -use sum_tree::Bias; -use unicode_segmentation::*; - -use super::blink_cursor::BlinkCursor; -use super::change::Change; -use super::element::{EditorScrollbarSnapshot, TextElement}; -use super::mask_pattern::MaskPattern; -use super::mode::InputMode; -use super::{DisplayMap, MASK_CHAR}; -use crate::actions::{SelectDown, SelectLeft, SelectRight, SelectUp}; -use crate::history::History; -use crate::input::blink_cursor::CURSOR_WIDTH; -use crate::input::display_map::LineLayout; -use crate::input::element::RIGHT_MARGIN; -use crate::input::movement::MoveDirection; -use crate::input::rope_ext::Position; -use crate::input::{RopeExt as _, Selection}; -use crate::{Root, Size}; - -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = input, no_json)] -pub struct Enter { - /// Is confirm with secondary. - pub secondary: bool, - /// Whether the Shift modifier was held when Enter was pressed. - pub shift: bool, -} - -impl Enter { - /// Returns true if `action` is a primary `Enter` action (`secondary: false`), - /// regardless of whether Shift was held. - pub fn is_primary(action: &dyn Action) -> bool { - action.partial_eq(&Enter { - secondary: false, - shift: false, - }) || action.partial_eq(&Enter { - secondary: false, - shift: true, - }) - } -} - -actions!( - input, - [ - Backspace, - Delete, - DeleteToBeginningOfLine, - DeleteToEndOfLine, - DeleteToPreviousWordStart, - DeleteToNextWordEnd, - Indent, - Outdent, - IndentInline, - OutdentInline, - MoveUp, - MoveDown, - MoveLeft, - MoveRight, - MoveHome, - MoveEnd, - MovePageUp, - MovePageDown, - SelectAll, - SelectToStartOfLine, - SelectToEndOfLine, - SelectToStart, - SelectToEnd, - SelectToPreviousWordStart, - SelectToNextWordEnd, - ShowCharacterPalette, - Copy, - Cut, - Paste, - Undo, - Redo, - MoveToStartOfLine, - MoveToEndOfLine, - MoveToStart, - MoveToEnd, - MoveToPreviousWord, - MoveToNextWord, - Escape, - ] -); - -#[derive(Clone)] -pub enum InputEvent { - Change, - PressEnter { secondary: bool, shift: bool }, - Focus, - Blur, -} - -pub(super) const CONTEXT: &str = "Input"; - -pub(crate) fn init(cx: &mut App) { - cx.bind_keys([ - KeyBinding::new("backspace", Backspace, Some(CONTEXT)), - KeyBinding::new("shift-backspace", Backspace, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-backspace", Backspace, Some(CONTEXT)), - KeyBinding::new("delete", Delete, Some(CONTEXT)), - KeyBinding::new("shift-delete", Delete, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-backspace", DeleteToBeginningOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-delete", DeleteToEndOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("alt-backspace", DeleteToPreviousWordStart, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-backspace", DeleteToPreviousWordStart, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("alt-delete", DeleteToNextWordEnd, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-delete", DeleteToNextWordEnd, Some(CONTEXT)), - KeyBinding::new( - "enter", - Enter { - secondary: false, - shift: false, - }, - Some(CONTEXT), - ), - KeyBinding::new( - "shift-enter", - Enter { - secondary: false, - shift: true, - }, - Some(CONTEXT), - ), - KeyBinding::new( - "secondary-enter", - Enter { - secondary: true, - shift: false, - }, - Some(CONTEXT), - ), - KeyBinding::new("escape", Escape, Some(CONTEXT)), - KeyBinding::new("up", MoveUp, Some(CONTEXT)), - KeyBinding::new("down", MoveDown, Some(CONTEXT)), - KeyBinding::new("left", MoveLeft, Some(CONTEXT)), - KeyBinding::new("right", MoveRight, Some(CONTEXT)), - KeyBinding::new("pageup", MovePageUp, Some(CONTEXT)), - KeyBinding::new("pagedown", MovePageDown, Some(CONTEXT)), - KeyBinding::new("tab", IndentInline, Some(CONTEXT)), - KeyBinding::new("shift-tab", OutdentInline, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-]", Indent, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-]", Indent, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-[", Outdent, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-[", Outdent, Some(CONTEXT)), - KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)), - KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)), - KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)), - KeyBinding::new("shift-down", SelectDown, Some(CONTEXT)), - KeyBinding::new("home", MoveHome, Some(CONTEXT)), - KeyBinding::new("end", MoveEnd, Some(CONTEXT)), - KeyBinding::new("shift-home", SelectToStartOfLine, Some(CONTEXT)), - KeyBinding::new("shift-end", SelectToEndOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-shift-a", SelectToStartOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-shift-e", SelectToEndOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("shift-cmd-left", SelectToStartOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("shift-cmd-right", SelectToEndOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("alt-shift-left", SelectToPreviousWordStart, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-shift-left", SelectToPreviousWordStart, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("alt-shift-right", SelectToNextWordEnd, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-shift-right", SelectToNextWordEnd, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-a", SelectAll, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-a", SelectAll, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-c", Copy, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-c", Copy, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-x", Cut, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-x", Cut, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-v", Paste, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-v", Paste, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-a", MoveHome, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-left", MoveHome, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-e", MoveEnd, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-right", MoveEnd, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-z", Undo, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-shift-z", Redo, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-up", MoveToStart, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-down", MoveToEnd, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("alt-left", MoveToPreviousWord, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("alt-right", MoveToNextWord, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-left", MoveToPreviousWord, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-right", MoveToNextWord, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-shift-up", SelectToStart, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-shift-down", SelectToEnd, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-z", Undo, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)), - ]); -} - -/// Whitespace indicators for rendering spaces and tabs. -#[derive(Clone, Default)] -pub(crate) struct WhitespaceIndicators { - /// Shaped line for space character indicator (•) - pub(crate) space: ShapedLine, - /// Shaped line for tab character indicator (→) - pub(crate) tab: ShapedLine, -} - -#[derive(Clone)] -pub(super) struct LastLayout { - /// The visible range (no wrap) of lines in the viewport, the value is row (0-based) index. - /// This is the buffer line range that encompasses all visible lines. - pub(super) visible_range: Range, - /// The list of visible buffer line indices (excludes hidden/folded lines). - /// Parallel to `lines`: `visible_buffer_lines[i]` is the buffer line index of `lines[i]`. - pub(super) visible_buffer_lines: Vec, - /// Byte offset of each visible buffer line in the Rope (parallel to visible_buffer_lines/lines). - pub(super) visible_line_byte_offsets: Vec, - /// The first visible line top position in scroll viewport. - pub(super) visible_top: Pixels, - /// The range of byte offset of the visible lines. - pub(super) visible_range_offset: Range, - /// The last layout lines (Only have visible lines, no empty entries for hidden lines). - pub(super) lines: Rc>, - /// The line_height of text layout, this will change will InputElement painted. - pub(super) line_height: Pixels, - /// The wrap width of text layout, this will change will InputElement painted. - pub(super) wrap_width: Option, - /// The line number area width of text layout, if not line number, this will be 0px. - pub(super) line_number_width: Pixels, - /// The cursor position (top, left) in pixels. - pub(super) cursor_bounds: Option>, - /// The text align of the text layout. - pub(super) text_align: TextAlign, - /// The content width of the text layout. - pub(super) content_width: Pixels, -} - -impl LastLayout { - /// Get the line layout for the given buffer row (0-based). - /// - /// Uses binary search on `visible_buffer_lines` to find the line. - /// Returns None if the row is not visible (out of range or folded). - pub(crate) fn line(&self, row: usize) -> Option<&LineLayout> { - let pos = self.visible_buffer_lines.binary_search(&row).ok()?; - self.lines.get(pos) - } - - /// Get the alignment offset for the given line width. - pub(super) fn alignment_offset(&self, line_width: Pixels) -> Pixels { - match self.text_align { - TextAlign::Left => px(0.), - TextAlign::Center => (self.content_width - line_width).half().max(px(0.)), - TextAlign::Right => (self.content_width - line_width).max(px(0.)), - } - } -} - -/// InputState to keep editing state of the [`super::Input`]. -#[allow(clippy::type_complexity)] -pub struct InputState { - pub(super) focus_handle: FocusHandle, - pub(super) mode: InputMode, - pub(super) text: Rope, - pub(super) display_map: DisplayMap, - pub(super) history: History, - pub(super) blink_cursor: Entity, - pub loading: bool, - /// Range in UTF-8 length for the selected text. - /// - /// - "Hello 世界💝" = 16 - /// - "💝" = 4 - pub(super) selected_range: Selection, - pub(super) replaceable: bool, - /// Range for save the selected word, use to keep word range when drag move. - pub(super) selected_word_range: Option, - pub(super) selection_reversed: bool, - /// The marked range is the temporary insert text on IME typing. - pub(super) ime_marked_range: Option, - pub(super) last_layout: Option, - pub(super) last_cursor: Option, - /// The input container bounds - pub(super) input_bounds: Bounds, - /// The text bounds - pub(super) last_bounds: Option>, - pub(super) last_selected_range: Option, - pub(super) selecting: bool, - pub(super) size: Size, - pub(super) disabled: bool, - pub(super) masked: bool, - pub(super) clean_on_escape: bool, - pub(super) submit_on_enter: bool, - pub(super) soft_wrap: bool, - /// This flag tells the renderer to prefer the end of the current visual line. - pub(crate) cursor_line_end_affinity: bool, - pub(super) pattern: Option, - pub(super) validate: Option) -> bool + 'static>>, - pub(crate) scroll_handle: ScrollHandle, - /// The deferred scroll offset to apply on next layout. - pub(crate) deferred_scroll_offset: Option>, - /// The size of the scrollable content. - pub(crate) scroll_size: gpui::Size, - pub(super) editor_scrollbar_paddings: Cell>, - pub(super) editor_scrollbar_snapshot: Cell>, - pub(super) text_align: TextAlign, - - /// The mask pattern for formatting the input text - pub(crate) mask_pattern: MaskPattern, - pub(super) placeholder: SharedString, - - /// A flag to indicate if we should ignore the next completion event. - pub(super) silent_replace_text: bool, - /// A flag to indicate if we should emit InputEvents. - pub(super) emit_events: bool, - - /// To remember the horizontal column (x-coordinate) of the cursor position for keep column for move up/down. - /// - /// The first element is the x-coordinate (Pixels), preferred to use this. - /// The second element is the column (usize), fallback to use this. - pub(super) preferred_column: Option<(Pixels, usize)>, - _subscriptions: Vec, -} - -impl EventEmitter for InputState {} - -impl InputState { - /// Create a Input state with default [`InputMode::SingleLine`] mode. - /// - /// See also: [`Self::multi_line`], [`Self::auto_grow`] to set other mode. - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let focus_handle = cx.focus_handle().tab_stop(true); - let blink_cursor = cx.new(|_| BlinkCursor::new()); - let history = History::new().group_interval(instant::Duration::from_secs(1)); - - let _subscriptions = vec![ - // Observe the blink cursor to repaint the view when it changes. - cx.observe(&blink_cursor, |_, _, cx| cx.notify()), - // Blink the cursor when the window is active, pause when it's not. - cx.observe_window_activation(window, |input, window, cx| { - if window.is_window_active() { - let focus_handle = input.focus_handle.clone(); - if focus_handle.is_focused(window) { - input.blink_cursor.update(cx, |blink_cursor, cx| { - blink_cursor.start(cx); - }); - } - } - }), - cx.on_focus(&focus_handle, window, Self::on_focus), - cx.on_blur(&focus_handle, window, Self::on_blur), - ]; - - let text_style = window.text_style(); - - Self { - focus_handle: focus_handle.clone(), - text: "".into(), - display_map: DisplayMap::new(text_style.font(), window.rem_size(), None), - blink_cursor, - history, - selected_range: Selection::default(), - replaceable: true, - selected_word_range: None, - selection_reversed: false, - ime_marked_range: None, - input_bounds: Bounds::default(), - selecting: false, - disabled: false, - masked: false, - clean_on_escape: false, - submit_on_enter: false, - soft_wrap: true, - loading: false, - pattern: None, - validate: None, - mode: InputMode::default(), - last_layout: None, - last_bounds: None, - last_selected_range: None, - last_cursor: None, - scroll_handle: ScrollHandle::new(), - scroll_size: gpui::size(px(0.), px(0.)), - editor_scrollbar_paddings: Cell::new(Edges { - top: px(0.), - right: px(0.), - bottom: px(0.), - left: px(0.), - }), - editor_scrollbar_snapshot: Cell::new(None), - deferred_scroll_offset: None, - preferred_column: None, - placeholder: SharedString::default(), - mask_pattern: MaskPattern::default(), - text_align: TextAlign::Left, - silent_replace_text: false, - emit_events: true, - size: Size::default(), - _subscriptions, - cursor_line_end_affinity: false, - } - } - - /// Set Input to use multi line mode. - /// - /// Default rows is 2. - pub fn multi_line(mut self, multi_line: bool) -> Self { - self.mode = self.mode.multi_line(multi_line); - self - } - - /// Set Input to use [`InputMode::AutoGrow`] mode with min, max rows limit. - pub fn auto_grow(mut self, min_rows: usize, max_rows: usize) -> Self { - self.mode = InputMode::auto_grow(min_rows, max_rows); - self - } - - /// Set whether search UI allows replacement, default is true. - pub fn replaceable(mut self, allow: bool) -> Self { - self.replaceable = allow; - self - } - - /// Set placeholder - pub fn placeholder(mut self, placeholder: impl Into) -> Self { - self.placeholder = placeholder.into(); - self - } - - /// Set the number of rows for the multi-line Textarea. - /// - /// This is only used when `multi_line` is set to true. - /// - /// default: 2 - pub fn rows(mut self, rows: usize) -> Self { - match &mut self.mode { - InputMode::PlainText { rows: r, .. } => *r = rows, - InputMode::AutoGrow { - max_rows: max_r, - rows: r, - .. - } => { - *r = rows; - *max_r = rows; - } - } - self - } - - /// Set placeholder - pub fn set_placeholder( - &mut self, - placeholder: impl Into, - _: &mut Window, - cx: &mut Context, - ) { - self.placeholder = placeholder.into(); - cx.notify(); - } - - /// Find which line and sub-line the given offset belongs to, along with the position within that sub-line. - /// - /// Returns: - /// - /// - The index of the line (zero-based) containing the offset. - /// - The index of the sub-line (zero-based) within the line containing the offset. - /// - The position of the offset. - pub(super) fn line_and_position_for_offset( - &self, - offset: usize, - ) -> (usize, usize, Option>) { - let Some(last_layout) = &self.last_layout else { - return (0, 0, None); - }; - let line_height = last_layout.line_height; - - let mut y_offset = last_layout.visible_top; - for (vi, line) in last_layout.lines.iter().enumerate() { - let prev_lines_offset = last_layout.visible_line_byte_offsets[vi]; - let local_offset = offset.saturating_sub(prev_lines_offset); - if let Some(pos) = line.position_for_index(local_offset, last_layout, false) { - let sub_line_index = (pos.y / line_height) as usize; - let adjusted_pos = point(pos.x + last_layout.line_number_width, pos.y + y_offset); - return (vi, sub_line_index, Some(adjusted_pos)); - } - - y_offset += line.size(line_height).height; - } - (0, 0, None) - } - - /// Set the text of the input field. - /// - /// And the selection_range will be reset to 0..0. - pub fn set_value( - &mut self, - value: impl Into, - window: &mut Window, - cx: &mut Context, - ) { - self.history.ignore = true; - self.emit_events = false; - self.replace_text(value, window, cx); - self.history.ignore = false; - self.emit_events = true; - - // Ensure cursor to start when set text - if self.mode.is_single_line() { - self.selected_range = (self.text.len()..self.text.len()).into(); - } else { - self.selected_range.clear(); - } - - // Move scroll to top - self.scroll_handle.set_offset(point(px(0.), px(0.))); - - self.history.clear(); - cx.notify(); - } - - /// Insert text at the current cursor position. - /// - /// And the cursor will be moved to the end of inserted text. - pub fn insert( - &mut self, - text: impl Into, - window: &mut Window, - cx: &mut Context, - ) { - let was_disabled = self.disabled; - self.disabled = false; - let text: SharedString = text.into(); - let range_utf16 = self.range_to_utf16(&(self.cursor()..self.cursor())); - self.replace_text_in_range_silent(Some(range_utf16), &text, window, cx); - self.selected_range = (self.selected_range.end..self.selected_range.end).into(); - self.disabled = was_disabled; - } - - /// Replace text at the current cursor position. - /// - /// And the cursor will be moved to the end of replaced text. - pub fn replace( - &mut self, - text: impl Into, - window: &mut Window, - cx: &mut Context, - ) { - let was_disabled = self.disabled; - self.disabled = false; - let text: SharedString = text.into(); - self.replace_text_in_range_silent(None, &text, window, cx); - self.selected_range = (self.selected_range.end..self.selected_range.end).into(); - self.disabled = was_disabled; - } - - fn replace_text( - &mut self, - text: impl Into, - window: &mut Window, - cx: &mut Context, - ) { - let was_disabled = self.disabled; - self.disabled = false; - let text: SharedString = text.into(); - let range = 0..self.text.chars().map(|c| c.len_utf16()).sum(); - self.replace_text_in_range_silent(Some(range), &text, window, cx); - self.disabled = was_disabled; - } - - /// Set with disabled mode. - /// - /// See also: [`Self::set_disabled`], [`Self::is_disabled`]. - #[allow(unused)] - pub(crate) fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } - - /// Set with password masked state. - /// - /// Only for [`InputMode::SingleLine`] mode. - pub fn masked(mut self, masked: bool) -> Self { - debug_assert!(self.mode.is_single_line()); - self.masked = masked; - self - } - - /// Set the password masked state of the input field. - /// - /// Only for [`InputMode::SingleLine`] mode. - pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context) { - debug_assert!(self.mode.is_single_line()); - self.masked = masked; - cx.notify(); - } - - /// Set true to clear the input by pressing Escape key. - pub fn clean_on_escape(mut self) -> Self { - self.clean_on_escape = true; - self - } - - /// Set true to treat `Enter` as a submit action in multi-line mode, - /// while `Shift+Enter` inserts a newline. - /// - /// Default is `false` (both `Enter` and `Shift+Enter` insert a newline). - pub fn submit_on_enter(mut self, submit: bool) -> Self { - self.submit_on_enter = submit; - self - } - - /// Set the soft wrap mode for multi-line input, default is true. - pub fn soft_wrap(mut self, wrap: bool) -> Self { - debug_assert!(self.mode.is_multi_line()); - self.soft_wrap = wrap; - self - } - - /// Update the soft wrap mode for multi-line input, default is true. - pub fn set_soft_wrap(&mut self, wrap: bool, _: &mut Window, cx: &mut Context) { - debug_assert!(self.mode.is_multi_line()); - self.soft_wrap = wrap; - if wrap { - let wrap_width = self - .last_layout - .as_ref() - .and_then(|b| b.wrap_width) - .unwrap_or(self.input_bounds.size.width); - - self.display_map.on_layout_changed(Some(wrap_width), cx); - - // Reset scroll to left 0 - let mut offset = self.scroll_handle.offset(); - offset.x = px(0.); - self.scroll_handle.set_offset(offset); - } else { - self.display_map.on_layout_changed(None, cx); - } - cx.notify(); - } - - /// Set the regular expression pattern of the input field. - /// - /// Only for [`InputMode::SingleLine`] mode. - pub fn pattern(mut self, pattern: regex::Regex) -> Self { - debug_assert!(self.mode.is_single_line()); - self.pattern = Some(pattern); - self - } - - /// Set the regular expression pattern of the input field with reference. - /// - /// Only for [`InputMode::SingleLine`] mode. - pub fn set_pattern( - &mut self, - pattern: regex::Regex, - _window: &mut Window, - _cx: &mut Context, - ) { - debug_assert!(self.mode.is_single_line()); - self.pattern = Some(pattern); - } - - /// Set the validation function of the input field. - /// - /// Only for [`InputMode::SingleLine`] mode. - pub fn validate(mut self, f: impl Fn(&str, &mut Context) -> bool + 'static) -> Self { - debug_assert!(self.mode.is_single_line()); - self.validate = Some(Box::new(f)); - self - } - - /// Set true to show spinner at the input right. - /// - /// Only for [`InputMode::SingleLine`] mode. - pub fn set_loading(&mut self, loading: bool, cx: &mut Context) { - debug_assert!(self.mode.is_single_line()); - self.loading = loading; - cx.notify(); - } - - /// Set the default value of the input field. - pub fn default_value(mut self, value: impl Into) -> Self { - let text: SharedString = value.into(); - self.text = Rope::from(text.as_str()); - self - } - - /// Return the value of the input field. - pub fn value(&self) -> SharedString { - SharedString::new(self.text.to_string()) - } - - /// Return the portion of the value within the input field that - /// is selected by the user - pub fn selected_value(&self) -> SharedString { - SharedString::new(self.selected_text().to_string()) - } - - /// Return the value without mask. - pub fn unmask_value(&self) -> SharedString { - self.mask_pattern.unmask(&self.text.to_string()).into() - } - - /// Return the text [`Rope`] of the input field. - pub fn text(&self) -> &Rope { - &self.text - } - - /// Return the (0-based) [`Position`] of the cursor. - pub fn cursor_position(&self) -> Position { - let offset = self.cursor(); - self.text.offset_to_position(offset) - } - - /// Set (0-based) [`Position`] of the cursor. - /// - /// This will move the cursor to the specified line and column, and update the selection range. - pub fn set_cursor_position( - &mut self, - position: impl Into, - window: &mut Window, - cx: &mut Context, - ) { - let position: Position = position.into(); - let offset = self.text.position_to_offset(&position); - - self.move_to(offset, None, cx); - self.update_preferred_column(); - self.focus(window, cx); - } - - /// Focus the input field. - pub fn focus(&self, window: &mut Window, cx: &mut Context) { - self.focus_handle.focus(window, cx); - self.blink_cursor.update(cx, |cursor, cx| { - cursor.start(cx); - }); - } - - pub(super) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { - self.select_to(self.previous_boundary(self.cursor()), cx); - } - - pub(super) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { - self.select_to(self.next_boundary(self.cursor()), cx); - } - - pub(super) fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { - if self.mode.is_single_line() { - return; - } - let offset = self.start_of_line().saturating_sub(1); - self.select_to(self.previous_boundary(offset), cx); - } - - pub(super) fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { - if self.mode.is_single_line() { - return; - } - let offset = (self.end_of_line() + 1).min(self.text.len()); - self.select_to(self.next_boundary(offset), cx); - } - - pub(super) fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { - self.selected_range = (0..self.text.len()).into(); - cx.notify(); - } - - pub(super) fn select_to_start( - &mut self, - _: &SelectToStart, - _: &mut Window, - cx: &mut Context, - ) { - self.select_to(0, cx); - } - - pub(super) fn select_to_end( - &mut self, - _: &SelectToEnd, - _: &mut Window, - cx: &mut Context, - ) { - let end = self.text.len(); - self.select_to(end, cx); - } - - pub(super) fn select_to_start_of_line( - &mut self, - _: &SelectToStartOfLine, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.start_of_line(); - self.select_to(offset, cx); - } - - pub(super) fn select_to_end_of_line( - &mut self, - _: &SelectToEndOfLine, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.end_of_line(); - self.select_to(offset, cx); - } - - pub(super) fn select_to_previous_word( - &mut self, - _: &SelectToPreviousWordStart, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.previous_start_of_word(); - self.select_to(offset, cx); - } - - pub(super) fn select_to_next_word( - &mut self, - _: &SelectToNextWordEnd, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.next_end_of_word(); - self.select_to(offset, cx); - } - - /// Return the start offset of the previous word. - pub(super) fn previous_start_of_word(&mut self) -> usize { - let offset = self.selected_range.start; - let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); - // FIXME: Avoid to_string - let left_part = self.text.slice(0..offset).to_string(); - - UnicodeSegmentation::split_word_bound_indices(left_part.as_str()) - .rfind(|(_, s)| !s.trim_start().is_empty()) - .map(|(i, _)| i) - .unwrap_or(0) - } - - /// Return the next end offset of the next word. - pub(super) fn next_end_of_word(&mut self) -> usize { - let offset = self.cursor(); - let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); - let right_part = self.text.slice(offset..self.text.len()).to_string(); - - UnicodeSegmentation::split_word_bound_indices(right_part.as_str()) - .find(|(_, s)| !s.trim_start().is_empty()) - .map(|(i, s)| offset + i + s.len()) - .unwrap_or(self.text.len()) - } - - /// Get start of line byte offset of cursor. - /// - /// When soft wrap is active, first press goes to visual line start, - /// second press (already at visual start) goes to logical line start. - pub(super) fn start_of_line(&self) -> usize { - if self.mode.is_single_line() { - return 0; - } - - let row = self.text.offset_to_point(self.cursor()).row; - let logical_start = self.text.line_start_offset(row); - - if self.soft_wrap { - let wrap_point = self.display_map.offset_to_wrap_display_point(self.cursor()); - if let Some(line) = self.display_map.lines().get(row) - && let Some(range) = line.wrapped_lines.get(wrap_point.local_row) - { - let visual_start = logical_start + range.start; - if self.cursor() != visual_start { - return visual_start; - } - } - } - - logical_start - } - - /// Get end of line byte offset of cursor. - /// - /// When soft wrap is active, first press goes to visual line end, - /// second press (already at visual end) goes to logical line end. - pub(super) fn end_of_line(&self) -> usize { - if self.mode.is_single_line() { - return self.text.len(); - } - - let row = self.text.offset_to_point(self.cursor()).row; - let logical_start = self.text.line_start_offset(row); - let logical_end = self.text.line_end_offset(row); - - if self.soft_wrap { - let wrap_point = self.display_map.offset_to_wrap_display_point(self.cursor()); - if let Some(line) = self.display_map.lines().get(row) - && let Some(range) = line.wrapped_lines.get(wrap_point.local_row) - { - let visual_end = logical_start + range.end; - if self.cursor() != visual_end { - return visual_end; - } - } - } - - logical_end - } - - /// Get start line of selection start or end (The min value). - /// - /// This is means is always get the first line of selection. - pub(super) fn start_of_line_of_selection( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> usize { - if self.mode.is_single_line() { - return 0; - } - - let mut offset = - self.previous_boundary(self.selected_range.start.min(self.selected_range.end)); - if self.text.char_at(offset) == Some('\r') { - offset += 1; - } - - self.text_for_range(self.range_to_utf16(&(0..offset + 1)), &mut None, window, cx) - .unwrap_or_default() - .rfind('\n') - .map(|i| i + 1) - .unwrap_or(0) - } - - /// Get indent string of next line. - /// - /// To get current and next line indent, to return more depth one. - pub(super) fn indent_of_next_line(&mut self) -> String { - if self.mode.is_single_line() { - return "".into(); - } - - let mut current_indent = String::new(); - let mut next_indent = String::new(); - let current_line_start_pos = self.start_of_line(); - let next_line_start_pos = self.end_of_line(); - for c in self.text.slice(current_line_start_pos..).chars() { - if !c.is_whitespace() { - break; - } - if c == '\n' || c == '\r' { - break; - } - current_indent.push(c); - } - - for c in self.text.slice(next_line_start_pos..).chars() { - if !c.is_whitespace() { - break; - } - if c == '\n' || c == '\r' { - break; - } - next_indent.push(c); - } - - if next_indent.len() > current_indent.len() { - next_indent - } else { - current_indent - } - } - - pub(super) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - self.select_to(self.previous_boundary(self.cursor()), cx) - } - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - } - - pub(super) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - self.select_to(self.next_boundary(self.cursor()), cx) - } - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - } - - pub(super) fn delete_to_beginning_of_line( - &mut self, - _: &DeleteToBeginningOfLine, - window: &mut Window, - cx: &mut Context, - ) { - if !self.selected_range.is_empty() { - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - return; - } - - let mut offset = self.start_of_line(); - if offset == self.cursor() { - offset = offset.saturating_sub(1); - } - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..self.cursor()))), - "", - window, - cx, - ); - self.pause_blink_cursor(cx); - } - - pub(super) fn delete_to_end_of_line( - &mut self, - _: &DeleteToEndOfLine, - window: &mut Window, - cx: &mut Context, - ) { - if !self.selected_range.is_empty() { - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - return; - } - - let mut offset = self.end_of_line(); - if offset == self.cursor() { - offset = (offset + 1).clamp(0, self.text.len()); - } - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(self.cursor()..offset))), - "", - window, - cx, - ); - self.pause_blink_cursor(cx); - } - - pub(super) fn delete_previous_word( - &mut self, - _: &DeleteToPreviousWordStart, - window: &mut Window, - cx: &mut Context, - ) { - if !self.selected_range.is_empty() { - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - return; - } - - let offset = self.previous_start_of_word(); - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..self.cursor()))), - "", - window, - cx, - ); - self.pause_blink_cursor(cx); - } - - pub(super) fn delete_next_word( - &mut self, - _: &DeleteToNextWordEnd, - window: &mut Window, - cx: &mut Context, - ) { - if !self.selected_range.is_empty() { - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - return; - } - - let offset = self.next_end_of_word(); - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(self.cursor()..offset))), - "", - window, - cx, - ); - self.pause_blink_cursor(cx); - } - - pub(super) fn enter(&mut self, action: &Enter, window: &mut Window, cx: &mut Context) { - // In multi-line mode with `submit_on_enter` enabled, a plain `Enter` - // (without Shift) is treated as submit: propagate the action and emit - // PressEnter without inserting a newline. `Shift+Enter` still inserts - // a newline. - let insert_newline = self.mode.is_multi_line() && (!self.submit_on_enter || action.shift); - - if insert_newline { - // Get current line indent - let indent = if self.mode.is_indentable() { - self.indent_of_next_line() - } else { - "".to_string() - }; - - // Add newline and indent - let new_line_text = format!("\n{}", indent); - self.replace_text_in_range_silent(None, &new_line_text, window, cx); - self.pause_blink_cursor(cx); - } else { - // Single line input or submit-on-enter: just emit the event - // (e.g.: in a dialog to confirm, or a chat textarea to send). - cx.propagate(); - } - - cx.emit(InputEvent::PressEnter { - secondary: action.secondary, - shift: action.shift, - }); - } - - pub(super) fn clean(&mut self, window: &mut Window, cx: &mut Context) { - self.replace_text("", window, cx); - self.selected_range = (0..0).into(); - self.scroll_to(0, None, cx); - } - - pub(super) fn escape(&mut self, _action: &Escape, window: &mut Window, cx: &mut Context) { - if self.ime_marked_range.is_some() { - self.unmark_text(window, cx); - } - - if self.clean_on_escape { - return self.clean(window, cx); - } - - cx.propagate(); - } - - pub(super) fn on_mouse_down( - &mut self, - event: &MouseDownEvent, - window: &mut Window, - cx: &mut Context, - ) { - // If there have IME marked range and is empty (Means pressed Esc to abort IME typing) - // Clear the marked range. - if let Some(ime_marked_range) = &self.ime_marked_range - && ime_marked_range.is_empty() - { - self.ime_marked_range = None; - } - - self.selecting = true; - let offset = self.index_for_mouse_position(event.position); - - // Triple click to select line - if event.button == MouseButton::Left && event.click_count >= 3 { - self.select_line(offset, window, cx); - return; - } - - // Double click to select word - if event.button == MouseButton::Left && event.click_count == 2 { - self.select_word(offset, window, cx); - return; - } - - if event.modifiers.shift { - self.select_to(offset, cx); - } else { - self.move_to(offset, None, cx) - } - } - - pub(super) fn on_mouse_up( - &mut self, - _: &MouseUpEvent, - _window: &mut Window, - _cx: &mut Context, - ) { - if self.selected_range.is_empty() { - self.selection_reversed = false; - } - self.selecting = false; - self.selected_word_range = None; - } - - pub(super) fn on_scroll_wheel( - &mut self, - event: &ScrollWheelEvent, - window: &mut Window, - cx: &mut Context, - ) { - let line_height = self - .last_layout - .as_ref() - .map(|layout| layout.line_height) - .unwrap_or(window.line_height()); - let delta = event.delta.pixel_delta(line_height); - - let old_offset = self.scroll_handle.offset(); - self.update_scroll_offset(Some(old_offset + delta), cx); - - // Only stop propagation if the offset actually changed - if self.scroll_handle.offset() != old_offset { - cx.stop_propagation(); - } - } - - pub(super) fn update_scroll_offset( - &mut self, - offset: Option>, - cx: &mut Context, - ) { - let mut offset = offset.unwrap_or(self.scroll_handle.offset()); - // In addition to left alignment, a cursor position will be reserved on the right side - let safe_x_offset = if self.text_align == TextAlign::Left { - px(0.) - } else { - -CURSOR_WIDTH - }; - - let safe_y_range = - (-self.scroll_size.height + self.input_bounds.size.height).min(px(0.0))..px(0.); - let safe_x_range = (-self.scroll_size.width + self.input_bounds.size.width + safe_x_offset) - .min(safe_x_offset)..px(0.); - - offset.y = if self.mode.is_single_line() { - px(0.) - } else { - offset.y.clamp(safe_y_range.start, safe_y_range.end) - }; - offset.x = offset.x.clamp(safe_x_range.start, safe_x_range.end); - self.scroll_handle.set_offset(offset); - cx.notify(); - } - - /// Scroll to make the given offset visible. - /// - /// If `direction` is Some, will keep edges at the same side. - pub(crate) fn scroll_to( - &mut self, - offset: usize, - direction: Option, - cx: &mut Context, - ) { - let Some(last_layout) = self.last_layout.as_ref() else { - return; - }; - let Some(bounds) = self.last_bounds.as_ref() else { - return; - }; - - let mut scroll_offset = self.scroll_handle.offset(); - let was_offset = scroll_offset; - let line_height = last_layout.line_height; - - let point = self.text.offset_to_point(offset); - - let row = point.row; - - let mut row_offset_y = px(0.); - for (ix, _wrap_line) in self.display_map.lines().iter().enumerate() { - if ix == row { - break; - } - - // Only accumulate height for visible (non-folded) wrap rows - let visible_wrap_rows = self.display_map.visible_wrap_row_count_for_buffer_line(ix); - row_offset_y += line_height * visible_wrap_rows; - } - - // For Right alignment use 0 margin: the cursor indicator is clamped inside bounds - // in layout_cursor, so shifting the text here would cause a first-click visual jump. - let safety_margin = match last_layout.text_align { - TextAlign::Left => RIGHT_MARGIN, - TextAlign::Right => px(0.), - TextAlign::Center => CURSOR_WIDTH, - }; - if let Some(line) = last_layout - .lines - .get(row.saturating_sub(last_layout.visible_range.start)) - { - // Check to scroll horizontally and soft wrap lines - if let Some(pos) = line.position_for_index(point.column, last_layout, false) { - let bounds_width = bounds.size.width - last_layout.line_number_width; - let col_offset_x = pos.x; - row_offset_y += pos.y; - if col_offset_x - safety_margin < -scroll_offset.x { - // If the position is out of the visible area, scroll to make it visible - scroll_offset.x = -col_offset_x + safety_margin; - } else if col_offset_x + safety_margin > -scroll_offset.x + bounds_width { - scroll_offset.x = -(col_offset_x - bounds_width + safety_margin); - } - } - } - - // Check if row_offset_y is out of the viewport - // If row offset is not in the viewport, scroll to make it visible - let edge_height = line_height; - if row_offset_y - edge_height + line_height < -scroll_offset.y { - // Scroll up - scroll_offset.y = -row_offset_y + edge_height - line_height; - } else if row_offset_y + edge_height > -scroll_offset.y + bounds.size.height { - // Scroll down - scroll_offset.y = -(row_offset_y - bounds.size.height + edge_height); - } - - // Avoid necessary scroll, when it was already in the correct position. - if direction == Some(MoveDirection::Up) { - scroll_offset.y = scroll_offset.y.max(was_offset.y); - } else if direction == Some(MoveDirection::Down) { - scroll_offset.y = scroll_offset.y.min(was_offset.y); - } - - scroll_offset.x = scroll_offset.x.min(px(0.)); - scroll_offset.y = scroll_offset.y.min(px(0.)); - self.deferred_scroll_offset = Some(scroll_offset); - cx.notify(); - } - - pub(super) fn show_character_palette( - &mut self, - _: &ShowCharacterPalette, - window: &mut Window, - _: &mut Context, - ) { - window.show_character_palette(); - } - - pub(super) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - return; - } - - let selected_text = self.text.slice(self.selected_range).to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); - } - - pub(super) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - return; - } - - let selected_text = self.text.slice(self.selected_range).to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); - - self.replace_text_in_range_silent(None, "", window, cx); - } - - pub(super) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { - if let Some(clipboard) = cx.read_from_clipboard() { - let mut new_text = clipboard.text().unwrap_or_default(); - if !self.mode.is_multi_line() { - new_text = new_text.replace('\n', ""); - } - - self.replace_text_in_range_silent(None, &new_text, window, cx); - self.scroll_to(self.cursor(), None, cx); - } - } - - fn push_history(&mut self, text: &Rope, range: &Range, new_text: &str) { - if self.history.ignore { - return; - } - - let range = - text.clip_offset(range.start, Bias::Left)..text.clip_offset(range.end, Bias::Right); - let old_text = text.slice(range.clone()).to_string(); - let new_range = range.start..range.start + new_text.len(); - - self.history - .push(Change::new(range, &old_text, new_range, new_text)); - } - - pub(super) fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) { - self.history.ignore = true; - if let Some(changes) = self.history.undo() { - for change in changes { - let range_utf16 = self.range_to_utf16(&change.new_range.into()); - self.replace_text_in_range_silent(Some(range_utf16), &change.old_text, window, cx); - } - } - self.history.ignore = false; - } - - pub(super) fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context) { - self.history.ignore = true; - if let Some(changes) = self.history.redo() { - for change in changes { - let range_utf16 = self.range_to_utf16(&change.old_range.into()); - self.replace_text_in_range_silent(Some(range_utf16), &change.new_text, window, cx); - } - } - self.history.ignore = false; - } - - /// Get byte offset of the cursor. - /// - /// The offset is the UTF-8 offset. - pub fn cursor(&self) -> usize { - if let Some(ime_marked_range) = &self.ime_marked_range { - return ime_marked_range.end; - } - - if self.selection_reversed { - self.selected_range.start - } else { - self.selected_range.end - } - } - - /// Visible row range in the last laid-out viewport, `None` before first layout. - pub fn visible_row_range(&self) -> Option> { - self.last_layout.as_ref().map(|l| l.visible_range.clone()) - } - - /// Current scroll offset of the editor viewport. - pub fn scroll_offset(&self) -> gpui::Point { - self.scroll_handle.offset() - } - - /// Laid-out line height; `None` before first layout. - pub fn line_height(&self) -> Option { - self.last_layout.as_ref().map(|l| l.line_height) - } - - /// Returns the current selection as a byte range into the text. - /// - /// The range is empty (`start == end`) when no text is selected; in - /// that case the offset equals `cursor()`. Byte offsets are measured - /// in the underlying rope's byte units. - pub fn selected_range(&self) -> std::ops::Range { - self.selected_range.into() - } - - pub(crate) fn index_for_mouse_position(&self, position: Point) -> usize { - // If the text is empty, always return 0 - if self.text.len() == 0 { - return 0; - } - - let (Some(bounds), Some(last_layout)) = - (self.last_bounds.as_ref(), self.last_layout.as_ref()) - else { - return 0; - }; - - let line_height = last_layout.line_height; - let line_number_width = last_layout.line_number_width; - - // TIP: About the IBeam cursor - // - // If cursor style is IBeam, the mouse mouse position is in the middle of the cursor (This is special in OS) - - // The position is relative to the bounds of the text input - // - // bounds.origin: - // - // - included the input padding. - // - included the scroll offset. - let inner_position = position - bounds.origin - point(line_number_width, px(0.)); - - let mut y_offset = last_layout.visible_top; - - // Traverse visible buffer lines (compact, no hidden entries) - for (vi, (line_layout, _buffer_line)) in last_layout - .lines - .iter() - .zip(last_layout.visible_buffer_lines.iter()) - .enumerate() - { - let line_start_offset = last_layout.visible_line_byte_offsets[vi]; - - // Calculate line origin for this display row - let line_origin = point(px(0.), y_offset); - let pos = inner_position - line_origin; - - // Return offset by use closest_index_for_x if is single line mode. - if self.mode.is_single_line() { - let local_index = line_layout.closest_index_for_x(pos.x, last_layout); - let index = line_start_offset + local_index; - return if self.masked { - self.text.char_index_to_offset(index / MASK_CHAR.len_utf8()) - } else { - index.min(self.text.len()) - }; - } - - // Check if mouse is in this line's bounds - if let Some(local_index) = line_layout.closest_index_for_position(pos, last_layout) { - let index = line_start_offset + local_index; - return if self.masked { - self.text.char_index_to_offset(index / MASK_CHAR.len_utf8()) - } else { - index.min(self.text.len()) - }; - } else if pos.y < px(0.) { - // Mouse is above this line, return start of this line - return if self.masked { - self.text - .char_index_to_offset(line_start_offset / MASK_CHAR.len_utf8()) - } else { - line_start_offset - }; - } - - y_offset += line_layout.size(line_height).height; - } - - // Mouse is below all visible lines, return end of text - self.text.len() - } - - /// Returns a y offsetted point for the line origin. - /// Select the text from the current cursor position to the given offset. - /// - /// The offset is the UTF-8 offset. - /// - /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. - pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context) { - let offset = offset.clamp(0, self.text.len()); - - if self.selection_reversed { - self.selected_range.start = offset - } else { - self.selected_range.end = offset - }; - - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = (self.selected_range.end..self.selected_range.start).into(); - } - - // Ensure keep word selected range - if let Some(word_range) = self.selected_word_range.as_ref() { - if self.selected_range.start > word_range.start { - self.selected_range.start = word_range.start; - } - if self.selected_range.end < word_range.end { - self.selected_range.end = word_range.end; - } - } - - if self.selected_range.is_empty() { - self.update_preferred_column(); - } - - cx.notify() - } - - /// Unselects the currently selected text. - pub fn unselect(&mut self, _: &mut Window, cx: &mut Context) { - let offset = self.cursor(); - self.selected_range = (offset..offset).into(); - cx.notify() - } - - #[inline] - pub(super) fn offset_from_utf16(&self, offset: usize) -> usize { - self.text.offset_utf16_to_offset(offset) - } - - #[inline] - pub(super) fn offset_to_utf16(&self, offset: usize) -> usize { - self.text.offset_to_offset_utf16(offset) - } - - #[inline] - pub(super) fn range_to_utf16(&self, range: &Range) -> Range { - self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) - } - - #[inline] - pub(super) fn range_from_utf16(&self, range_utf16: &Range) -> Range { - self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) - } - - pub(super) fn previous_boundary(&self, offset: usize) -> usize { - let mut offset = self.text.clip_offset(offset.saturating_sub(1), Bias::Left); - if let Some(ch) = self.text.char_at(offset) - && ch == '\r' - { - offset -= 1; - } - - offset - } - - pub(super) fn next_boundary(&self, offset: usize) -> usize { - let mut offset = self.text.clip_offset(offset + 1, Bias::Right); - if let Some(ch) = self.text.char_at(offset) - && ch == '\r' - { - offset += 1; - } - - offset - } - - /// Returns the true to let InputElement to render cursor, when Input is focused and current BlinkCursor is visible. - pub(crate) fn show_cursor(&self, window: &Window, cx: &App) -> bool { - self.focus_handle.is_focused(window) - && !self.disabled - && self.blink_cursor.read(cx).visible() - && window.is_window_active() - } - - fn on_focus(&mut self, _: &mut Window, cx: &mut Context) { - self.blink_cursor.update(cx, |cursor, cx| { - cursor.start(cx); - }); - cx.emit(InputEvent::Focus); - } - - fn on_blur(&mut self, window: &mut Window, cx: &mut Context) { - self.blink_cursor.update(cx, |cursor, cx| { - cursor.stop(cx); - }); - Root::update(window, cx, |root, _, _| { - root.focused_input = None; - }); - cx.emit(InputEvent::Blur); - cx.notify(); - } - - pub(super) fn pause_blink_cursor(&mut self, cx: &mut Context) { - self.blink_cursor.update(cx, |cursor, cx| { - cursor.pause(cx); - }); - } - - pub(super) fn on_key_down(&mut self, _: &KeyDownEvent, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - } - - pub(super) fn on_drag_move( - &mut self, - event: &MouseMoveEvent, - window: &mut Window, - cx: &mut Context, - ) { - if self.text.len() == 0 { - return; - } - - if self.last_layout.is_none() { - return; - } - - if !self.focus_handle.is_focused(window) { - return; - } - - if !self.selecting { - return; - } - - let offset = self.index_for_mouse_position(event.position); - self.select_to(offset, cx); - } - - fn is_valid_input(&self, new_text: &str, cx: &mut Context) -> bool { - if new_text.is_empty() { - return true; - } - - if let Some(validate) = &self.validate - && !validate(new_text, cx) - { - return false; - } - - if !self.mask_pattern.is_valid(new_text) { - return false; - } - - let Some(pattern) = &self.pattern else { - return true; - }; - - pattern.is_match(new_text) - } - - /// Set the mask pattern for formatting the input text. - /// - /// The pattern can contain: - /// - 9: Any digit or dot - /// - A: Any letter - /// - *: Any character - /// - Other characters will be treated as literal mask characters - /// - /// Example: "(999)999-999" for phone numbers - pub fn mask_pattern(mut self, pattern: impl Into) -> Self { - self.mask_pattern = pattern.into(); - if let Some(placeholder) = self.mask_pattern.placeholder() { - self.placeholder = placeholder.into(); - } - self - } - - pub fn set_mask_pattern( - &mut self, - pattern: impl Into, - _: &mut Window, - cx: &mut Context, - ) { - self.mask_pattern = pattern.into(); - if let Some(placeholder) = self.mask_pattern.placeholder() { - self.placeholder = placeholder.into(); - } - cx.notify(); - } - - pub(super) fn set_input_bounds(&mut self, new_bounds: Bounds, cx: &mut Context) { - let wrap_width_changed = self.input_bounds.size.width != new_bounds.size.width; - self.input_bounds = new_bounds; - - // Update display_map wrap_width if changed. - if let Some(last_layout) = self.last_layout.as_ref() - && wrap_width_changed - { - let wrap_width = if !self.soft_wrap { - // None to disable wrapping (will use Pixels::MAX) - None - } else { - last_layout.wrap_width - }; - - self.display_map.on_layout_changed(wrap_width, cx); - self.mode.update_auto_grow(&self.display_map); - cx.notify(); - } - } - - pub(super) fn selected_text(&self) -> RopeSlice<'_> { - let range_utf16 = self.range_to_utf16(&self.selected_range.into()); - let range = self.range_from_utf16(&range_utf16); - self.text.slice(range) - } - - /// Return the rendered bounds for a UTF-8 byte range in the current input contents. - /// - /// Returns `None` when the requested range is not currently laid out or visible. - pub fn range_to_bounds(&self, range: &Range) -> Option> { - let last_layout = self.last_layout.as_ref()?; - let last_bounds = self.last_bounds?; - - let (_, _, start_pos) = self.line_and_position_for_offset(range.start); - let (_, _, end_pos) = self.line_and_position_for_offset(range.end); - - let start_pos = start_pos?; - let end_pos = end_pos?; - - Some(Bounds::from_corners( - last_bounds.origin + start_pos, - last_bounds.origin + end_pos + point(px(0.), last_layout.line_height), - )) - } - - /// Replace text in range in silent. - /// - /// This will not trigger any UI interaction, such as auto-completion. - pub(crate) fn replace_text_in_range_silent( - &mut self, - range_utf16: Option>, - new_text: &str, - window: &mut Window, - cx: &mut Context, - ) { - self.silent_replace_text = true; - self.replace_text_in_range(range_utf16, new_text, window, cx); - self.silent_replace_text = false; - } -} - -impl EntityInputHandler for InputState { - fn text_for_range( - &mut self, - range_utf16: Range, - adjusted_range: &mut Option>, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - let range = self.range_from_utf16(&range_utf16); - adjusted_range.replace(self.range_to_utf16(&range)); - Some(self.text.slice(range).to_string()) - } - - fn selected_text_range( - &mut self, - _ignore_disabled_input: bool, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - Some(UTF16Selection { - range: self.range_to_utf16(&self.selected_range.into()), - reversed: false, - }) - } - - fn marked_text_range( - &self, - _window: &mut Window, - _cx: &mut Context, - ) -> Option> { - self.ime_marked_range - .map(|range| self.range_to_utf16(&range.into())) - } - - fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { - self.ime_marked_range = None; - } - - /// Replace text in range. - /// - /// - If the new text is invalid, it will not be replaced. - /// - If `range_utf16` is not provided, the current selected range will be used. - fn replace_text_in_range( - &mut self, - range_utf16: Option>, - new_text: &str, - _window: &mut Window, - cx: &mut Context, - ) { - if self.disabled { - return; - } - - if self.blink_cursor.read(cx).visible() { - self.pause_blink_cursor(cx); - } - - let range = range_utf16 - .as_ref() - .map(|range_utf16| self.range_from_utf16(range_utf16)) - .or(self.ime_marked_range.map(|range| { - let range = self.range_to_utf16(&(range.start..range.end)); - self.range_from_utf16(&range) - })) - .unwrap_or(self.selected_range.into()); - - let old_text = self.text.clone(); - self.text.replace(range.clone(), new_text); - - let mut new_offset = (range.start + new_text.len()).min(self.text.len()); - - if self.mode.is_single_line() { - let pending_text = self.text.to_string(); - // Check if the new text is valid - if !self.is_valid_input(&pending_text, cx) { - self.text = old_text; - return; - } - - if !self.mask_pattern.is_none() { - let mask_text = self.mask_pattern.mask(&pending_text); - self.text = Rope::from(mask_text.as_str()); - let new_text_len = - (new_text.len() + mask_text.len()).saturating_sub(pending_text.len()); - new_offset = (range.start + new_text_len).min(mask_text.len()); - } - } - - self.push_history(&old_text, &range, new_text); - self.history.end_grouping(); - - // Adjust folds before updating wrap map: remove overlapping folds and shift others - self.display_map - .adjust_folds_for_edit(&old_text, &range, new_text); - self.display_map - .on_text_changed(&self.text, &range, &Rope::from(new_text), cx); - - self.selected_range = (new_offset..new_offset).into(); - self.ime_marked_range.take(); - self.update_preferred_column(); - self.mode.update_auto_grow(&self.display_map); - if self.emit_events { - cx.emit(InputEvent::Change); - } - cx.notify(); - } - - /// Mark text is the IME temporary insert on typing. - fn replace_and_mark_text_in_range( - &mut self, - range_utf16: Option>, - new_text: &str, - new_selected_range_utf16: Option>, - _window: &mut Window, - cx: &mut Context, - ) { - if self.disabled { - return; - } - - let range = range_utf16 - .as_ref() - .map(|range_utf16| self.range_from_utf16(range_utf16)) - .or(self.ime_marked_range.map(|range| { - let range = self.range_to_utf16(&(range.start..range.end)); - self.range_from_utf16(&range) - })) - .unwrap_or(self.selected_range.into()); - - let old_text = self.text.clone(); - self.text.replace(range.clone(), new_text); - - if self.mode.is_single_line() { - let pending_text = self.text.to_string(); - if !self.is_valid_input(&pending_text, cx) { - self.text = old_text; - return; - } - } - - // Adjust folds before updating wrap map: remove overlapping folds and shift others - self.display_map - .adjust_folds_for_edit(&old_text, &range, new_text); - self.display_map - .on_text_changed(&self.text, &range, &Rope::from(new_text), cx); - - if new_text.is_empty() { - // Cancel selection, when cancel IME input. - self.selected_range = (range.start..range.start).into(); - self.ime_marked_range = None; - } else { - self.ime_marked_range = Some((range.start..range.start + new_text.len()).into()); - self.selected_range = new_selected_range_utf16 - .as_ref() - .map(|range_utf16| self.range_from_utf16(range_utf16)) - .map(|new_range| new_range.start + range.start..new_range.end + range.end) - .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()) - .into(); - } - - self.mode.update_auto_grow(&self.display_map); - self.history.start_grouping(); - self.push_history(&old_text, &range, new_text); - cx.notify(); - } - - /// Used to position IME candidates. - fn bounds_for_range( - &mut self, - range_utf16: Range, - bounds: Bounds, - _window: &mut Window, - _cx: &mut Context, - ) -> Option> { - let last_layout = self.last_layout.as_ref()?; - let line_height = last_layout.line_height; - let line_number_width = last_layout.line_number_width; - let range = self.range_from_utf16(&range_utf16); - - let mut start_origin = None; - let mut end_origin = None; - let line_number_origin = point(line_number_width, px(0.)); - let mut y_offset = last_layout.visible_top; - - for (vi, line) in last_layout.lines.iter().enumerate() { - if start_origin.is_some() && end_origin.is_some() { - break; - } - - let index_offset = last_layout.visible_line_byte_offsets[vi]; - - if start_origin.is_none() - && let Some(p) = line.position_for_index( - range.start.saturating_sub(index_offset), - last_layout, - false, - ) - { - start_origin = Some(p + point(px(0.), y_offset)); - } - - if end_origin.is_none() - && let Some(p) = line.position_for_index( - range.end.saturating_sub(index_offset), - last_layout, - false, - ) - { - end_origin = Some(p + point(px(0.), y_offset)); - } - - y_offset += line.size(line_height).height; - } - - let start_origin = start_origin.unwrap_or_default(); - let mut end_origin = end_origin.unwrap_or_default(); - // Ensure at same line. - end_origin.y = start_origin.y; - - Some(Bounds::from_corners( - bounds.origin + line_number_origin + start_origin, - // + line_height for show IME panel under the cursor line. - bounds.origin + line_number_origin + point(end_origin.x, end_origin.y + line_height), - )) - } - - fn character_index_for_point( - &mut self, - point: gpui::Point, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - let last_layout = self.last_layout.as_ref()?; - let line_point = self.last_bounds?.localize(&point)?; - - for (vi, line) in last_layout.lines.iter().enumerate() { - let offset = last_layout.visible_line_byte_offsets[vi]; - if let Some(utf8_index) = line.index_for_position(line_point, last_layout) { - return Some(self.offset_to_utf16(offset + utf8_index)); - } - } - - None - } -} - -impl Focusable for InputState { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for InputState { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .id("input-state") - .flex_1() - .when(self.mode.is_multi_line(), |this| this.h_full()) - .flex_grow_1() - .overflow_x_hidden() - .child(TextElement::new(cx.entity().clone()).placeholder(self.placeholder.clone())) - } -} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 114becd9..70515f82 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -15,7 +15,6 @@ pub mod button; pub mod divider; pub mod dock; pub mod group_box; -pub mod history; pub mod indicator; pub mod input; pub mod menu; @@ -43,7 +42,6 @@ mod window_ext; pub fn init(cx: &mut gpui::App) { gpui_base::init(cx); theme::sync_base(cx); - input::init(cx); modal::init(cx); popover::init(cx); menu::init(cx); diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 9ba463aa..34f52fcb 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -13,7 +13,6 @@ use theme::{ CLIENT_SIDE_DECORATION_SHADOW, }; -use crate::input::InputState; use crate::modal::Modal; use crate::notification::{Notification, NotificationList}; @@ -50,9 +49,6 @@ pub struct Root { /// Notification layer pub(crate) notification: Entity, - /// Current focused input - pub(crate) focused_input: Option>, - /// App view view: AnyView, } @@ -60,7 +56,6 @@ pub struct Root { impl Root { pub fn new(view: AnyView, window: &mut Window, cx: &mut Context) -> Self { Self { - focused_input: None, active_modals: Vec::new(), notification: cx.new(|cx| NotificationList::new(window, cx)), view, @@ -171,8 +166,6 @@ impl Root { /// Close the topmost modal. pub fn close_modal(&mut self, window: &mut Window, cx: &mut Context) { - self.focused_input = None; - if let Some(handle) = self .active_modals .pop() @@ -187,7 +180,6 @@ impl Root { /// Close all modals. pub fn close_all_modals(&mut self, window: &mut Window, cx: &mut Context) { - self.focused_input = None; self.active_modals.clear(); let previous_focused_handle = self diff --git a/crates/ui/src/window_ext.rs b/crates/ui/src/window_ext.rs index d4ed229b..e042c448 100644 --- a/crates/ui/src/window_ext.rs +++ b/crates/ui/src/window_ext.rs @@ -3,7 +3,6 @@ use std::rc::Rc; use gpui::{App, ElementId, Entity, Window}; use crate::Root; -use crate::input::InputState; use crate::modal::Modal; use crate::notification::Notification; @@ -43,12 +42,6 @@ pub trait WindowExtension: Sized { /// Clear all notifications fn clear_notifications(&mut self, cx: &mut App); - - /// Return current focused Input entity. - fn focused_input(&mut self, cx: &mut App) -> Option>; - - /// Returns true if there is a focused Input entity. - fn has_focused_input(&mut self, cx: &mut App) -> bool; } impl WindowExtension for Window { @@ -122,12 +115,4 @@ impl WindowExtension for Window { let entity = Root::read(self, cx).notification.clone(); Rc::new(entity.read(cx).notifications()) } - - fn has_focused_input(&mut self, cx: &mut App) -> bool { - Root::read(self, cx).focused_input.is_some() - } - - fn focused_input(&mut self, cx: &mut App) -> Option> { - Root::read(self, cx).focused_input.clone() - } } diff --git a/crates/workspace/src/panels/profile.rs b/crates/workspace/src/panels/profile.rs index 5bee4cb3..a6c7fd19 100644 --- a/crates/workspace/src/panels/profile.rs +++ b/crates/workspace/src/panels/profile.rs @@ -15,7 +15,7 @@ use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::input::{Input, InputState}; +use ui::input::{Input, InputState, Textarea, TextareaState}; use ui::notification::Notification; use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; @@ -38,7 +38,7 @@ pub struct ProfilePanel { avatar_input: Entity, /// User's bio multi line input - bio_input: Entity, + bio_input: Entity, /// User's website url text input website_input: Entity, @@ -64,8 +64,7 @@ impl ProfilePanel { // Use multi-line input for bio let bio_input = cx.new(|cx| { - InputState::new(window, cx) - .multi_line(true) + TextareaState::new(window, cx) .auto_grow(3, 8) .placeholder("A short introduce about you.") }); @@ -367,7 +366,7 @@ impl Render for ProfilePanel { .text_color(cx.theme().text_muted) .child(SharedString::from("A short introduction about you:")), ) - .child(Input::new(&self.bio_input).small()), + .child(Textarea::new(&self.bio_input).small()), ) .child( v_flex() diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index b8181182..b72d590a 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -257,10 +257,10 @@ impl Sidebar { } /// Set the finding status - fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context) { + fn set_finding(&mut self, status: bool, window: &mut Window, cx: &mut Context) { // Disable the input to prevent duplicate requests self.find_input.update(cx, |this, cx| { - this.set_loading(status, cx); + this.set_loading(status, window, cx); }); // Set the search status self.finding = status; @@ -530,15 +530,18 @@ impl Render for Sidebar { .small() .text_xs() .disabled(loading) - .when(!self.find_input.read(cx).loading, |this| { - this.suffix( - Button::new("find-icon") - .icon(IconName::Search) - .tooltip("Press Enter to search") - .transparent() - .small(), - ) - }), + .when( + !self.find_input.read(cx).presentation().is_loading(), + |this| { + this.suffix( + Button::new("find-icon") + .icon(IconName::Search) + .tooltip("Press Enter to search") + .transparent() + .small(), + ) + }, + ), ), ) .child( diff --git a/docs/gpui-base-migration.md b/docs/gpui-base-migration.md index 781572d1..2673260f 100644 --- a/docs/gpui-base-migration.md +++ b/docs/gpui-base-migration.md @@ -18,7 +18,10 @@ repository at `main`, and this workspace's `Cargo.lock` (zed at `4b47ceb`, of dead weight are gone. `history.rs` moved to phase 2 once it turned out its only consumer is `input/state.rs`. No dependency became unused, so the pruning step is a no-op (four dependencies were already unused before this work). -- **Phases 2-5: not started.** +- **Phase 2: landed.** `input/` runs on base's editing engine. 6,715 lines of + engine and history are gone and 306 are written, taking `crates/ui/src/input` + from 6,573 lines to 321. Six call sites changed, all named in phase 2 below. +- **Phases 3-5: not started.** - One pre-existing, unrelated breakage was found; see [A pre-existing wasm blocker](#a-pre-existing-wasm-blocker). @@ -46,7 +49,7 @@ deeper than `ui::::`: | Module | Items | Consumer files | | --- | --- | --- | | crate root (`Icon`, `IconName`, `h_flex`, `v_flex`, `divider`, `Root`, `TitleBar`, `Sizable`, `Selectable`, `Disableable`, `StyledExt`, `WindowExtension`, `InteractiveElementExt`) | 13 | 17 | -| `input` (`InputState`, `Input`, `InputEvent`) | 3 | 10 | +| `input` (`InputState`, `Input`, `InputEvent`; plus `TextareaState` and `Textarea` after phase 2) | 3, then 5 | 10 | | `button` (`Button`, `ButtonVariants`) | 2 | 14 | | `dock` (`Panel`, `PanelView`, `DockArea`, `DockItem`, `DockPlacement`, `PanelEvent`, `ClosePanel`) | 7 | 10 | | `notification`, `avatar`, `menu`, `scroll`, `group_box`, `indicator`, `switch`, `modal`, `tooltip` | 12 | 16 | @@ -108,7 +111,7 @@ dragging, both of which are projected. | `ui` module | LOC | Plan | `gpui-base` counterpart | | --- | --- | --- | --- | -| `input/` (state, element, display_map, rope_ext, mask_pattern, movement, selection, indent, mode, change, cursor, blink_cursor, clear_button) | 6,929 | Replace; keep `ui::input::{Input, InputEvent, InputState}` as the import path | `Input`/`InputState`, `Textarea`/`TextareaState`, `Editor` | +| `input/` (input, clear_button) | 6,573 | Replace; keep `ui::input::{Input, InputEvent, InputState}` as the import path. 321 lines remain, and the engine paints itself through `InputEditorStyle` | `InputState`/`TextareaState` (`InputBaseState` in two modes) plus the `InputBase` frame | | `list/` | 1,477 | Delete | GPUI's own `list` (already in use) | | `checkbox.rs` | 312 | Delete | `Checkbox` | | `scroll/` (scrollbar, scrollable, scrollable_mask) | 1,332 | Replace; keep the `ScrollableElement` and `Scrollbar` names | `Scrollbar`, `ScrollableMask` | @@ -120,13 +123,13 @@ dragging, both of which are projected. | `button.rs` | 626 | Skin: base behavior plus coop's existing variant tables | `Button`, `StateStyle` | | `switch.rs` | 287 | Skin | `Switch`, `SwitchTrack`, `SwitchThumb` | | `avatar.rs` | 141 | Skin | `Avatar`, `AvatarImage`, `AvatarFallback` | -| `history.rs` | 184 | Defer to phase 2 | `UndoHistory`, not `History`: base's `History` is navigation (back/forward), while `UndoHistory` is the grouped undo/redo with `max_undos`, `group_interval`, `start_grouping`/`end_grouping`, and `set_ignoring` in place of the fork's `pub(crate) ignore` field. Its only consumer is `input/state.rs`, which phase 2 replaces | +| `history.rs` | 184 | Delete. Base's input keeps its own `UndoManager`, and `UndoHistory` is a separate public utility the input never touches, so nothing has to be re-based. Its only consumer was `input/state.rs` | — | | `index_path.rs`, `element_ext.rs`, `event.rs`, `focusable.rs` | 156 | Delete | `IndexPath`, `ElementExt`, `InteractiveElementExt`. `FocusableCycle` has no counterpart — base's `FocusableExt` is a different concept (whether a component draws a focus ring) — so it is dropped rather than re-based | | `styled.rs`, `actions.rs`, `animation.rs` | 305 | Keep `ui::StyledExt`, `Size`, and `Sizable` as the app's import. `Selectable`, `Disableable`, and `Collapsible` now come from `gpui_base::component_traits`; the local three-line `h_flex`/`v_flex` wrappers stay rather than delegating to base's identical ones | `styled`, `StateStyle` | | `icon.rs`, `kbd.rs`, `divider.rs`, `skeleton.rs`, `group_box.rs`, `indicator.rs` | 1,023 | Keep; no base equivalent, these are the design system | — | | `menu/` | 2,208 | Keep; base has no menu. Optional later: re-base anchoring and dismissal on `Popup`/`Positioner` | `Popup` (optional) | | `dock/` + `tab/` | 3,356 | Keep for now; see phase 5 | base dock (different contract) | -| `root.rs`, `window_ext.rs`, `title_bar.rs` | 965 | Keep; app shell. `Root` continues to host the dialog and toast layers and `focused_input` | — | +| `root.rs`, `window_ext.rs`, `title_bar.rs` | 965 | Keep; app shell. `Root` continues to host the dialog and toast layers. Its `focused_input` field and the two `WindowExtension` methods that read it are gone — the only thing that ever set them was the deleted input paint hook, and no crate consumed them | — | Roughly 10k lines are removed, 3k are re-expressed as thin skins, and 8k are kept. @@ -230,30 +233,82 @@ touched are the two manifests, `ui/src/lib.rs`, `ui/src/styled.rs`, and acceptance — launching the app and walking the settings dialog and chat panel — has to be done by hand and has not been run. -### Phase 2 — `input/` (the largest single win, ~6.9k lines) +### Phase 2 — `input/` (the largest single win) — landed -The mapping is close to 1:1 with what the app actually uses: +`crates/ui/src/input` is three files and 321 lines: a rewritten 299-line `input.rs`, a +7-line `mod.rs` that re-exports base, and the untouched 15-line `clear_button.rs`. Deleted: +`state.rs`, `element.rs`, `display_map/`, `rope_ext.rs`, `mask_pattern.rs`, `movement.rs`, +`selection.rs`, `indent.rs`, `mode.rs`, `change.rs`, `cursor.rs`, `blink_cursor.rs`, and +`history.rs` — 6,715 lines. -| Coop today | `gpui-base` | +The names the application imports are unchanged, but two of them are base's now: + +| Coop before | `ui::input` now | | --- | --- | -| `InputState::new(window, cx).placeholder(..)` | same | -| `.auto_grow(1, 20)` (chat composer) | `TextareaState::auto_grow(2, 8)` with `Textarea` | -| `.masked(true)` (nsec, password, key) | `InputState::masked(true)`, `unmask_value()` | -| `.set_value(value, window, cx)` | `set_value(value, window, cx)` | -| `InputEvent::{Change, PressEnter, Focus, Blur}` | identical variants | -| `Input::new(&state).appearance(false)` | coop's `Input` keeps these chrome options | +| `InputState`, one struct that became multi-line through `auto_grow`/`multi_line` | `InputState` = `InputBaseState` and `TextareaState` = `InputBaseState`; multi-line is a property of the state's kind | +| `Input`, one element that rendered whatever kind of state it was given | `Input` for `InputState` and `Textarea` for `TextareaState` — one generic element, two names | +| `InputEvent::{Change, PressEnter, Focus, Blur}` | identical | +| `history::History` and `HistoryItem` | gone; `Change` keeps no trait impl | -Known gaps to reconcile here, verified against the 0.6.1 source before starting: -`clean_on_escape()`, `set_loading()` (called from `crates/workspace/src/sidebar/mod.rs`), -and the `InputEditorStyle` hook that has to be filled from coop tokens. Everything else -in `input/` — `display_map`, `rope_ext`, `mask_pattern`, `movement`, `selection`, -`indent`, `mode`, `element` — is deleted. Afterwards, `ropey`, `sum_tree`, -`lsp-types`, `tree-sitter`, `regex`, `unicode-segmentation`, and `uuid` can probably -leave `crates/ui`'s manifest. +The styled element is a frame around base's engine rather than the engine itself. Base's +`InputBaseState::render` registers the key context, the focus handle, every editing +action, the text element and the editor scrollbar, so the coop element no longer carries +any of it. What is left is chrome — background, radius, font size, prefix and suffix +slots, clear button, mask toggle, loading indicator — plus three projections onto the +state: -Surfaces to re-verify: the chat composer (auto-grow, Enter to send, IME), the subject -line, the settings dialog, profile, relay and messaging lists, the import/restore/backup -dialogs, and sidebar search. +- `set_editor_style(InputEditorStyle)`, filling `foreground`, `muted_foreground`, + `selection` and `caret` from `text`, `text_muted`, `selection` and `cursor`. Base + resolves any color left transparent from its own palette, and that palette is only a + projection of coop's, so every color coop paints with is named rather than left to + resolve. The remaining fields stay at base's defaults: coop configures no highlighter, + no diagnostics and no gutter. +- `set_editor_paddings(Edges)`, for multi-line only, resolved from the same `Size` + table the single-line frame applies, through the window's rem size. Base puts that + padding on the text element itself so the text, the gutter and the scrollbar share one + inset; putting it on the frame *and* passing it here would double it. Passing the + frame's own value is also what keeps the scrollbar where the fork drew it. +- `set_disabled` and `set_text_align`, replacing the fork's direct writes to `state.size`, + `state.disabled` and `state.text_align`. + +`history.rs` folded in as predicted, with one correction: it did not need re-basing at +all. Base's engine owns an `UndoManager`, and `gpui_base::UndoHistory` — the grouped +undo/redo, not the back/forward `History` — is a separate utility the engine never +reaches for. Removing `pub mod history` is safe because nothing outside `crates/ui` +referenced it. + +The gaps named before the phase started all resolved in base's favor: `clean_on_escape()` +and `set_loading()` both exist in 0.6.1, and `InputEditorStyle` is the third piece. + +**Six call sites changed, and none of them is churn:** + +| Call site | Change | Why | +| --- | --- | --- | +| `chat_ui` composer | `InputState` → `TextareaState`, `Input::new` → `Textarea::new` | multi-line is the state's kind, not a layout flag | +| `workspace`, profile bio | the same, and `.multi_line(true)` is dropped | the same; `auto_grow(3, 8)` is unchanged | +| `workspace`, sidebar | `set_loading(status, cx)` → `set_loading(status, window, cx)` | base's signature takes the window | +| `workspace`, sidebar | the `.loading` field read → `.presentation().is_loading()` | `loading` is private; `InputPresentation` is the facade for reading it | +| `ui::window_ext` | `focused_input` and `has_focused_input` are removed | their only implementation was the deleted paint hook, and no crate consumed them | +| `ui::init` | `input::init(cx)` is removed | `gpui_base::init` binds the same keys, and its set is a strict superset | + +Three visible differences survive, all of them base's, none of them a color, radius or +spacing value: + +- **The mask character is `•`, not `*`.** Base's `MASK_CHAR` is a private constant, so the + fork's `*` cannot be restored. It shows only in masked inputs. +- **The caret is `0.85 × line_height` at every size.** The fork scaled it by `Size` (0.75 + at small, 1.0 at large) from a `size` field base does not have. +- **Inputs are tab stops.** Base builds the state's focus handle with `tab_stop(true)`; + the fork's frame was not a tab stop, so Tab skipped text fields and now lands on them. + +Two things came along with `InputBase` that the fork's plain `div` did not do: the frame +carries the `TextInput` accessibility role, and a left click anywhere in the frame — +including the padding outside the text element — focuses the input. The second has to be +restated on the frame because base handles its own mouse events on the inner element only. + +Surfaces to re-verify by hand: the chat composer (auto-grow, Enter to send, IME), the +profile bio, the subject line, the settings dialog, the relay and messaging lists, the +import/restore/backup dialogs, and the sidebar search field. ### Phase 3 — overlays and feedback @@ -298,9 +353,10 @@ There is no UI test suite to lean on, so each phase gets the same treatment: densest single smoke surface (Button, GroupBox, Switch, Input, DropdownMenu, PopupMenuItem), followed by the chat panel and the sidebar. - For phase 4, record before/after screenshots per module. -- Keep the call-site diff at zero for phases 1, 3, and 4; if a call site has to change - because base has no equivalent (`set_loading` is the known candidate), list it in the - pull request. +- Keep the call-site diff at zero where the phase claims it — phases 1 and 3–4 do; if a + call site has to change because base has no equivalent, list it in the pull request. + Phase 2 needed six, tabulated above, and the list is the record of what "no equivalent" + turned out to mean in practice. ### A pre-existing wasm blocker @@ -345,7 +401,7 @@ compile for `wasm32-unknown-unknown`". | --- | --- | --- | --- | | 1 | Phase 0: `gpui` moves to the `gpui-pre` package, `gpui_tokio` vendored | root `Cargo.toml`, `Cargo.lock`, `web/Cargo.toml`, new `crates/gpui_tokio`; `crates/state` needed no edit | landed | | 2 | Phase 1: base wiring, `sync_base`, deletions | `crates/theme` | landed | -| 3 | Phase 2: input, plus `history.rs` → `UndoHistory` and the `ropey`/`sum_tree`/… pruning | none, or the named gaps | not started | +| 3 | Phase 2: input, plus `history.rs` and the `ropey`/`sum_tree`/`lsp-types`/`regex`/`unicode-segmentation`/`tree-sitter` pruning | `crates/workspace`, `crates/chat_ui` (six call sites); no manifest outside `crates/ui` | landed | | 4 | Phase 3: popover, modal, notification, tooltip | none | not started | | 5–10 | Phase 4: one leaf module each | none | not started | | later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | not started | -- 2.54.0 From df23067c01931ebe3c06d46fb89fbf4e70bbd647 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 17 Sep 2026 16:39:46 +0700 Subject: [PATCH 04/10] migrate overlays and feedback --- crates/ui/src/lib.rs | 2 - crates/ui/src/modal.rs | 314 +++++++++++++++------------------- crates/ui/src/notification.rs | 273 ++++++++++++++++++----------- crates/ui/src/popover.rs | 262 ++++------------------------ crates/ui/src/root.rs | 3 +- crates/ui/src/tooltip.rs | 7 +- docs/gpui-base-migration.md | 110 ++++++++++-- 7 files changed, 445 insertions(+), 526 deletions(-) diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 70515f82..fa211c81 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -42,7 +42,5 @@ mod window_ext; pub fn init(cx: &mut gpui::App) { gpui_base::init(cx); theme::sync_base(cx); - modal::init(cx); - popover::init(cx); menu::init(cx); } diff --git a/crates/ui/src/modal.rs b/crates/ui/src/modal.rs index a16d4b13..e7715416 100644 --- a/crates/ui/src/modal.rs +++ b/crates/ui/src/modal.rs @@ -2,28 +2,19 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Div, FocusHandle, - InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point, - RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px, + Animation, AnimationExt as _, AnyElement, App, BoxShadow, ClickEvent, Div, FocusHandle, + InteractiveElement as _, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, + StyleRefinement, Styled, Window, div, hsla, point, px, size, }; +use gpui_base::Dialog; use instant::Duration; use theme::ActiveTheme; -use crate::actions::{Cancel, Confirm}; use crate::animation::cubic_bezier; use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _}; use crate::scroll::ScrollableElement; use crate::{IconName, Root, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; -const CONTEXT: &str = "Modal"; - -pub fn init(cx: &mut App) { - cx.bind_keys([ - KeyBinding::new("escape", Cancel, Some(CONTEXT)), - KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), - ]); -} - type OnClose = Rc; type OnOk = Option bool + 'static>>; type OnCancel = Rc bool + 'static>; @@ -275,6 +266,8 @@ impl Styled for Modal { impl RenderOnce for Modal { fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement { let layer_ix = self.layer_ix; + let is_topmost = layer_ix + 1 == Root::read(window, cx).active_modals.len(); + let has_footer = self.footer.is_some(); let on_close = self.on_close.clone(); let on_ok = self.on_ok.clone(); let on_cancel = self.on_cancel.clone(); @@ -345,19 +338,16 @@ impl RenderOnce for Modal { let radius = cx.theme().radius_lg; let view_size = window.viewport_size() - - gpui::size( + - size( window_paddings.left + window_paddings.right, window_paddings.top + window_paddings.bottom, ); - let bounds = Bounds { - origin: Point::default(), - size: view_size, - }; - let offset_top = px(layer_ix as f32 * 16.); let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top; - let x = bounds.center().x - self.width / 2.; + let x = view_size.width / 2. - self.width / 2.; + let card_top = window_paddings.top + y; + let card_left = window_paddings.left + x; let mut padding_right = px(16.); let mut padding_left = px(16.); @@ -373,168 +363,138 @@ impl RenderOnce for Modal { let animation = Animation::new(Duration::from_secs_f64(0.25)) .with_easing(cubic_bezier(0.32, 0.72, 0., 1.)); - anchored() - .position(point(window_paddings.left, window_paddings.top)) - .snap_to_window() + let backdrop = div() + .absolute() + .top(window_paddings.top) + .left(window_paddings.left) + .w(view_size.width) + .h(view_size.height) + .when(self.overlay_visible, |this| { + this.occlude().bg(cx.theme().overlay) + }) + .with_animation("fade-in", animation.clone(), move |this, delta| { + this.opacity(delta) + }); + + let card = v_flex() + .id(layer_ix) + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().border.alpha(0.4)) + .rounded(radius) + .when(cx.theme().shadow, |this| this.shadow_xl()) + .min_h_24() + .refine_style(&self.style) + // There style is high priority, can't be overridden. + .absolute() + .occlude() + .relative() + .left(card_left) + .top(card_top) + .w(self.width) + .when_some(self.max_width, |this, w| this.max_w(w)) .child( div() - .id("modal") - .w(view_size.width) - .h(view_size.height) - .when(self.overlay_visible, |this| { - this.occlude().bg(cx.theme().overlay) - }) - .when(self.overlay_closable, |this| { - // Only the last modal owns the `mouse down - close modal` event. - if (self.layer_ix + 1) != Root::read(window, cx).active_modals.len() { - return this; - } + .px_4() + .h_8() + .w_full() + .flex() + .items_center() + .justify_center() + .when_some(self.title, |this, title| { + this.h_10().font_semibold().text_center().child(title) + }), + ) + .when(self.show_close, |this| { + let on_cancel = on_cancel.clone(); + let on_close = on_close.clone(); - this.on_mouse_down(MouseButton::Left, { - let on_cancel = on_cancel.clone(); - let on_close = on_close.clone(); - move |_, window, cx| { - on_cancel(&ClickEvent::default(), window, cx); - on_close(&ClickEvent::default(), window, cx); - window.close_modal(cx); - } - }) - }) + this.child( + Button::new("close") + .icon(IconName::CloseCircleFill) + .absolute() + .top_1p5() + .right_2() + .custom( + ButtonCustomVariant::new(window, cx) + .foreground(cx.theme().icon_muted) + .color(cx.theme().ghost_element_background) + .hover(cx.theme().ghost_element_background) + .active(cx.theme().ghost_element_background), + ) + .on_click(move |_, window, cx| { + on_cancel(&ClickEvent::default(), window, cx); + on_close(&ClickEvent::default(), window, cx); + window.close_modal(cx); + }), + ) + }) + .child( + div() + .pt_px() + .w_full() + .h_auto() + .flex_1() + .overflow_hidden() .child( v_flex() - .id(layer_ix) - .bg(cx.theme().background) - .border_1() - .border_color(cx.theme().border.alpha(0.4)) - .rounded(radius) - .when(cx.theme().shadow, |this| this.shadow_xl()) - .min_h_24() - .key_context(CONTEXT) - .track_focus(&self.focus_handle) - .refine_style(&self.style) - .when(self.keyboard, |this| { - this.on_action({ - let on_cancel = on_cancel.clone(); - let on_close = on_close.clone(); - move |_: &Cancel, window, cx| { - // FIXME: - // - // Here some Modal have no focus_handle, so it will not work will Escape key. - // But by now, we `cx.close_modal()` going to close the last active model, so the Escape is unexpected to work. - on_cancel(&ClickEvent::default(), window, cx); - on_close(&ClickEvent::default(), window, cx); - window.close_modal(cx); - } - }) - .on_action({ - let on_ok = on_ok.clone(); - let on_close = on_close.clone(); - let has_footer = self.footer.is_some(); - move |_: &Confirm, window, cx| { - if let Some(on_ok) = &on_ok { - if on_ok(&ClickEvent::default(), window, cx) { - on_close(&ClickEvent::default(), window, cx); - window.close_modal(cx); - } - } else if has_footer { - window.close_modal(cx); - } - } - }) - }) - // There style is high priority, can't be overridden. - .absolute() - .occlude() - .relative() - .left(x) - .top(y) - .w(self.width) - .when_some(self.max_width, |this, w| this.max_w(w)) - .child( - div() - .px_4() - .h_8() - .w_full() - .flex() - .items_center() - .justify_center() - .when_some(self.title, |this, title| { - this.h_10().font_semibold().text_center().child(title) - }), - ) - .when(self.show_close, |this| { - this.child( - Button::new("close") - .icon(IconName::CloseCircleFill) - .absolute() - .top_1p5() - .right_2() - .custom( - ButtonCustomVariant::new(window, cx) - .foreground(cx.theme().icon_muted) - .color(cx.theme().ghost_element_background) - .hover(cx.theme().ghost_element_background) - .active(cx.theme().ghost_element_background), - ) - .on_click(move |_, window, cx| { - on_cancel(&ClickEvent::default(), window, cx); - on_close(&ClickEvent::default(), window, cx); - window.close_modal(cx); - }), - ) - }) - .child( - div() - .pt_px() - .w_full() - .h_auto() - .flex_1() - .overflow_hidden() - .child( - v_flex() - .pr(padding_right) - .pl(padding_left) - .size_full() - .overflow_y_scrollbar() - .child(self.content), - ), - ) - .when_none(&self.footer, |this| this.child(div().pt(padding_left))) - .when_some(self.footer, |this, footer| { - this.child( - h_flex() - .gap_2() - .pt(padding_left) - .pr(padding_right) - .pb(padding_left) - .pl(padding_right) - .justify_end() - .children(footer(render_ok, render_cancel, window, cx)), - ) - }) - .with_animation("slide-down", animation.clone(), move |this, delta| { - let y_offset = px(0.) + delta * px(30.); - // This is equivalent to `shadow_xl` with an extra opacity. - let shadow = vec![ - BoxShadow { - color: hsla(0., 0., 0., 0.1 * delta), - offset: point(px(0.), px(20.)), - blur_radius: px(25.), - spread_radius: px(-5.), - inset: false, - }, - BoxShadow { - color: hsla(0., 0., 0., 0.1 * delta), - offset: point(px(0.), px(8.)), - blur_radius: px(10.), - spread_radius: px(-6.), - inset: false, - }, - ]; - this.top(y + y_offset).shadow(shadow) - }), - ) - .with_animation("fade-in", animation, move |this, delta| this.opacity(delta)), + .pr(padding_right) + .pl(padding_left) + .size_full() + .overflow_y_scrollbar() + .child(self.content), + ), ) + .when_none(&self.footer, |this| this.child(div().pt(padding_left))) + .when_some(self.footer, |this, footer| { + this.child( + h_flex() + .gap_2() + .pt(padding_left) + .pr(padding_right) + .pb(padding_left) + .pl(padding_right) + .justify_end() + .children(footer(render_ok, render_cancel, window, cx)), + ) + }) + .with_animation("slide-down", animation, move |this, delta| { + let y_offset = px(0.) + delta * px(30.); + // This is equivalent to `shadow_xl` with an extra opacity. + let shadow = vec![ + BoxShadow { + color: hsla(0., 0., 0., 0.1 * delta), + offset: point(px(0.), px(20.)), + blur_radius: px(25.), + spread_radius: px(-5.), + inset: false, + }, + BoxShadow { + color: hsla(0., 0., 0., 0.1 * delta), + offset: point(px(0.), px(8.)), + blur_radius: px(10.), + spread_radius: px(-6.), + inset: false, + }, + ]; + this.top(card_top + y_offset).shadow(shadow) + }); + + Dialog::new(cx) + .layer(layer_ix, is_topmost) + .focus_handle(self.focus_handle.clone()) + .close_on_escape(self.keyboard) + .close_on_backdrop_press(self.overlay_closable) + .on_ok(move |event, window, cx| match &on_ok { + Some(on_ok) => on_ok(event, window, cx), + None => has_footer, + }) + .on_cancel(move |event, window, cx| on_cancel(event, window, cx)) + .on_close(move |event, window, cx| { + on_close(event, window, cx); + window.close_modal(cx); + }) + .backdrop(backdrop) + .popup(card) } } diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs index b297d4e5..026e0135 100644 --- a/crates/ui/src/notification.rs +++ b/crates/ui/src/notification.rs @@ -1,7 +1,7 @@ use std::any::TypeId; -use std::collections::{HashMap, VecDeque}; +use std::collections::HashMap; use std::rc::Rc; -use instant::Duration; +use std::time::Duration; use gpui::prelude::FluentBuilder; use gpui::{ @@ -10,12 +10,25 @@ use gpui::{ ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Window, div, px, relative, }; +use gpui_base::{ + Toast as BaseToast, ToastManager, ToastMotion, ToastOptions, ToastStack, ToastStackState, + ToastTransitionStatus, +}; use theme::ActiveTheme; use crate::animation::cubic_bezier; use crate::button::{Button, ButtonVariants as _}; use crate::{Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex}; +/// How often the notification lifecycle clock is sampled. +const ADVANCE_INTERVAL: Duration = Duration::from_millis(50); + +/// How long a notification stays before it hides itself. +const AUTOHIDE_DURATION: Duration = Duration::from_secs(5); + +/// Request by a notification to be dismissed; the list owns the transition. +struct DismissRequest; + #[derive(Debug, Clone, Copy, Default)] pub enum NotificationKind { #[default] @@ -79,7 +92,7 @@ pub struct Notification { action_builder: Option) -> Button>>, content_builder: Option) -> AnyElement>>, on_click: Option>, - closing: bool, + transition_status: ToastTransitionStatus, } impl From for Notification { @@ -133,7 +146,7 @@ impl Notification { action_builder: None, content_builder: None, on_click: None, - closing: false, + transition_status: ToastTransitionStatus::Starting, } } @@ -238,29 +251,29 @@ impl Notification { } /// Dismiss the notification. - pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context) { - if self.closing { - return; + pub fn dismiss(&mut self, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissRequest); + } + + /// Begin the exit transition, driven by the notification list. + pub(crate) fn begin_close(&mut self, cx: &mut Context) { + if self.transition_status != ToastTransitionStatus::Ending { + self.transition_status = ToastTransitionStatus::Ending; + cx.notify(); } - self.closing = true; - cx.notify(); + } - // Dismiss the notification after 0.15s to show the animation. - cx.spawn(async move |view, cx| { - cx.background_executor() - .timer(Duration::from_secs_f32(0.15)) - .await; + /// Mark the enter transition as finished, driven by the notification list. + pub(crate) fn complete_enter(&mut self, cx: &mut Context) { + if self.transition_status == ToastTransitionStatus::Starting { + self.transition_status = ToastTransitionStatus::Present; + cx.notify(); + } + } - cx.update(|cx| { - if let Some(view) = view.upgrade() { - view.update(cx, |view, cx| { - view.closing = false; - cx.emit(DismissEvent); - }); - } - }) - }) - .detach(); + /// Finish the exit transition, driven by the notification list. + pub(crate) fn complete_close(&mut self, cx: &mut Context) { + cx.emit(DismissEvent); } /// Set the content of the notification. @@ -280,6 +293,7 @@ impl Default for Notification { } impl EventEmitter for Notification {} +impl EventEmitter for Notification {} impl FluentBuilder for Notification {} @@ -319,17 +333,19 @@ impl Render for Notification { _ => cx.theme().text, }; - let closing = self.closing; + let transition_status = self.transition_status; + let closing = transition_status == ToastTransitionStatus::Ending; let has_title = self.title.is_some(); let only_message = !has_title && content.is_none() && action.is_none(); let placement = cx.theme().notification.placement; - h_flex() - .id("notification") + BaseToast::new("notification") + .transition_status(transition_status) + .h_flex() .group("") .occlude() .relative() - .w_112() + .w_full() .border_1() .border_color(cx.theme().border) .bg(background) @@ -455,10 +471,13 @@ impl Render for Notification { /// A list of notifications. pub struct NotificationList { /// Notifications that will be auto hidden. - pub(crate) notifications: VecDeque>, + pub(crate) notifications: ToastManager>, - /// Whether the notification list is expanded. - expanded: bool, + /// Measured geometry and interaction state of the visible stack. + stack_state: ToastStackState, + + /// Whether the lifecycle clock is running. The loop clears it as it exits. + is_advancing: bool, /// Subscriptions _subscriptions: HashMap, @@ -467,12 +486,64 @@ pub struct NotificationList { impl NotificationList { pub fn new(_window: &mut Window, _cx: &mut Context) -> Self { Self { - notifications: VecDeque::new(), - expanded: false, + notifications: ToastManager::new(ToastMotion::default()), + stack_state: ToastStackState::default(), + is_advancing: false, _subscriptions: HashMap::new(), } } + /// Tick the toast lifecycle until the last notification is unmounted. + /// + /// The stack expansion is sampled here because it reaches the list through + /// no event, and an idle window should arm no timer. + fn start_advancing(&mut self, window: &mut Window, cx: &mut Context) { + if self.is_advancing { + return; + } + self.is_advancing = true; + cx.spawn_in(window, async move |view, cx| { + loop { + cx.background_executor().timer(ADVANCE_INTERVAL).await; + let running = view.update(cx, |view, cx| { + view.advance(cx); + view.is_advancing = !view.notifications.is_empty(); + view.is_advancing + }); + if !matches!(running, Ok(true)) { + break; + } + } + }) + .detach(); + } + + fn advance(&mut self, cx: &mut Context) { + let changes = self.notifications.advance( + cx.background_executor().now(), + self.stack_state.is_expanded(), + ); + + for id in changes.presented { + if let Some(note) = self.notifications.get(&id) { + note.update(cx, |note, cx| note.complete_enter(cx)); + } + } + for id in changes.ending { + if let Some(note) = self.notifications.get(&id) { + note.update(cx, |note, cx| note.begin_close(cx)); + } + } + for (id, note) in changes.removed { + self._subscriptions.remove(&id); + note.update(cx, |note, cx| note.complete_close(cx)); + } + + if changes.changed { + cx.notify(); + } + } + pub fn push( &mut self, notification: impl Into, @@ -483,102 +554,110 @@ impl NotificationList { let id = notification.id.clone(); let autohide = notification.autohide; - // Remove the notification by id, for keep unique. - self.notifications.retain(|note| note.read(cx).id != id); - let notification = cx.new(|_| notification); + let dismiss_id = id.clone(); self._subscriptions.insert( id.clone(), - cx.subscribe(¬ification, move |view, _, _: &DismissEvent, cx| { - view.notifications.retain(|note| id != note.read(cx).id); - view._subscriptions.remove(&id); + cx.subscribe(¬ification, move |view, _, _: &DismissRequest, cx| { + if view + .notifications + .dismiss(&dismiss_id, cx.background_executor().now()) + && let Some(note) = view.notifications.get(&dismiss_id) + { + note.update(cx, |note, cx| note.begin_close(cx)); + } }), ); - self.notifications.push_back(notification.clone()); - - if autohide { - // Sleep for 5 seconds to autohide the notification - cx.spawn_in(window, async move |_this, cx| { - cx.background_executor().timer(Duration::from_secs(5)).await; - - if let Err(err) = - notification.update_in(cx, |note, window, cx| note.dismiss(window, cx)) - { - log::error!("failed to auto hide notification: {:?}", err); - } - }) - .detach(); - } + self.notifications.push( + id, + notification, + ToastOptions { + timeout: autohide.then_some(AUTOHIDE_DURATION), + }, + cx.background_executor().now(), + ); + self.start_advancing(window, cx); cx.notify(); } pub(crate) fn close( &mut self, id: impl Into, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { let id: NotificationId = id.into(); - if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) { - n.update(cx, |note, cx| note.dismiss(window, cx)) + if self + .notifications + .dismiss(&id, cx.background_executor().now()) + && let Some(note) = self.notifications.get(&id) + { + note.update(cx, |note, cx| note.begin_close(cx)); } cx.notify(); } - pub fn clear(&mut self, _: &mut Window, cx: &mut Context) { - self.notifications.clear(); + pub fn clear(&mut self, _window: &mut Window, cx: &mut Context) { + for id in self + .notifications + .dismiss_all(cx.background_executor().now()) + { + if let Some(note) = self.notifications.get(&id) { + note.update(cx, |note, cx| note.begin_close(cx)); + } + } cx.notify(); } pub fn notifications(&self) -> Vec> { - self.notifications.iter().cloned().collect() + self.notifications + .iter() + .map(|(_, note, _)| note.clone()) + .collect() } } impl Render for NotificationList { - fn render( - &mut self, - window: &mut gpui::Window, - cx: &mut gpui::Context, - ) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let size = window.viewport_size(); - let items = self.notifications.iter().rev().take(10).rev().cloned(); + let settings = &cx.theme().notification; + let (placement, margins, max_items) = ( + settings.placement, + settings.margins.clone(), + settings.max_items, + ); - let placement = cx.theme().notification.placement; - let margins = &cx.theme().notification.margins; + let items = self + .notifications + .visible(max_items) + .map(|(id, note, _)| (id.clone(), note.clone())) + .collect::>(); - v_flex() - .id("notification-list") + let stack = items + .into_iter() + .fold( + ToastStack::new("notification-list", self.stack_state.clone()), + |stack, (id, note)| stack.item(format!("{id:?}"), note), + ) + .placement(placement) + .v_flex() + .w_112() .max_h(size.height) - .pt(margins.top) - .pb(margins.bottom) - .gap_3() - .when( - matches!(placement, Anchor::TopRight), - |this| this.pr(margins.right), // ignore left - ) - .when( - matches!(placement, Anchor::TopLeft), - |this| this.pl(margins.left), // ignore right - ) - .when( - matches!(placement, Anchor::BottomLeft), - |this| this.flex_col_reverse().pl(margins.left), // ignore right - ) - .when( - matches!(placement, Anchor::BottomRight), - |this| this.flex_col_reverse().pr(margins.right), // ignore left - ) - .when(matches!(placement, Anchor::BottomCenter), |this| { - this.flex_col_reverse() - }) - .on_hover(cx.listener(|view, hovered, _, cx| { - view.expanded = *hovered; - cx.notify() - })) - .children(items) + .absolute() + .map(|this| match placement { + Anchor::TopLeft => this.top(margins.top).left(margins.left), + Anchor::TopRight => this.top(margins.top).right(margins.right), + Anchor::TopCenter => this.top(margins.top).left_0().right_0().mx_auto(), + Anchor::BottomLeft => this.bottom(margins.bottom).left(margins.left), + Anchor::BottomRight => this.bottom(margins.bottom).right(margins.right), + Anchor::BottomCenter => this.bottom(margins.bottom).left_0().right_0().mx_auto(), + Anchor::LeftCenter => this.left(margins.left).top_0().bottom_0().my_auto(), + Anchor::RightCenter => this.right(margins.right).top_0().bottom_0().my_auto(), + }); + + div().size_full().child(stack) } } diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 0893a701..c77ab263 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -2,20 +2,13 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - Anchor, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter, - FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, - ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, Styled, - Subscription, Window, anchored, deferred, div, px, + Anchor, AnyElement, App, Context, Div, ElementId, FocusHandle, InteractiveElement as _, + IntoElement, MouseButton, ParentElement, RenderOnce, Stateful, StyleRefinement, Styled, Window, }; +use gpui_base::Popover as BasePopover; +pub use gpui_base::PopoverState; -use crate::actions::Cancel; -use crate::{ElementExt, Selectable, StyledExt as _, v_flex}; - -const CONTEXT: &str = "Popover"; - -pub(crate) fn init(cx: &mut App) { - cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))]) -} +use crate::{Selectable, StyledExt as _, v_flex}; /// A popover element that can be triggered by a button or any other element. #[derive(IntoElement)] @@ -173,28 +166,6 @@ impl Popover { self.tracked_focus_handle = Some(handle.clone()); self } - - pub(crate) fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds) -> Point { - match anchor { - Anchor::TopLeft => trigger_bounds.origin, - Anchor::TopCenter => trigger_bounds.top_center(), - Anchor::TopRight => trigger_bounds.top_right(), - Anchor::BottomLeft => Point { - x: trigger_bounds.origin.x, - y: trigger_bounds.origin.y - trigger_bounds.size.height, - }, - Anchor::BottomCenter => Point { - x: trigger_bounds.top_center().x, - y: trigger_bounds.origin.y - trigger_bounds.size.height, - }, - Anchor::BottomRight => Point { - x: trigger_bounds.top_right().x, - y: trigger_bounds.origin.y - trigger_bounds.size.height, - }, - // Fallback for LeftCenter/RightCenter – adjust as needed. - _ => trigger_bounds.origin, - } - } } impl ParentElement for Popover { @@ -209,119 +180,7 @@ impl Styled for Popover { } } -pub struct PopoverState { - focus_handle: FocusHandle, - pub(crate) tracked_focus_handle: Option, - trigger_bounds: Bounds, - open: bool, - #[allow(clippy::type_complexity)] - on_open_change: Option>, - - _dismiss_subscription: Option, -} - -impl PopoverState { - pub fn new(default_open: bool, cx: &mut App) -> Self { - Self { - focus_handle: cx.focus_handle(), - tracked_focus_handle: None, - trigger_bounds: Bounds::default(), - open: default_open, - on_open_change: None, - _dismiss_subscription: None, - } - } - - /// Check if the popover is open. - pub fn is_open(&self) -> bool { - self.open - } - - /// Dismiss the popover if it is open. - pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context) { - if self.open { - self.toggle_open(window, cx); - } - } - - /// Open the popover if it is closed. - pub fn show(&mut self, window: &mut Window, cx: &mut Context) { - if !self.open { - self.toggle_open(window, cx); - } - } - - fn toggle_open(&mut self, window: &mut Window, cx: &mut Context) { - self.open = !self.open; - if self.open { - let state = cx.entity(); - let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone() - { - tracked_focus_handle - } else { - self.focus_handle.clone() - }; - focus_handle.focus(window, cx); - - self._dismiss_subscription = - Some( - window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| { - state.update(cx, |state, cx| { - state.dismiss(window, cx); - }); - window.refresh(); - }), - ); - } else { - self._dismiss_subscription = None; - } - - if let Some(callback) = self.on_open_change.as_ref() { - callback(&self.open, window, cx); - } - cx.notify(); - } - - fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - self.dismiss(window, cx); - } -} - -impl Focusable for PopoverState { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for PopoverState { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div() - } -} - -impl EventEmitter for PopoverState {} - impl Popover { - pub(crate) fn render_popover( - anchor: Anchor, - trigger_bounds: Bounds, - content: E, - _: &mut Window, - _: &mut App, - ) -> Deferred - where - E: IntoElement + 'static, - { - deferred( - anchored() - .snap_to_window_with_margin(px(8.)) - .anchor(anchor) - .position(Self::resolved_corner(anchor, trigger_bounds)) - .child(div().relative().child(content)), - ) - .with_priority(1) - } - pub(crate) fn render_popover_content( anchor: Anchor, appearance: bool, @@ -342,91 +201,34 @@ impl Popover { } impl RenderOnce for Popover { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let force_open = self.open; - let default_open = self.default_open; - let tracked_focus_handle = self.tracked_focus_handle.clone(); - let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| { - PopoverState::new(default_open, cx) - }); + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let anchor = self.anchor; + let appearance = self.appearance; + let style = self.style; + let children = self.children; + let content = self.content; - state.update(cx, |state, _| { - if let Some(tracked_focus_handle) = tracked_focus_handle { - state.tracked_focus_handle = Some(tracked_focus_handle); - } - state.on_open_change = self.on_open_change.clone(); - if let Some(force_open) = force_open { - state.open = force_open; - } - }); - - let open = state.read(cx).open; - let focus_handle = state.read(cx).focus_handle.clone(); - let trigger_bounds = state.read(cx).trigger_bounds; - - let Some(trigger) = self.trigger else { - return div().id("empty"); - }; - - let parent_view_id = window.current_view(); - - let el = div() - .id(self.id) - .child((trigger)(open, window, cx)) - .on_mouse_down(self.mouse_button, { - let state = state.clone(); - move |_, window, cx| { - cx.stop_propagation(); - state.update(cx, |state, cx| { - // We force set open to false to toggle it correctly. - // Because if the mouse down out will toggle open first. - state.open = open; - state.toggle_open(window, cx); - }); - cx.notify(parent_view_id); - } + BasePopover::new(self.id) + .anchor(anchor) + .mouse_button(self.mouse_button) + .default_open(self.default_open) + .overlay_closable(self.overlay_closable) + .content(move |state, window, cx| { + Self::render_popover_content(anchor, appearance, window, cx) + .when_some(content, |this, content| { + this.child((content)(state, window, cx)) + }) + .children(children) + .refine_style(&style) }) - .on_prepaint({ - let state = state.clone(); - move |bounds, _, cx| { - state.update(cx, |state, _| { - state.trigger_bounds = bounds; - }) - } - }); - - if !open { - return el; - } - - let popover_content = - Self::render_popover_content(self.anchor, self.appearance, window, cx) - .track_focus(&focus_handle) - .key_context(CONTEXT) - .on_action(window.listener_for(&state, PopoverState::on_action_cancel)) - .when_some(self.content, |this, content| { - this.child(state.update(cx, |state, cx| (content)(state, window, cx))) - }) - .children(self.children) - .when(self.overlay_closable, |this| { - this.on_mouse_down_out({ - let state = state.clone(); - move |_, window, cx| { - state.update(cx, |state, cx| { - state.dismiss(window, cx); - }); - cx.notify(parent_view_id); - } - }) - }) - .refine_style(&self.style); - - el.child(Self::render_popover( - self.anchor, - trigger_bounds, - popover_content, - window, - cx, - )) + .when_some(self.trigger, |this, trigger| this.trigger_with(trigger)) + .when_some(self.open, |this, open| this.open(open)) + .when_some(self.tracked_focus_handle, |this, handle| { + this.track_focus(&handle) + }) + .when_some(self.on_open_change, |this, callback| { + this.on_open_change(move |open, window, cx| callback(open, window, cx)) + }) + .into_any_element() } } diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 34f52fcb..d1e44182 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -93,8 +93,7 @@ impl Root { Some( div() .absolute() - .top_0() - .right_0() + .inset_0() .child(root.read(cx).notification.clone()), ) } diff --git a/crates/ui/src/tooltip.rs b/crates/ui/src/tooltip.rs index f997e5d1..e6dcc4aa 100644 --- a/crates/ui/src/tooltip.rs +++ b/crates/ui/src/tooltip.rs @@ -1,8 +1,9 @@ use gpui::prelude::FluentBuilder; use gpui::{ - div, relative, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, - SharedString, Styled, Window, + App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, + Window, div, relative, }; +use gpui_base::Tooltip as BaseTooltip; use theme::ActiveTheme; pub struct Tooltip { @@ -18,7 +19,7 @@ impl Tooltip { impl Render for Tooltip { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div().child( - div() + BaseTooltip::new("tooltip") .font_family(".SystemUIFont") .m_3() .p_1p5() diff --git a/docs/gpui-base-migration.md b/docs/gpui-base-migration.md index 2673260f..5ef4c8e7 100644 --- a/docs/gpui-base-migration.md +++ b/docs/gpui-base-migration.md @@ -21,7 +21,11 @@ repository at `main`, and this workspace's `Cargo.lock` (zed at `4b47ceb`, - **Phase 2: landed.** `input/` runs on base's editing engine. 6,715 lines of engine and history are gone and 306 are written, taking `crates/ui/src/input` from 6,573 lines to 321. Six call sites changed, all named in phase 2 below. -- **Phases 3-5: not started.** +- **Phase 3: landed.** `tooltip`, `popover`, `modal`, and `notification` run on base's + overlay and feedback primitives. Those four modules are 1,434 lines where they + were 1,592, and nothing outside `crates/ui` changed. The behavioural differences + are named in phase 3 below; the largest is that base's toast stack replaces the + fork's notification list. - One pre-existing, unrelated breakage was found; see [A pre-existing wasm blocker](#a-pre-existing-wasm-blocker). @@ -109,6 +113,9 @@ dragging, both of which are projected. ## What each module becomes +LOC is the count before the work; a module whose phase has landed reads +`before → after`. + | `ui` module | LOC | Plan | `gpui-base` counterpart | | --- | --- | --- | --- | | `input/` (input, clear_button) | 6,573 | Replace; keep `ui::input::{Input, InputEvent, InputState}` as the import path. 321 lines remain, and the engine paints itself through `InputEditorStyle` | `InputState`/`TextareaState` (`InputBaseState` in two modes) plus the `InputBase` frame | @@ -116,10 +123,10 @@ dragging, both of which are projected. | `checkbox.rs` | 312 | Delete | `Checkbox` | | `scroll/` (scrollbar, scrollable, scrollable_mask) | 1,332 | Replace; keep the `ScrollableElement` and `Scrollbar` names | `Scrollbar`, `ScrollableMask` | | `resizable/` | 927 | Replace; base exports the same names (`h_resizable`, `v_resizable`, `resizable_panel`, `PANEL_MIN_SIZE`, `resize_handle`) | `Resizable` + `ResizeHandleRenderer` for the coop hairline | -| `modal.rs` | 540 | Port onto base parts; keep `Modal`, `ModalButtonProps`, and `window.open_modal` | `Dialog`, `AlertDialog` | -| `notification.rs` | 584 | Port; keep `Notification`, `NotificationKind`, and `window.push_notification` | `Toast`, `ToastManager`, `ToastStack` | -| `popover.rs` | 432 | Replace with a coop-styled wrapper | `Popover`, `Popup`, `Positioner` | -| `tooltip.rs` | 36 | Replace with a coop-styled wrapper | `Tooltip` | +| `modal.rs` | 540 → 500 | Port onto base parts; `Modal`, `ModalButtonProps`, and `window.open_modal` unchanged. `Root` still owns the stack | `Dialog` — focus trap, Escape/Enter/backdrop dispatch, layer priority, deferred host | +| `notification.rs` | 584 → 663 | Port; `Notification`, `NotificationKind`, and `window.push_notification` unchanged | `ToastManager` (storage, ids, timers, exit), `ToastStack` (geometry, motion), `Toast` (`Role::Alert`) | +| `popover.rs` | 432 → 234 | Coop's builder over base's element; `PopoverState` is base's, re-exported | `Popover`, `Popup`, `Positioner` | +| `tooltip.rs` | 36 → 37 | Coop's view rooted at base's element | `Tooltip` (`Role::Tooltip`) | | `button.rs` | 626 | Skin: base behavior plus coop's existing variant tables | `Button`, `StateStyle` | | `switch.rs` | 287 | Skin | `Switch`, `SwitchTrack`, `SwitchThumb` | | `avatar.rs` | 141 | Skin | `Avatar`, `AvatarImage`, `AvatarFallback` | @@ -310,18 +317,86 @@ Surfaces to re-verify by hand: the chat composer (auto-grow, Enter to send, IME) profile bio, the subject line, the settings dialog, the relay and messaging lists, the import/restore/backup dialogs, and the sidebar search field. -### Phase 3 — overlays and feedback +### Phase 3 — overlays and feedback — landed -`popover` becomes a wrapper over base `Popover`; `modal` composes base `Dialog` and -`AlertDialog` while keeping the `Modal` API and `window.open_modal`; `notification` -moves onto `Toast`/`ToastManager` (base owns the stack, timers, and motion; coop owns -the visual and the placement from `theme.notification`); `tooltip` becomes a wrapper -over base `Tooltip`. `Root` and `window_ext` keep their public API and host the new -layers. No call site changes. +All four modules keep their names, builders, and call sites. The four files go from +1,592 lines to 1,434, and no file outside `crates/ui` changed. + +| `ui` module | What stayed coop's | What is base's now | +| --- | --- | --- | +| `tooltip` | the whole look, `Tooltip::new(text, window, cx)` and the `Render` view | the element and `Role::Tooltip` | +| `popover` | every builder, the content styling, the anchor | open lifecycle, dismissal, focus capture and restore, deferred registration, trigger measurement and anchor math | +| `modal` | `Modal`, `ModalButtonProps`, `Root`'s stack, `window.open_modal`, the card, buttons, shadows and animations | focus trap, Escape/Enter/backdrop dispatch with a cancel veto, layer priority, the deferred host, `Role::Dialog` | +| `notification` | `Notification`, `NotificationKind`, `window.push_notification`, the card and the placement from `theme.notification` | id-replacing storage, auto-hide and exit timers, stack geometry and motion, `Role::Alert` | + +**`tooltip`.** The view and its `new` are unchanged; the styled box inside is +`gpui_base::Tooltip` instead of a bare `div`. That is what carries the role. Base's +window-level `TooltipOverlay` is deliberately not adopted — gpui's own `.tooltip()` +layer already provides the delay and the placement, and taking the overlay would mean +rewriting every `.tooltip(..)` call site onto `Popup` plus hover state. + +**`popover`.** `PopoverState` is `gpui_base::PopoverState`, re-exported so +`ui::popover::PopoverState` still resolves, and the hand-rolled `anchored`/`deferred` +layer, `resolved_corner` and `render_popover` are gone — base's `Popup` measures the +trigger, resolves the anchor and snaps to the window edge. The rest of the file is the +fork's builder, unchanged, including `trigger_style`, which the fork already stored +without ever reading. Two bindings changed hands: `popover::init` (escape → coop's +`Cancel` in the `Popover` context) is deleted, because `gpui_base::init` binds +escape/enter/space in that same context and coop's lone escape binding would have +shadowed base's `Confirm` — the one that opens a popover from its trigger. + +**`modal`.** `Modal` still assembles the card, title, close button, footer buttons, +the two shadows and the `fade-in`/`slide-down` animations; `Root` still owns the stack, +the focus restore, and the one-visible-overlay rule, now expressed as base's +`layer(index, topmost)`. What changed underneath: + +- Escape, Enter and the backdrop now run through base's `Dialog` decisions, so + `on_cancel`/`on_ok` returning `false` vetoes all three. The fork honored the veto on + the buttons and the backdrop but ignored it on Escape. +- Enter on a modal that has a footer but no `on_ok` now calls `on_close` before closing; + the fork closed silently. No caller combines the two, and `on_close` defaults to a + no-op. +- Tab is trapped inside the modal, and the dialog surface carries `Role::Dialog`. +- `modal::init` (escape/enter in the `Modal` context) is deleted; base binds them in + its own `Dialog` context, which the `Dialog` host installs when `keyboard` is on. +- The dim does not move: coop's backdrop element keeps the `window_paddings` inset and + the `view_size` that the fork used. Its hit area does move — base's host covers the + whole viewport, so a click in the client-side-decoration shadow band now dismisses + the modal instead of starting a window resize. + +`AlertDialog` turned out to be unnecessary. Coop's `alert()` and `confirm()` select a +button set, not an ARIA role, and they already opt out of backdrop dismissal, which is +the whole of what `AlertDialog` adds over `Dialog`. + +**`notification`.** `Notification` keeps its builder and its card. `closing: bool` +becomes base's `ToastTransitionStatus`, `dismiss` now emits a `DismissRequest` the list +turns into a `ToastManager::dismiss`, and the exit delay is base's 200 ms rather than +the fork's fixed 150 ms. `NotificationList` holds +`ToastManager>` plus one `ToastStackState`; its +`expanded` field and hover handler are gone, and a 50 ms lifecycle tick runs only while +something is mounted. The stack is base's: + +- It collapses to three layers with a 14 px peek and a 5% width step per layer, expands + on hover or focus, and pauses auto-hide while expanded. +- The newest notification sits nearest the window edge; the fork's list grew downwards + with the oldest first. +- Motion is `ToastMotion::default()`, base's shadcn/Sonner figures. Coop contributes the + width the fork's card had, the placement and the margins from `theme.notification`. + +That stack is the one visible change of the phase, and it is the one to judge by hand. +If it is not wanted, the smaller step is to keep the list's own `v_flex` and use only +`ToastManager` together with `Toast` — base separates the lifecycle from the geometry, +so nothing else has to come back. + +Surfaces to re-verify by hand: the settings dialog (its Escape and Enter paths), the +import, restore and screening modals (a modal with a textarea, and one with +`keyboard(false)`), the dropdown menus that ride the popover, and every +`push_notification` site — sending an empty message, a failed upload with its retry +action, and the device-approval notification that never auto-hides. ### Phase 4 — leaf controls, scroll, and resizable (one module per pull request) -Order: `tooltip`, `avatar`, `switch`, `button`, `scroll/`, `resizable/`. `button` is the +Order: `avatar`, `switch`, `button`, `scroll/`, `resizable/`. `button` is the largest skin: the `ButtonVariants` and `ButtonCustomVariant` tables, the `compact`, `loading`, and `caret` builders, and the variant names stay as they are, with styling supplied through base's semantic-state styles. `scroll/` keeps the `ScrollableElement` @@ -345,7 +420,12 @@ menu positioning and dismissal on base `Popup`/`Positioner` is optional and late There is no UI test suite to lean on, so each phase gets the same treatment: -- `cargo check` and `cargo build` at the workspace root. +- `cargo check --workspace` and `cargo build` (default members build `desktop`). + `cargo build --workspace` cannot link the web crate's host dylib: `coop_web` is + `crate-type = ["cdylib", "rlib"]` and depends on `wasm-bindgen`, `web-sys`, + `console_log` and `tracing-wasm` unconditionally, so its dylib is a wasm artifact. + That is a property of the manifest rather than of any migrated crate — + `cargo check -p coop_web` passes, and the desktop binary links the same crates. - `cargo check -p theme -p ui --target wasm32-unknown-unknown`. The web target cannot be checked end to end until the pre-existing blocker below is fixed, so the migrated crates are checked directly. @@ -402,7 +482,7 @@ compile for `wasm32-unknown-unknown`". | 1 | Phase 0: `gpui` moves to the `gpui-pre` package, `gpui_tokio` vendored | root `Cargo.toml`, `Cargo.lock`, `web/Cargo.toml`, new `crates/gpui_tokio`; `crates/state` needed no edit | landed | | 2 | Phase 1: base wiring, `sync_base`, deletions | `crates/theme` | landed | | 3 | Phase 2: input, plus `history.rs` and the `ropey`/`sum_tree`/`lsp-types`/`regex`/`unicode-segmentation`/`tree-sitter` pruning | `crates/workspace`, `crates/chat_ui` (six call sites); no manifest outside `crates/ui` | landed | -| 4 | Phase 3: popover, modal, notification, tooltip | none | not started | +| 4 | Phase 3: popover, modal, notification, tooltip | none | landed | | 5–10 | Phase 4: one leaf module each | none | not started | | later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | not started | -- 2.54.0 From 39496445977d9ece72393967b8b8d2ca997377a5 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 17 Sep 2026 16:53:43 +0700 Subject: [PATCH 05/10] migrate switch onto gpui-base --- crates/ui/src/switch.rs | 283 +++++++++++++--------------------------- 1 file changed, 92 insertions(+), 191 deletions(-) diff --git a/crates/ui/src/switch.rs b/crates/ui/src/switch.rs index 375da353..cdf6ab99 100644 --- a/crates/ui/src/switch.rs +++ b/crates/ui/src/switch.rs @@ -1,19 +1,19 @@ -use std::cell::RefCell; use std::rc::Rc; -use instant::Duration; +use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ - Animation, AnimationExt as _, AnyElement, App, Element, ElementId, GlobalElementId, - InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString, Styled as _, - Window, div, px, white, + App, ElementId, IntoElement, ParentElement as _, RenderOnce, SharedString, Styled as _, Window, + div, px, white, }; +use gpui_base::{Spring, Switch as BaseSwitch, SwitchThumb, SwitchTrack, spring}; use theme::{ActiveTheme, Side}; use crate::{Disableable, Sizable, Size}; type OnClick = Option>; +#[derive(IntoElement)] pub struct Switch { id: ElementId, checked: bool, @@ -84,204 +84,105 @@ impl Disableable for Switch { } } -impl IntoElement for Switch { - type Element = Self; +impl RenderOnce for Switch { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let checked = self.checked; + let on_click = self.on_click.clone(); - fn into_element(self) -> Self::Element { - self - } -} + let (bg, toggle_bg) = match checked { + true => (cx.theme().element_background, white()), + false => (cx.theme().elevated_surface_background, white()), + }; -#[derive(Default)] -pub struct SwitchState { - prev_checked: Rc>>, -} + let (bg, toggle_bg) = match self.disabled { + true => (bg.opacity(0.3), toggle_bg.opacity(0.8)), + false => (bg, toggle_bg), + }; -impl Element for Switch { - type PrepaintState = (); - type RequestLayoutState = AnyElement; + let (bg_width, bg_height) = match self.size { + Size::XSmall | Size::Small => (px(28.), px(16.)), + _ => (px(36.), px(20.)), + }; - fn id(&self) -> Option { - Some(self.id.clone()) - } + let bar_width = match self.size { + Size::XSmall | Size::Small => px(12.), + _ => px(16.), + }; - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } + let inset = px(2.); - fn request_layout( - &mut self, - global_id: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - window.with_element_state::(global_id.unwrap(), move |state, window| { - let state = state.unwrap_or_default(); + let thumb_left = spring( + (self.id.clone(), "thumb"), + if checked { + bg_width - bar_width - inset * 2. + } else { + px(0.) + }, + Spring::new(Duration::from_secs_f64(0.15)), + window, + cx, + ); - let theme = cx.theme(); - let checked = self.checked; - let on_click = self.on_click.clone(); + let accessibility_label = self.label.clone(); + let label = self.label; - let (bg, toggle_bg) = match self.checked { - true => (theme.element_background, white()), - false => (theme.elevated_surface_background, white()), - }; - - let (bg, toggle_bg) = match self.disabled { - true => (bg.opacity(0.3), toggle_bg.opacity(0.8)), - false => (bg, toggle_bg), - }; - - let (bg_width, bg_height) = match self.size { - Size::XSmall | Size::Small => (px(28.), px(16.)), - _ => (px(36.), px(20.)), - }; - - let bar_width = match self.size { - Size::XSmall | Size::Small => px(12.), - _ => px(16.), - }; - - let inset = px(2.); - - let mut element = div() + div().child( + BaseSwitch::new(self.id.clone()) + .checked(checked) + .disabled(self.disabled) + .when_some(accessibility_label, |this, label| { + this.accessibility_label(label) + }) + .when_some(on_click, |this, on_click| { + this.on_change(move |next, _event, window, cx| on_click(&next, window, cx)) + }) + .when(self.label_side.is_left(), |this| this.flex_row_reverse()) .child( div() - .id(self.id.clone()) - .when(self.label_side.is_left(), |this| this.flex_row_reverse()) - .child( - div() - .w_full() - .flex() - .justify_between() - .items_center() - .gap_4() - .when_some(self.label.clone(), |this, label| { - // Label - this.child( - div().text_sm().text_color(cx.theme().text).child(label), - ) - }) - .child( - // Switch Bar - div() - .id(self.id.clone()) - .flex_shrink_0() - .w(bg_width) - .h(bg_height) - .rounded(bg_height / 2.) - .flex() - .items_center() - .border(inset) - .border_color(theme.border_transparent) - .bg(bg) - .when(!self.disabled, |this| this.cursor_pointer()) - .child( - // Switch Toggle - div() - .rounded_full() - .when(cx.theme().shadow, |this| this.shadow_sm()) - .bg(toggle_bg) - .size(bar_width) - .map(|this| { - let prev_checked = state.prev_checked.clone(); - if !self.disabled - && prev_checked - .borrow() - .is_some_and(|prev| prev != checked) - { - let dur = Duration::from_secs_f64(0.15); - cx.spawn(async move |cx| { - cx.background_executor() - .timer(dur) - .await; - *prev_checked.borrow_mut() = - Some(checked); - }) - .detach(); - this.with_animation( - ElementId::NamedInteger( - "move".into(), - checked as u64, - ), - Animation::new(dur), - move |this, delta| { - let max_x = bg_width - - bar_width - - inset * 2; - let x = if checked { - max_x * delta - } else { - max_x - max_x * delta - }; - this.left(x) - }, - ) - .into_any_element() - } else { - let max_x = - bg_width - bar_width - inset * 2; - let x = - if checked { max_x } else { px(0.) }; - this.left(x).into_any_element() - } - }), - ), - ), - ) - .when_some(self.description.clone(), |this, description| { - this.child( - div() - .pr_3() - .text_xs() - .text_color(cx.theme().text_muted) - .child(description), - ) + .w_full() + .flex() + .justify_between() + .items_center() + .gap_4() + .when_some(label, |this, label| { + // Label + this.child(div().text_sm().text_color(cx.theme().text).child(label)) }) - .when_some( - on_click - .as_ref() - .map(|c| c.clone()) - .filter(|_| !self.disabled), - |this, on_click| { - let prev_checked = state.prev_checked.clone(); - this.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| { - cx.stop_propagation(); - *prev_checked.borrow_mut() = Some(checked); - on_click(&!checked, window, cx); - }) - }, + .child( + // Switch Bar + SwitchTrack::new((self.id.clone(), "track")) + .checked(checked) + .disabled(self.disabled) + .flex_shrink_0() + .w(bg_width) + .h(bg_height) + .rounded(bg_height / 2.) + .flex() + .items_center() + .border(inset) + .border_color(cx.theme().border_transparent) + .bg(bg) + .when(!self.disabled, |this| this.cursor_pointer()) + .child( + // Switch Toggle + SwitchThumb::new(checked) + .rounded_full() + .when(cx.theme().shadow, |this| this.shadow_sm()) + .bg(toggle_bg) + .size(bar_width) + .left(thumb_left), + ), ), ) - .into_any_element(); - - ((element.request_layout(window, cx), element), state) - }) - } - - fn prepaint( - &mut self, - _: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _: gpui::Bounds, - element: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) { - element.prepaint(window, cx); - } - - fn paint( - &mut self, - _: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _: gpui::Bounds, - element: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - element.paint(window, cx) + .when_some(self.description.clone(), |this, description| { + this.child( + div() + .pr_3() + .text_xs() + .text_color(cx.theme().text_muted) + .child(description), + ) + }), + ) } } -- 2.54.0 From b5d959748f117ff17d6c7534303b4b6190db6d57 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 17 Sep 2026 16:53:43 +0700 Subject: [PATCH 06/10] migrate button onto gpui-base --- crates/ui/src/button.rs | 64 ++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 40 deletions(-) diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index 2647ccc8..81d72f09 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -2,15 +2,16 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement, IntoElement, - ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _, - StyleRefinement, Styled, Window, div, relative, + AnyElement, App, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, + Styled, Window, div, relative, }; +use gpui_base::Button as BaseButton; use theme::ActiveTheme; use crate::indicator::Indicator; use crate::tooltip::Tooltip; -use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex}; +use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, h_flex}; #[derive(Clone, Copy, PartialEq, Eq)] pub struct ButtonCustomVariant { @@ -114,9 +115,7 @@ pub trait ButtonVariants: Sized { #[derive(IntoElement)] #[allow(clippy::type_complexity)] pub struct Button { - id: ElementId, - base: Stateful
, - style: StyleRefinement, + base: BaseButton, icon: Option, label: Option, @@ -151,12 +150,8 @@ impl From