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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/buiy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions crates/buiy_core/src/text/edit/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -461,6 +469,7 @@ pub fn apply_keyboard_edits(
mut changed: MessageWriter<TextChanged>,
mut undone: MessageWriter<super::undo::EditUndone>,
mut redone: MessageWriter<super::undo::EditRedone>,
mut submitted: MessageWriter<EditSubmitted>,
) {
// No input infrastructure (no `InputPlugin` and no manual seed) ⇒ the
// `KeyboardInput` Message and the `ButtonInput<KeyCode>` resource are
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -597,4 +608,7 @@ pub fn apply_keyboard_edits(
if any_value_change {
changed.write(TextChanged(entity));
}
if any_submit {
submitted.write(EditSubmitted(entity));
}
}
100 changes: 100 additions & 0 deletions crates/buiy_core/src/text/edit/lifecycle.rs
Original file line number Diff line number Diff line change
@@ -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<Option<Entity>>` 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<Option<Entity>>` 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<Time>,
focused: Option<Res<FocusedEntity>>,
fonts: Res<SharedFontSystem>,
mut tree: Option<NonSendMut<LayoutTree>>,
mut prev: Local<Option<Entity>>,
mut editors: Query<&mut TextEditState, Without<Disabled>>,
) {
let Some(focused) = focused.as_ref() else {
return;
};
let now = time.elapsed();
let current = focused.0;
if current == *prev {
return; // no transition — the common case
}
let lost = *prev;
let gained = current;
*prev = current;

// --- LOSS: seal undo, remove preedit (+ M1 dirty-mark), RETAIN selection --
if let Some(old) = lost
&& let Ok(mut state) = editors.get_mut(old)
{
state.seal_undo_for_lifecycle();
if state.has_preedit() {
{
let mut fs = fonts.lock();
state.remove_preedit(&mut fs);
}
// The M1 dirty-mark: remove_preedit changed the buffer but tripped
// no TextSyncTrigger and did not invalidate — do it here so next
// frame's measure → TextCommit → extract republishes (the
// apply_ime / apply_keyboard_edits seam).
state.invalidate_intrinsics();
if let Some(tree) = tree.as_deref_mut() {
tree.mark_dirty_for_entity(old);
}
}
}

// --- GAIN: reset the blink origin (caret visibility is the blink writer's) -
if let Some(new) = gained
&& let Ok(mut state) = editors.get_mut(new)
{
state.blink.reset(now);
}
}
8 changes: 7 additions & 1 deletion crates/buiy_core/src/text/edit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ mod command;
mod ime;
mod input;
pub mod keymap;
mod lifecycle;
mod placeholder;
mod pointer;
mod scroll;
mod selection;
mod state;
mod undo;
Expand All @@ -31,9 +34,12 @@ pub use ime::{
CompositionEnd, CompositionStart, CompositionUpdate, PREEDIT_METADATA, PreeditSpan, apply_ime,
write_ime_window,
};
pub use input::{EditContext, EditOutcome, TextChanged, apply_keyboard_edits};
pub use input::{EditContext, EditOutcome, EditSubmitted, TextChanged, apply_keyboard_edits};
pub use keymap::{Keymap, KeymapTable, Modifiers, default_keymap_for_platform};
pub use lifecycle::focus_lifecycle;
pub use placeholder::{PlaceholderActive, PlaceholderBuffer, sync_placeholder};
pub use pointer::{ClickTracker, PointerGesture, pointer_selection, pointer_to_cursor};
pub use scroll::{auto_scroll_caret, clamp_into_view};
pub use selection::{SelectionRange, TextSelection};
pub use state::{CaretBlink, Disabled, Placeholder, ReadOnly, SingleLine, TextEditState};
pub use undo::{DEFAULT_UNDO_DEPTH, EditRedone, EditUndone, GroupKind, UndoStack, UndoUnit};
123 changes: 123 additions & 0 deletions crates/buiy_core/src/text/edit/placeholder.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//! E6 — placeholder rendering state (editing-and-ime § 10,
//! decoration-and-paint § 7). The placeholder is "just text with a different
//! tint" (the spec decision; the rejected runner-up is a dedicated placeholder
//! paint path). E6 maintains a `PlaceholderActive` marker — present iff the
//! editor's logical value is empty (preedit excluded) AND a non-empty
//! `Placeholder` string exists — and shapes the string into a display-only
//! `PlaceholderBuffer` the glyph producer paints in `color.text.placeholder`.
//! The string never enters the editor buffer or the undo history.
//!
//! **M3 — the placeholder buffer shapes ITSELF.** Unlike the editor buffer
//! (which `TextCommit` reshapes downstream via `TextBufferAccess`), nothing
//! downstream touches a `PlaceholderBuffer` — so `sync_placeholder` must lock
//! `SharedFontSystem` and call `buffer.shape_until_scroll(&mut fs, false)`
//! after `set_text`, or `layout_runs()` stays empty and the placeholder paints
//! nothing. This system takes the lock (correct — it runs in the
//! `BuiyLayoutStep::TextSync` step, the measure-pipeline lock window).
//!
//! This file names only the pure-data `Buffer`/`Metrics`/`Attrs`/`Shaping`
//! cosmic types (no `Editor`/`Edit`/`Action`/`Change`), so it stays inside the
//! `text::edit` facade.

