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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ Other useful one-offs:
- `BUIY_ACCEPT_SHAPING=1 cargo test -p buiy_core --test text_shaping_snapshots`
— regenerate the `.snap` shaping snapshots (curated: review the diff before
committing).
- `cargo test -p buiy_core --features clipboard-image` — exercise the clipboard
image flavor (`ClipboardImage`, `get_image`/`set_image`). The default
workspace gate runs with this feature **OFF** (the image module compiles out),
so this gated lane must be run separately to keep the image path from rotting.

## Code Conventions

Expand Down
7 changes: 7 additions & 0 deletions crates/buiy_core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ default = ["default_font"]
# Family::SansSerif etc. resolve identically on every host. Disable to ship
# zero font bytes — the app must then register fonts before any text renders.
default_font = []
# The clipboard image flavor (editing-and-ime § 7). OFF by default: it turns on
# arboard's `image-data` feature, which drags in the image-clipboard deps
# (objc2-core-graphics on macOS, gdi on Windows; the `image` crate is already
# in-tree). The text + HTML flavors need none of this, so the text-only build
# stays lean. Exercised with `cargo test -p buiy_core --features clipboard-image`
# — the default `cargo test --workspace` gate runs with this OFF.
clipboard-image = ["arboard/image-data"]

[dev-dependencies]
naga = "29"
Expand Down
19 changes: 18 additions & 1 deletion crates/buiy_core/src/text/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,15 +388,25 @@ pub struct CaretVisual {
pub visible: bool,
/// Content-box-local caret rect, logical px, unsnapped.
pub rect: bevy::math::Rect,
/// The SECONDARY split-caret indicator rect (§§ 4.1, 5), content-box-local,
/// logical px, unsnapped. `Some` only when the caret sits on a bidirectional
/// DIRECTION BOUNDARY — the BEFORE glyph's logical-end visual edge, a shorter
/// mark that tells the user which direction the next typed char flows.
/// `None` for a normal caret (no second insertion point). Rides this
/// component (not a standalone one) because the extract producer's
/// query/trigger/removal params are at Bevy's 15-tuple cap (extract.rs § 6.1),
/// so it reuses `Changed<CaretVisual>` as its damage trigger.
pub secondary: Option<bevy::math::Rect>,
}

impl Default for CaretVisual {
/// Visible (matches the t=0 blink phase and editing § 10's
/// "caret becomes visible" on focus gain), zero rect.
/// "caret becomes visible" on focus gain), zero rect, no secondary.
fn default() -> Self {
Self {
visible: true,
rect: bevy::math::Rect::default(),
secondary: None,
}
}
}
Expand Down Expand Up @@ -691,4 +701,11 @@ mod tests {
buffer.invalidate_intrinsics();
assert_eq!(buffer.intrinsics(), None);
}

/// §§ 4.1, 5: a fresh caret has no split-caret secondary indicator — the
/// secondary lands only when the writer finds a bidi direction boundary.
#[test]
fn caret_visual_default_has_no_secondary() {
assert_eq!(CaretVisual::default().secondary, None);
}
}
106 changes: 99 additions & 7 deletions crates/buiy_core/src/text/edit/caret.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
//! Disabled editor's selection OUT into `TextSelection` + the T7 paint seats
//! (`CaretVisual`, `SelectionVisual`), resets the blink phase on a caret/
//! selection transition, and emits `CaretMoved` / `SelectionChanged` on
//! transition only. E3 paints the SINGLE primary caret; the BiDi split caret is
//! a named deferral (cosmic 0.19 exposes no dual-caret API — see the plan's
//! Architecture + the follow-up filed in Task 7).
//! transition only. The PRIMARY caret is `cursor_position` from the run that
//! owns the cursor. The SECONDARY indicator (the BiDi split caret, §§ 4.1, 5)
//! now lands as `secondary_caret_rect_for`: at a direction boundary it is the
//! BEFORE glyph's (`end == index`) logical-end visual edge — the position
//! cosmic 0.19's single affinity-blind `cursor_glyph` cannot surface (it
//! resolves `index == glyph.start` BEFORE `index == glyph.end`, buffer.rs:
//! 151-174, so its one `cursor_position` only ever reports the start-glyph
//! edge). Non-boundary carets have no secondary (`None`).
//!
//! It reads the editor through `TextEditState`'s facade accessors
//! (`mirror_selection` / `with_buffer`) and names only the pure-data cosmic
Expand Down Expand Up @@ -39,14 +44,22 @@ pub struct SelectionChanged(pub Entity, pub TextSelection);
/// `caret_stamp_rect`). A thin vertical bar; 1.0 logical is the convention.
const CARET_W: f32 = 1.0;

