page layout tool
git clone https://git.lucas.co/cce-layout-interface.git
fix: recent-files edge rows render cut, not culled
ScrollRegion copy deleted for cce_ui::widget::ScrollRegion
(cce-ui@764d9e7, intersection contract). PageContent gains a scoped
active_clip: recorded rects clamp to it (exact — this flat pipeline
only emits plain quads) and texts get it intersected into their bounds;
the recent-files row loop runs under the list-viewport clip. A
straddling row's stored hit rect is re-clamped to its visible sliver
after drawing, so the hidden part cannot shadow the controls below
the well.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/main.rs | 59 ++++++++--
src/scroll_region.rs | 321 ---------------------------------------------------
2 files changed, 52 insertions(+), 328 deletions(-)
diff --git a/src/main.rs b/src/main.rs
index b1d27d3..c2e4910 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,4 @@
-mod scroll_region;
-use scroll_region::ScrollRegion;
+use cce_ui::widget::ScrollRegion;
use wayland_client::QueueHandle;
use cce_ui::cosmic_text::FontSystem;
use serde::{Serialize, Deserialize};
@@ -14,6 +13,13 @@ use cce_ui::layout::{RenderTarget, Section, UiFrame};
pub struct PageContent {
pub rects: Vec<([f32; 4], f32, f32, f32, f32)>,
pub texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
+ /// Scoped clip `[l, t, r, b]`: while set, recorded rects are CLAMPED to it
+ /// (exact for this flat pipeline — every rect is a plain quad) and texts
+ /// get it intersected into their bounds. Set around the recent-files row
+ /// loop so partially visible rows render cut at the list viewport instead
+ /// of bleeding out of the well (the toolkit ScrollRegion's intersection
+ /// contract returns partial rows).
+ pub active_clip: Option<[f32; 4]>,
}
impl PageContent {
@@ -21,29 +27,52 @@ impl PageContent {
Self {
rects: Vec::new(),
texts: Vec::new(),
+ active_clip: None,
+ }
+ }
+
+ fn clipped_bounds(&self, bounds: Option<[f32; 4]>) -> Option<[f32; 4]> {
+ match (self.active_clip, bounds) {
+ (Some(c), Some(b)) => Some([b[0].max(c[0]), b[1].max(c[1]), b[2].min(c[2]), b[3].min(c[3])]),
+ (Some(c), None) => Some(c),
+ (None, b) => b,
}
}
}
impl RenderTarget for PageContent {
fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
- self.rects.push((color, x, y, w, h));
+ let (mut x0, mut y0, mut x1, mut y1) = (x, y, x + w, y + h);
+ if let Some(c) = self.active_clip {
+ x0 = x0.max(c[0]);
+ y0 = y0.max(c[1]);
+ x1 = x1.min(c[2]);
+ y1 = y1.min(c[3]);
+ if x1 <= x0 || y1 <= y0 {
+ return;
+ }
+ }
+ self.rects.push((color, x0, y0, x1 - x0, y1 - y0));
}
fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
- self.texts.push((content.to_string(), size, x, y, color, None, None));
+ let b = self.clipped_bounds(None);
+ self.texts.push((content.to_string(), size, x, y, color, None, b));
}
fn text_with_font(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str) {
- self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), None));
+ let b = self.clipped_bounds(None);
+ self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), b));
}
fn text_with_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], bounds: Option<[f32; 4]>) {
- self.texts.push((content.to_string(), size, x, y, color, None, bounds));
+ let b = self.clipped_bounds(bounds);
+ self.texts.push((content.to_string(), size, x, y, color, None, b));
}
fn text_with_font_and_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str, bounds: Option<[f32; 4]>) {
- self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), bounds));
+ let b = self.clipped_bounds(bounds);
+ self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), b));
}
}
@@ -805,13 +834,29 @@ impl LayoutApp {
let inner_x = list_x + 4.0;
let inner_w = col_w - 16.0;
+ // `get_item_draw_y` returns PARTIALLY visible rows (toolkit
+ // ScrollRegion intersection contract); the scoped clip clamps
+ // their quads and bounds their labels to the list viewport, so
+ // an edge row renders cut instead of bleeding out of the well.
+ pc.active_clip = Some([list_x, list_y, list_x + col_w, list_y + list_h]);
for (idx, btn) in self.recent_files_buttons.iter_mut().enumerate() {
if let Some(draw_y) = self.recent_files_list.get_item_draw_y(idx, 0.0) {
cce_ui::layout::render_widget(&mut pc, btn, inner_x, draw_y, inner_w, btn_h, &mut self.ui_context);
+ // Hit rect = the VISIBLE sliver: the drawn geometry is
+ // already recorded (clamped by the clip above), so
+ // re-clamping the stored rect only affects hit-testing
+ // — without it a straddling row's hidden part would
+ // shadow the controls below the well.
+ let cy = draw_y.max(list_y);
+ let cb = (draw_y + btn_h).min(list_y + list_h);
+ if cb - cy < btn_h {
+ btn.set_rect(inner_x, cy, inner_w, (cb - cy).max(0.0));
+ }
} else {
btn.set_rect(-9999.0, -9999.0, 0.0, 0.0);
}
}
+ pc.active_clip = None;
if self.recent_files.is_empty() {
pc.text("No recent files", list_x + 12.0, list_y + 16.0, 11.0, [0.55, 0.55, 0.60, 1.0]);
diff --git a/src/scroll_region.rs b/src/scroll_region.rs
deleted file mode 100644
index d79a5a4..0000000
--- a/src/scroll_region.rs
+++ /dev/null
@@ -1,321 +0,0 @@
-//! App-owned scroll region replacing the dissolved `ScrollBox` / `List` embedded bases
-//! (ported from cce-fonts' Phase 6q dissolution). Every settings list was a pure scroll
-//! frame (`List` with `columns: None`) — the rows are drawn by the pages themselves — so
-//! the widgets contributed only: the rounded border + background, the scrollbar, the
-//! scroll state/virtualization math, and wheel/drag/keyboard input. All replicated here.
-//!
-//! One deliberate visual fix over the legacy pipeline: `List::extra_quads` skipped its
-//! bg-removal for `columns: None` lists, so `render_widget` emitted border+bg TWICE (the
-//! plain bg quad through the solid-border branch, then `all_rounded_quads` again) with
-//! the scrollbar sandwiched between the two translucent bg layers — the same
-//! double-composite class Phase 6p found on Plates. The dissolved region draws
-//! border, bg, track, thumb once, in that order.
-
-use cce_ui::widget::{ElementState, Key, KeyEvent, MouseScrollDelta, NamedKey};
-
-#[derive(Debug, Clone)]
-pub struct ScrollRegion {
- pub x: f32,
- pub y: f32,
- pub w: f32,
- pub h: f32,
- /// Row height, with `List::new`'s silent adjustment to `max(item_height, list_font + 14)`.
- pub item_height: f32,
- pub item_gap: f32,
- pub scroll_y: f32,
- pub content_h: f32,
- pub viewport_y: f32,
- pub viewport_h: f32,
- pub dragging: bool,
- drag_offset_y: f32,
- pub hovered: bool,
- /// Local stand-in for the legacy global focus flag (`ScrollBox::focus()` on any press
- /// inside the frame): set on a press that hits the region, cleared on one that misses.
- pub focused: bool,
-}
-
-impl ScrollRegion {
- pub fn new(item_height: f32, item_gap: f32) -> Self {
- let (_, font_size) = cce_ui::layout::list_font_parsed();
- Self {
- x: 0.0,
- y: 0.0,
- w: 0.0,
- h: 0.0,
- item_height: item_height.max(font_size + 14.0),
- item_gap,
- scroll_y: 0.0,
- content_h: 0.0,
- viewport_y: 0.0,
- viewport_h: 0.0,
- dragging: false,
- drag_offset_y: 0.0,
- hovered: false,
- focused: false,
- }
- }
-
- pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
- self.x = x;
- self.y = y;
- self.w = w;
- self.h = h;
- }
-
- /// The `List::update_bounds` count math: `content_h = count * (item_height + gap) + 4`.
- pub fn update_bounds(&mut self, count: usize, viewport_y: f32, viewport_h: f32) {
- self.content_h = count as f32 * (self.item_height + self.item_gap) + 4.0;
- self.viewport_y = viewport_y;
- self.viewport_h = viewport_h;
- self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
- }
-
- pub fn set_scroll_y(&mut self, val: f32) {
- self.scroll_y = val;
- }
-
- fn max_scroll(&self) -> f32 {
- (self.content_h - self.viewport_h).max(0.0)
- }
-
- pub fn hit(&self, px: f32, py: f32) -> bool {
- px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
- }
-
- /// Row virtualization (`List::get_item_draw_y`): screen y for row `idx`, or `None`
- /// when the row isn't fully inside the viewport.
- pub fn get_item_draw_y(&self, idx: usize, offset: f32) -> Option<f32> {
- let virtual_y = idx as f32 * (self.item_height + self.item_gap) + offset;
- let draw_y = self.viewport_y + virtual_y - self.scroll_y;
- if draw_y >= self.viewport_y - 1.0
- && draw_y + self.item_height <= self.viewport_y + self.viewport_h + 1.0
- {
- Some(draw_y)
- } else {
- None
- }
- }
-
- /// Scrollbar geometry (`ScrollBox::extra_quads`): (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h).
- fn scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
- let sb_w = cce_ui::layout::scrollbar_width();
- let sb_x = self.x + self.w - sb_w - 4.0;
- let track_h = self.viewport_h - 8.0;
- let track_y = self.viewport_y + 4.0;
- let visible_ratio = self.viewport_h / self.content_h.max(1.0);
- let thumb_h = if track_h <= 20.0 {
- track_h
- } else {
- (track_h * visible_ratio).clamp(20.0, track_h)
- };
- let scroll_ratio = if self.max_scroll() > 0.0 { self.scroll_y / self.max_scroll() } else { 0.0 };
- let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
- (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)
- }
-
- fn hit_scrollbar(&self, px: f32, py: f32) -> bool {
- if self.content_h <= self.viewport_h {
- return false;
- }
- let (sb_x, track_y, sb_w, track_h, _, _) = self.scrollbar_geom();
- px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
- }
-
- /// Left press: scrollbar thumb grab or track jump (`ScrollBox::mouse_input`), plus the
- /// press-inside focus / press-outside unfocus bookkeeping. Returns true only when the
- /// scrollbar consumed the press — a press on the rows falls through to them.
- pub fn press(&mut self, px: f32, py: f32) -> bool {
- self.focused = self.hit(px, py);
- if !self.hit_scrollbar(px, py) {
- self.dragging = false;
- return false;
- }
- self.dragging = true;
- let (_, track_y, _, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
- let click_offset = py - thumb_y;
- if click_offset >= 0.0 && click_offset <= thumb_h {
- self.drag_offset_y = click_offset;
- } else {
- self.drag_offset_y = thumb_h / 2.0;
- let target = py - self.drag_offset_y;
- let ratio = if track_h - thumb_h > 0.0 {
- ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
- } else {
- 0.0
- };
- self.scroll_y = ratio * self.max_scroll();
- }
- true
- }
-
- /// Returns whether a thumb drag was in progress (the caller's redraw signal).
- pub fn release(&mut self) -> bool {
- std::mem::take(&mut self.dragging)
- }
-
- fn drag_move(&mut self, py: f32) -> bool {
- let (_, track_y, _, track_h, _, thumb_h) = self.scrollbar_geom();
- let target = py - self.drag_offset_y;
- let ratio = if track_h - thumb_h > 0.0 {
- ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
- } else {
- 0.0
- };
- let old = self.scroll_y;
- self.scroll_y = ratio * self.max_scroll();
- (self.scroll_y - old).abs() > 0.01
- }
-
- /// Pointer-move bookkeeping: forwards to an active thumb drag (returns true so the host
- /// treats it as a high-priority drag override), else just tracks hover for the border
- /// tint and the keyboard scope.
- pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
- self.hovered = self.hit(px, py);
- if self.dragging {
- self.drag_move(py);
- return true;
- }
- false
- }
-
- pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
- if !self.hit(px, py) {
- return false;
- }
- let dy = match delta {
- MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
- MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
- };
- let old = self.scroll_y;
- self.scroll_y = (self.scroll_y + dy).clamp(0.0, self.max_scroll());
- (self.scroll_y - old).abs() > 0.01
- }
-
- /// Hover/focus-scoped keyboard scrolling (`ScrollBox::keyboard_input` reached the boxes
- /// when focused or hovered; the dissolved region keeps both via its local flags).
- pub fn keyboard(&mut self, event: &KeyEvent) -> bool {
- if (!self.hovered && !self.focused) || event.state != ElementState::Pressed {
- return false;
- }
- let max = self.max_scroll();
- let old = self.scroll_y;
- if event.ctrl {
- match &event.logical_key {
- Key::Character(c) if c == "n" || c == "N" => self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max),
- Key::Character(c) if c == "p" || c == "P" => self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max),
- _ => return false,
- }
- } else {
- match &event.logical_key {
- Key::Named(NamedKey::ArrowDown) => self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max),
- Key::Named(NamedKey::ArrowUp) => self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max),
- Key::Named(NamedKey::PageDown) => self.scroll_y = (self.scroll_y + self.viewport_h).clamp(0.0, max),
- Key::Named(NamedKey::PageUp) => self.scroll_y = (self.scroll_y - self.viewport_h).clamp(0.0, max),
- Key::Named(NamedKey::Home) => self.scroll_y = 0.0,
- Key::Named(NamedKey::End) => self.scroll_y = max,
- _ => return false,
- }
- }
- (self.scroll_y - old).abs() > 0.01
- }
-
- /// The legacy frame, single-drawn: 1px rounded border (focus/hover tinted, from
- /// `List::solid_border`), inset rounded bg, then the scrollbar track and thumb ON TOP.
- pub fn push_prims(&self, pc: &mut dyn cce_ui::layout::RenderTarget) {
- let radius = cce_ui::layout::list_corner_radius();
- let border_color = if self.focused {
- [0.30, 0.50, 0.32, 1.0]
- } else if self.hovered {
- [0.25, 0.25, 0.35, 1.0]
- } else {
- [0.18, 0.18, 0.24, 1.0]
- };
- let all = (true, true, true, true);
- pc.rect_with_radius_corners(border_color, self.x, self.y, self.w, self.h, radius, all);
- pc.rect_with_radius_corners(
- cce_ui::color::list_bg_color(),
- self.x + 1.0,
- self.y + 1.0,
- self.w - 2.0,
- self.h - 2.0,
- (radius - 1.0).max(0.0),
- all,
- );
- if self.content_h > self.viewport_h {
- let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
- pc.rect(cce_ui::color::scrollbar_track_color(), sb_x, track_y, sb_w, track_h);
- pc.rect(cce_ui::color::scrollbar_thumb_color(), sb_x, thumb_y, sb_w, thumb_h);
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn region() -> ScrollRegion {
- // item_height clamps to list_font + 14, so pick one comfortably above any config.
- let mut r = ScrollRegion::new(40.0, 4.0);
- r.set_rect(10.0, 20.0, 200.0, 100.0);
- r
- }
-
- #[test]
- fn wheel_scrolls_and_clamps() {
- let mut r = region();
- r.update_bounds(10, 20.0, 100.0); // content_h = 444 > 100
- assert!(r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
- assert_eq!(r.scroll_y, 48.0);
- assert!(!r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 500.0, 50.0)); // miss
- r.wheel(&MouseScrollDelta::LineDelta(0.0, -100.0), 50.0, 50.0);
- assert_eq!(r.scroll_y, 344.0); // clamped to max_scroll
- }
-
- #[test]
- fn virtualization_matches_list_math() {
- let mut r = region();
- r.update_bounds(10, 20.0, 100.0);
- r.set_scroll_y(0.0);
- // Row 0 at viewport_y + 0*(44) + 4 = 24; fits (24 + 40 <= 121).
- assert_eq!(r.get_item_draw_y(0, 4.0), Some(24.0));
- // Row 2 at 20 + 92 - 0 = 112; 112 + 40 > 121 → culled.
- assert!(r.get_item_draw_y(2, 4.0).is_none());
- }
-
- #[test]
- fn press_focuses_and_grabs_only_scrollbar() {
- let mut r = region();
- r.update_bounds(10, 20.0, 100.0);
- // Press in the rows area: focused, not dragging, falls through.
- assert!(!r.press(50.0, 50.0));
- assert!(r.focused && !r.dragging);
- // Press on the scrollbar strip (x + w - sb_w - 4 ± 4): consumed.
- let sb_x = 10.0 + 200.0 - cce_ui::layout::scrollbar_width() - 4.0;
- assert!(r.press(sb_x + 1.0, 50.0));
- assert!(r.dragging);
- assert!(r.release());
- // Press outside: unfocuses.
- assert!(!r.press(500.0, 500.0));
- assert!(!r.focused);
- }
-
- #[test]
- fn keyboard_is_hover_or_focus_scoped() {
- let mut r = region();
- r.update_bounds(10, 20.0, 100.0);
- let down = KeyEvent {
- state: ElementState::Pressed,
- logical_key: Key::Named(NamedKey::ArrowDown),
- text: None,
- repeat: false,
- ctrl: false,
- shift: false,
- alt: false,
- };
- assert!(!r.keyboard(&down)); // neither hovered nor focused
- r.cursor_moved(50.0, 50.0);
- assert!(r.hovered);
- assert!(r.keyboard(&down));
- assert_eq!(r.scroll_y, 24.0);
- }
-}