use bevy::prelude::*;
use cosmic_text::{Attrs, Buffer, Metrics, Shaping};

use super::state::{Disabled, Placeholder, TextEditState};
use crate::text::{FontSize, SharedFontSystem};

/// Marker: the placeholder is currently shown (the editor value is empty,
/// preedit excluded, and a non-empty `Placeholder` exists). Drives the
/// producer's DAMAGE gate (a `Changed<PlaceholderActive>` probe member) and the
/// headless test; the producer's PAINT branch keys on `PlaceholderBuffer`
/// presence (M2 — the inner extract tuple is at the 15-cap, so the marker is
/// not in it). Lean derives (not reflect-registered — no authored data).
#[derive(Component, Default, Clone, Copy, Debug, PartialEq, Eq)]
pub struct PlaceholderActive;

/// The display-only shaped buffer for the placeholder string. NEVER the
/// editor buffer (§ 10: "the placeholder never enters the editor Buffer").
/// `pub buffer` so the run-count test + the producer read it; not
/// reflect-registered (carries a cosmic `Buffer`, the cosmic boundary).
#[derive(Component)]
pub struct PlaceholderBuffer {
pub buffer: Buffer,
/// The string the buffer was last shaped from — so we only re-shape on a
/// `Placeholder` text change, not every frame.
shaped_from: String,
}

/// Main-world (the `BuiyLayoutStep::TextSync` step): maintain the
/// `PlaceholderActive` marker and the `PlaceholderBuffer` for every editor with
/// a `Placeholder`. Present the marker iff `value().is_empty() &&
/// !has_preedit()` and the placeholder string is non-empty; remove BOTH the
/// marker and the buffer otherwise (so the producer's
/// `PlaceholderBuffer`-presence paint signal is exact). When active, shape the
/// string into the display-only buffer (M3 — its own `shape_until_scroll`,
/// since nothing downstream shapes it).
///
/// Runs in the same step as `text_sync_buffers` (the measure-pipeline lock
/// window); `Without<Disabled>` follows the editor-system discipline.
#[allow(clippy::type_complexity)]
pub fn sync_placeholder(
mut commands: Commands,
fonts: Res<SharedFontSystem>,
mut editors: Query<
(
Entity,
&TextEditState,
&Placeholder,
Option<&FontSize>,
Option<&mut PlaceholderBuffer>,
Has<PlaceholderActive>,
),
Without<Disabled>,
>,
) {
for (entity, state, placeholder, font_size, ph_buffer, was_active) in &mut editors {
let active = state.value().is_empty() && !state.has_preedit() && !placeholder.0.is_empty();

if active && !was_active {
commands.entity(entity).insert(PlaceholderActive);
} else if !active && was_active {
// Remove BOTH — the producer paints on PlaceholderBuffer presence.
commands.entity(entity).remove::<PlaceholderActive>();
commands.entity(entity).remove::<PlaceholderBuffer>();
}

if !active {
continue;
}

// Shape the placeholder string into the display-only buffer. M3: lock
// and shape OURSELVES — nothing downstream shapes a PlaceholderBuffer
// (TextCommit only reshapes the editor-owned buffer). Skip when the
// string is unchanged (already shaped this content).
let size = font_size.map(|f| f.0).unwrap_or(16.0);
let metrics = Metrics::new(size, size * 1.2);
match ph_buffer {
Some(buf) if buf.shaped_from == placeholder.0 => { /* unchanged */ }
Some(mut buf) => {
// `set_metrics` / `set_text` record + dirty (no FontSystem);
// `shape_until_scroll` does the actual shape under the lock.
buf.buffer.set_metrics(metrics);
buf.buffer
.set_text(&placeholder.0, &Attrs::new(), Shaping::Advanced, None);
// M3: shape OURSELVES — shape_until_scroll DOES take the lock.
buf.buffer.shape_until_scroll(&mut fonts.lock(), false);
buf.shaped_from = placeholder.0.clone();
}
None => {
// `Buffer::new(&mut fs, metrics)` is the shaped constructor;
// set_text (no fs, defers) + shape_until_scroll (fs, shapes).
let mut fs = fonts.lock();
let mut buffer = Buffer::new(&mut fs, metrics);
buffer.set_text(&placeholder.0, &Attrs::new(), Shaping::Advanced, None);
buffer.shape_until_scroll(&mut fs, false);
commands.entity(entity).insert(PlaceholderBuffer {
buffer,
shaped_from: placeholder.0.clone(),
});
}
}
}
}
Loading
Loading