/// The secondary indicator's height as a fraction of the line box (§ 4.1: the
/// split caret's SECONDARY mark is a shorter indicator, not a second full-height
/// bar — it tells the user which direction the next typed char flows without
/// reading as a second insertion point). A v1 visual choice; tests assert only
/// that it is `<=` the primary height, so a later tweak doesn't churn them.
const SECONDARY_CARET_H_FRAC: f32 = 0.5;

/// The caret rect for `caret` in CONTENT-BOX-LOCAL coords (logical px), from the
/// run that owns the cursor: `cursor_position` gives x, `line_top`/`line_height`
/// give y/height (§ 4.1). Returns `None` if no run owns the cursor (degenerate /
/// not-yet-shaped buffer).
///
/// `pub(crate)` so `ime.rs`'s `write_ime_window` reuses it for the caret rect
/// the IME popup anchors to (`Window.ime_position`, editing-and-ime § 6.3).
pub(crate) fn caret_rect_for(buffer: &cosmic_text::Buffer, caret: &Cursor) -> Option<Rect> {
/// `pub` so `ime.rs`'s `write_ime_window` reuses it for the caret rect the IME
/// popup anchors to (`Window.ime_position`, editing-and-ime § 6.3), and so the
/// E3 caret-geometry tests can pin it against a shaped buffer directly.
pub fn caret_rect_for(buffer: &cosmic_text::Buffer, caret: &Cursor) -> Option<Rect> {
for run in buffer.layout_runs() {
if let Some(x) = run.cursor_position(caret) {
return Some(Rect::new(
Expand All @@ -60,6 +73,78 @@ pub(crate) fn caret_rect_for(buffer: &cosmic_text::Buffer, caret: &Cursor) -> Op
None
}

/// The SECONDARY split-caret rect (§§ 4.1, 5) in CONTENT-BOX-LOCAL coords
/// (logical px), or `None` when `caret` does not sit on a bidirectional
/// DIRECTION BOUNDARY.
///
/// At a direction boundary two glyphs abut the caret's byte index: a BEFORE
/// glyph (`end == caret.index`) and an AFTER glyph (`start == caret.index`).
/// cosmic 0.19's `cursor_glyph` (buffer.rs:151-174) resolves the AFTER glyph
/// first (`index == glyph.start` before `index == glyph.end`), so the PRIMARY
/// caret (`caret_rect_for`) already lands at the AFTER glyph's edge. The
/// SECONDARY is the OTHER abutting glyph — the BEFORE glyph — at its
/// LOGICAL-END visual edge, per cosmic's own direction convention (buffer.rs:
/// 120-142, mirrored by `cursor_from_glyph_right` buffer.rs:191-197): an LTR
/// glyph's logical end is its right edge (`x + w`), an RTL glyph's is its left
/// edge (`x`). This is exactly the position cosmic's single affinity-blind
/// `cursor_position` cannot surface — hence a dedicated walk here.
///
/// Returns `None` unless BOTH glyphs exist within ONE `line_i`-matching run AND
/// they have OPPOSITE directions (`before.level.is_rtl() != after.level.is_rtl()`).
/// A run extremity (only one abutting glyph) or a same-direction join is a normal
/// caret with no second insertion point.
///
/// A logical line that SOFT-WRAPS emits MULTIPLE `LayoutRun`s sharing the same
/// `line_i` (cosmic 0.19 `LayoutRunIter`: one run per wrapped `layout_line`, all
/// with that line's `line_i`; each run's `glyphs` is the line-relative sub-slice
/// for its wrap segment). The caret's index may live on a CONTINUATION segment,
/// whose glyphs are in a LATER run — so a `line_i`-matching run that holds
/// NEITHER an abutting before NOR after glyph at the index is the WRONG wrap
/// segment, not a verdict. Mirror `caret_rect_for`'s all-runs scan: CONTINUE
/// past such a run; only conclude `None` after exhausting every run. (The
/// primary path gets this for free — `run.cursor_position` returns `None` for a
/// non-owning run and the loop continues; this walk must do the same explicitly.)
///
/// `pub` so `write_caret_and_selection` populates `CaretVisual.secondary` from
/// it (a second solid-stamp instance, CPU geometry only — no new GPU), and so
/// the E3 caret-geometry tests can pin it against a shaped buffer directly.
pub fn secondary_caret_rect_for(buffer: &cosmic_text::Buffer, caret: &Cursor) -> Option<Rect> {
for run in buffer.layout_runs() {
if run.line_i != caret.line {
continue;
}
// The two abutting glyphs at the caret's byte index. A cluster that
// STRADDLES the index (`start < index < end`) is not a boundary — the
// caret is mid-cluster, a single insertion point.
let before = run.glyphs.iter().find(|g| g.end == caret.index);
let after = run.glyphs.iter().find(|g| g.start == caret.index);
let (Some(before), Some(after)) = (before, after) else {
// Neither/only-one abutting glyph: this is the WRONG wrap segment of
// a soft-wrapped logical line (the owning run is later), or a genuine
// run extremity. Either way, keep scanning — a LATER `line_i`-
// matching run may own both glyphs. Only after exhausting every run
// is `None` the verdict.
continue;
};
if before.level.is_rtl() == after.level.is_rtl() {
return None; // same direction → no split (this run owns the index)
}
// The BEFORE glyph's logical-end visual edge (cosmic's convention).
let sec_x = if before.level.is_rtl() {
before.x
} else {
before.x + before.w
};
return Some(Rect::new(
sec_x,
run.line_top,
sec_x + CARET_W,
run.line_top + run.line_height * SECONDARY_CARET_H_FRAC,
));
}
None
}

/// Render-prep: drive every focused, non-`Disabled` editor's caret + selection
/// paint seats from the editor state, emit transition Messages, reset blink.
#[allow(clippy::type_complexity)]
Expand Down Expand Up @@ -117,15 +202,22 @@ pub fn write_caret_and_selection(
let Some(new_rect) = state.with_buffer(|buffer| caret_rect_for(buffer, &caret)) else {
continue;
};
// The SECONDARY split-caret indicator (§§ 4.1, 5): `Some` only when the
// caret sits on a bidirectional direction boundary, else `None`.
let secondary = state.with_buffer(|buffer| secondary_caret_rect_for(buffer, &caret));

let caret_changed = prev_caret.map(|c| c.rect) != Some(new_rect);
// Compare the PAIR: a boundary crossing that changes only the secondary
// (same primary x) must still re-emit, else a stale secondary paints.
let caret_changed =
prev_caret.map(|c| (c.rect, c.secondary)) != Some((new_rect, secondary));
if caret_changed {
// Preserve the current visibility (the blink writer owns it); a NEW
// caret defaults visible.
let visible = prev_caret.map(|c| c.visible).unwrap_or(true);
commands.entity(entity).insert(CaretVisual {
visible,
rect: new_rect,
secondary,
});
}

Expand Down
105 changes: 95 additions & 10 deletions crates/buiy_core/src/text/edit/clipboard.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,52 @@
//! The clipboard facade (editing-and-ime § 7). `arboard` behind a
//! `ClipboardProvider` Resource trait-object so tests inject a fake and the
//! dependency stays swappable. v1 is PLAIN TEXT only (HTML/image deferred —
//! pre-campaign decision 4). This file names NO cosmic type, so the
//! facade-boundary tripwire does not constrain it — it lives in `text::edit`
//! for cohesion (it is editing mechanism), not because it must.
//! dependency stays swappable. v1 shipped PLAIN TEXT only; the named follow-up
//! slice adds an HTML flavor (always available) and an image flavor (behind the
//! `clipboard-image` cargo feature, which turns on arboard's `image-data`).
//! This file names NO cosmic type, so the facade-boundary tripwire does not
//! constrain it — it lives in `text::edit` for cohesion (it is editing
//! mechanism), not because it must.

use bevy::prelude::Resource;

/// The swappable clipboard backend. Plain text only in v1. Both methods take
/// `&mut self`: a real clipboard owns OS handles that mutate on read on some
/// platforms, and the fake owns interior state — `&mut` keeps the trait
/// honest for both. Errors are swallowed to `None` / no-op (a clipboard that
/// is unavailable must never crash an editor; spec § 7 "must not be optional"
/// is about presence, not infallibility).
/// An owned clipboard image (RGBA8, row-major, `width * height * 4` bytes).
/// Buiy-owned so the [`ClipboardProvider`] trait signature names no arboard
/// `ImageData<'a>` borrowed-lifetime type; conversion to/from arboard happens
/// at the [`ArboardClipboard`] boundary (a one-time byte copy — clipboard
/// payloads are not a hot path). Behind the `clipboard-image` feature because
/// the image flavor pulls arboard's `image-data` deps.
#[cfg(feature = "clipboard-image")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipboardImage {
pub width: usize,
pub height: usize,
pub bytes: Vec<u8>,
}

/// The swappable clipboard backend. Carries a plain-text flavor (always) and an
/// HTML flavor (always); the image flavor is behind the `clipboard-image`
/// feature. All methods take `&mut self`: a real clipboard owns OS handles that
/// mutate on read on some platforms, and the fake owns interior state — `&mut`
/// keeps the trait honest for both. Errors are swallowed to `None` / no-op (a
/// clipboard that is unavailable must never crash an editor; spec § 7 "must not
/// be optional" is about presence, not infallibility).
pub trait ClipboardProvider: Send + Sync + 'static {
/// The current clipboard text, or `None` if empty / unavailable.
fn get_text(&mut self) -> Option<String>;
/// Replace the clipboard text. A failure is a silent no-op.
fn set_text(&mut self, text: String);
/// The current clipboard HTML flavor, or `None` if absent / unavailable.
/// A plain-text editor reads text by preference; the HTML getter is here
/// for rich-content callers, not for the § 3.3 plain-text Paste path.
fn get_html(&mut self) -> Option<String>;
/// Replace the clipboard HTML flavor. A failure is a silent no-op.
fn set_html(&mut self, html: String);
/// The current clipboard image, or `None` if absent / unavailable.
#[cfg(feature = "clipboard-image")]
fn get_image(&mut self) -> Option<ClipboardImage>;
/// Replace the clipboard image. A failure is a silent no-op.
#[cfg(feature = "clipboard-image")]
fn set_image(&mut self, image: ClipboardImage);
}

/// The active provider, a Resource newtype over the boxed trait object
Expand Down Expand Up @@ -59,6 +88,41 @@ impl ClipboardProvider for ArboardClipboard {
let _ = h.set_text(text);
}
}

fn get_html(&mut self) -> Option<String> {
// `get().html()` is on arboard's cross-platform Get builder and is NOT
// behind `image-data` (verified against arboard 3.6.1).
self.handle()?.get().html().ok()
}

fn set_html(&mut self, html: String) {
if let Some(h) = self.handle() {
// No separate alt text: a plain-text editor's html flavor is just
// escaped text, and the text flavor is set independently.
let _ = h.set_html(html, None);
}
}

#[cfg(feature = "clipboard-image")]
fn get_image(&mut self) -> Option<ClipboardImage> {
let img = self.handle()?.get_image().ok()?;
Some(ClipboardImage {
width: img.width,
height: img.height,
bytes: img.bytes.into_owned(),
})
}

#[cfg(feature = "clipboard-image")]
fn set_image(&mut self, image: ClipboardImage) {
if let Some(h) = self.handle() {
let _ = h.set_image(arboard::ImageData {
width: image.width,
height: image.height,
bytes: std::borrow::Cow::Owned(image.bytes),
});
}
}
}

/// An in-memory clipboard for tests (PUBLIC so integration-crate tests can
Expand All @@ -68,6 +132,9 @@ impl ClipboardProvider for ArboardClipboard {
#[derive(Default)]
pub struct MemClipboard {
text: Option<String>,
html: Option<String>,
#[cfg(feature = "clipboard-image")]
image: Option<ClipboardImage>,
}

impl ClipboardProvider for MemClipboard {
Expand All @@ -78,4 +145,22 @@ impl ClipboardProvider for MemClipboard {
fn set_text(&mut self, text: String) {
self.text = Some(text);
}

fn get_html(&mut self) -> Option<String> {
self.html.clone()
}

fn set_html(&mut self, html: String) {
self.html = Some(html);
}

#[cfg(feature = "clipboard-image")]
fn get_image(&mut self) -> Option<ClipboardImage> {
self.image.clone()
}

#[cfg(feature = "clipboard-image")]
fn set_image(&mut self, image: ClipboardImage) {
self.image = Some(image);
}
}
Loading
Loading