From 436e4631ae0cfdaa83d2cc8a18a54278313a4d7a Mon Sep 17 00:00:00 2001 From: Noah Date: Mon, 15 Jun 2026 21:52:30 -0700 Subject: [PATCH] =?UTF-8?q?feat(text-editing):=20E6=20=E2=80=94=20lifecycl?= =?UTF-8?q?e=20+=20placeholder=20+=20auto-scroll=20+=20TextInput=20+=20clo?= =?UTF-8?q?sure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final phase of the text-editing campaign (E1–E6). Completes the F-tier editor surface: focus lifecycle, placeholder rendering, auto-scroll, the host-facing `EditSubmitted` Message, the `buiy_widgets::TextInput` widget bundle — and the campaign closure (errata fold + spec status flip). Headless + one additive GPU golden. What lands: - **Focus lifecycle** (`text/edit/lifecycle.rs`): a `Local`-based transition detector. On blur, `focus_lifecycle` seals the open undo group and removes the active preedit (the E5-deferred focus-loss removal, with the `invalidate_intrinsics` + Taffy dirty-mark so the orphaned preedit glyphs don't persist a frame), while RETAINING the selection (web parity). Caret visibility is NOT written here — `write_caret_blink` is reworked into the single focus-aware owner: an editor caret that is not the `FocusedEntity` is forced steady-hidden (this also fixes the latent pre-E6 bug that a blurred caret kept blinking); the bare-`CaretVisual`/no-focus-resource paths keep the global phase, so the E3/E5 goldens are unaffected. - **Placeholder** (`text/edit/placeholder.rs`): a separate display-only `PlaceholderBuffer`, shaped under the `SharedFontSystem` lock by `sync_placeholder` (it has no downstream shaper, unlike the editor buffer), shown when `value().is_empty() && !has_preedit()` in the `color.text.placeholder` token. It paints as a SEPARATE additive branch in the glyph producer — the §3.2 extract tripwire stays on the editor buffer only (the placeholder is not part of the editor's `ComputedTextLayout`). - **Auto-scroll** (`text/edit/scroll.rs`): a pure `clamp_into_view` + a system that pans the layout `ScrollOffset` (x single-line / y multi-line) to keep the caret in the clip viewport after each move/edit; clamps against `ResolvedLayout.size`. - **`EditSubmitted`** Message (single-line Enter) — finalizing the § 11 taxonomy; an audit test pins all nine Messages registered. - **`buiy_widgets::TextInput::{single_line,multi_line}`** — an `impl Bundle` (the `Button::new` precedent) composing the core `TextEditState` (built via the new `TextEditState::for_font_size`, which keeps `cosmic_text::Metrics` out of `buiy_widgets`) + markers + focus; `focus_on_click` is widget-side policy (core never auto-focuses). Two majors caught in review + fixed before coding (the caret-visibility ownership collision; the placeholder buffer being shaped by nobody / the §3.2 tripwire) and two more in-driver (the placeholder damage-gate; the GPU golden's ink assertion). Campaign closure (docs): the editing spec is flipped from design-only to **implemented (editing, E1–E6)**; six "As landed (E_n)" errata folded (E1 intrinsics-cache relocation, E4 empty-`Change` drop, E5 `ime_position` is `Vec2`, E6 `Edges` are `Length` / `A11yRole::Text` / lock-free `set_text`); the spec README, `docs/README.md`, and `follow-ups.md` (the named deferrals) updated. Gate: headless 1010 / 0 / 57; GPU lane (RX 6700 XT) 8 / 0 incl. the new `placeholder_paints_when_empty_and_ink_when_typed` golden. **This completes the text-editing campaign — the F-tier editor surface of foundation text.md § 3.5 is delivered (E1–E6).** Plan: docs/plans/2026-06-13-buiy-text-editing-e6-lifecycle-widget-closure.md Co-Authored-By: Claude Fable 5 --- crates/buiy/src/lib.rs | 2 +- crates/buiy_core/src/text/edit/input.rs | 14 + crates/buiy_core/src/text/edit/lifecycle.rs | 100 + crates/buiy_core/src/text/edit/mod.rs | 8 +- crates/buiy_core/src/text/edit/placeholder.rs | 123 + crates/buiy_core/src/text/edit/scroll.rs | 140 + crates/buiy_core/src/text/edit/state.rs | 30 + crates/buiy_core/src/text/extract.rs | 159 +- crates/buiy_core/src/text/mod.rs | 44 +- crates/buiy_core/src/text/visual.rs | 30 +- crates/buiy_core/tests/text_auto_scroll.rs | 115 + .../buiy_core/tests/text_caret_blink_focus.rs | 96 + crates/buiy_core/tests/text_edit_submit.rs | 92 + crates/buiy_core/tests/text_edit_substrate.rs | 13 + crates/buiy_core/tests/text_extract.rs | 80 +- .../buiy_core/tests/text_focus_lifecycle.rs | 103 + .../buiy_core/tests/text_message_taxonomy.rs | 50 + crates/buiy_core/tests/text_placeholder.rs | 124 + .../buiy_core/tests/text_placeholder_gpu.rs | 177 ++ crates/buiy_widgets/src/lib.rs | 6 +- crates/buiy_widgets/src/text_input.rs | 112 + crates/buiy_widgets/tests/text_input.rs | 93 + docs/README.md | 4 +- .../2026-06-13-buiy-text-editing-campaign.md | 7 +- ...ext-editing-e6-lifecycle-widget-closure.md | 2394 +++++++++++++++++ docs/plans/follow-ups.md | 62 + .../README.md | 42 +- .../editing-and-ime.md | 101 +- 28 files changed, 4246 insertions(+), 75 deletions(-) create mode 100644 crates/buiy_core/src/text/edit/lifecycle.rs create mode 100644 crates/buiy_core/src/text/edit/placeholder.rs create mode 100644 crates/buiy_core/src/text/edit/scroll.rs create mode 100644 crates/buiy_core/tests/text_auto_scroll.rs create mode 100644 crates/buiy_core/tests/text_caret_blink_focus.rs create mode 100644 crates/buiy_core/tests/text_edit_submit.rs create mode 100644 crates/buiy_core/tests/text_focus_lifecycle.rs create mode 100644 crates/buiy_core/tests/text_message_taxonomy.rs create mode 100644 crates/buiy_core/tests/text_placeholder.rs create mode 100644 crates/buiy_core/tests/text_placeholder_gpu.rs create mode 100644 crates/buiy_widgets/src/text_input.rs create mode 100644 crates/buiy_widgets/tests/text_input.rs create mode 100644 docs/plans/2026-06-13-buiy-text-editing-e6-lifecycle-widget-closure.md diff --git a/crates/buiy/src/lib.rs b/crates/buiy/src/lib.rs index 6cfd418..07813f0 100644 --- a/crates/buiy/src/lib.rs +++ b/crates/buiy/src/lib.rs @@ -39,7 +39,7 @@ pub use buiy_core::{ }, theme::{Theme, UserPreferences, default_light_theme}, }; -pub use buiy_widgets::{Button, OnPress, WidgetsPlugin}; +pub use buiy_widgets::{Button, OnPress, TextInput, WidgetsPlugin}; // `buiy_core::render::ExtractedDraws` is intentionally NOT re-exported at // the crate root: it is a render-world resource only, populated during the diff --git a/crates/buiy_core/src/text/edit/input.rs b/crates/buiy_core/src/text/edit/input.rs index 3c25fcd..a63839b 100644 --- a/crates/buiy_core/src/text/edit/input.rs +++ b/crates/buiy_core/src/text/edit/input.rs @@ -21,6 +21,14 @@ use super::undo::{GroupKind, UndoUnit}; #[derive(Message, Debug, Clone, Copy, PartialEq, Eq)] pub struct TextChanged(pub Entity); +/// Emitted when a single-line editor is submitted (editing-and-ime § 11 row +/// `EditSubmitted`, § 3.3). Born from `EditCommand::Submit` — the focused +/// single-line Enter. Payload: the entity (the value is read via the +/// component, per the § 11 contract). This FINALIZES the § 11 taxonomy +/// (the host-facing surface of E2's internal `EditOutcome.submitted` flag). +#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)] +pub struct EditSubmitted(pub Entity); + /// What one `apply` did, so the system can emit the right Messages. `value` /// changes drive `TextChanged`; `submitted` drives the internal Submit path /// (E6 turns it into the host-facing `EditSubmitted`). @@ -461,6 +469,7 @@ pub fn apply_keyboard_edits( mut changed: MessageWriter, mut undone: MessageWriter, mut redone: MessageWriter, + mut submitted: MessageWriter, ) { // No input infrastructure (no `InputPlugin` and no manual seed) ⇒ the // `KeyboardInput` Message and the `ButtonInput` resource are @@ -544,6 +553,7 @@ pub fn apply_keyboard_edits( let mut font_system = fonts.lock(); let mut any_value_change = false; let mut any_reshape = false; + let mut any_submit = false; for command in commands { // Capture the group BEFORE applying, for the undo/redo Messages. let was_undo = command == EditCommand::Undo; @@ -562,6 +572,7 @@ pub fn apply_keyboard_edits( let outcome = state.apply_tracked(&mut font_system, command, &mut ctx); any_value_change |= outcome.value_changed; any_reshape |= outcome.reshaped; + any_submit |= outcome.submitted; if was_undo && outcome.value_changed && let Some(g) = group_before_undo @@ -597,4 +608,7 @@ pub fn apply_keyboard_edits( if any_value_change { changed.write(TextChanged(entity)); } + if any_submit { + submitted.write(EditSubmitted(entity)); + } } diff --git a/crates/buiy_core/src/text/edit/lifecycle.rs b/crates/buiy_core/src/text/edit/lifecycle.rs new file mode 100644 index 0000000..a53ba67 --- /dev/null +++ b/crates/buiy_core/src/text/edit/lifecycle.rs @@ -0,0 +1,100 @@ +//! E6 — focus lifecycle (editing-and-ime § 10). One render-prep system, +//! `focus_lifecycle`, detects the `FocusedEntity` transition (gain / loss) +//! via a `Local>` previous-value compare (no transition +//! detector existed before E6) and runs the spec § 10 edges: +//! +//! - on GAIN of a non-`Disabled` editor: the blink phase resets (the user +//! just acted — the focused caret is solid-on for one half-period, web +//! parity); +//! - on LOSS of an editor: the open undo group seals (`seal()`), and any live +//! preedit is removed (wiring E5's deferred focus-loss removal — E5's +//! `apply_ime` only removes on `Ime::Disabled`, never on a bare focus +//! change). The **selection / buffer is RETAINED** (we never touch +//! `SelectionVisual` — re-focus restores it, web parity). +//! +//! **Caret visibility is NOT touched here (M1).** `write_caret_blink` is the +//! single focus-aware owner of `CaretVisual.visible` (it forces a non-focused +//! editor caret hidden and blinks the focused one). `focus_lifecycle` only +//! resets the blink ORIGIN on gain. +//! +//! `ime_enabled` is also NOT handled here: E5's `write_ime_window` already +//! decides it from focus + markers alone every frame (`ime.rs` enable_q). +//! +//! **The M1 dirty-mark.** `remove_preedit` does direct BufferLine surgery and +//! does NOT invalidate intrinsics or Taffy-dirty the node (verified `ime.rs` — +//! the removal there relies on `apply_ime`'s own dirty-mark). On a bare focus +//! change there is no `apply_ime` pass, so `focus_lifecycle` MUST do the same +//! dirty-mark itself after removing the preedit, or the orphaned preedit glyphs +//! persist a frame: `invalidate_intrinsics()` + +//! `tree.mark_dirty_for_entity(entity)` (the `apply_keyboard_edits` / +//! `apply_ime` seam). +//! +//! It names only the pure-data cosmic types via the facade accessors +//! (`remove_preedit` locks the `SharedFontSystem`; `seal` names none), so it +//! stays inside the `text::edit` facade. + +use bevy::prelude::*; + +use super::state::{Disabled, TextEditState}; +use crate::FocusedEntity; +use crate::layout::LayoutTree; +use crate::text::SharedFontSystem; + +/// Render-prep: react to focus gain / loss for editor entities (§ 10). Runs in +/// the `.after(BuiySet::Input).before(write_caret_blink)` window alongside the +/// E3 caret writer and E5 IME window writer. +/// +/// `Local>` holds last frame's focused entity — the canonical +/// transition detector E6 introduces (the codebase had none). Option params +/// (`focused`, `tree`) follow the inert-harness discipline — a bare +/// `BuiyTextPlugin` without `FocusPlugin` / `LayoutPlugin` no-ops instead of +/// panicking at param validation (the `apply_ime` precedent). +#[allow(clippy::type_complexity)] +pub fn focus_lifecycle( + time: Res