relief editors: wall and edge profiles, finish, frost; ramp specs
git clone https://git.lucas.co/cce-relief.git
src/bin/cce-ramp.rs (16K)
1 //! `cce-ramp` — a minimal popup hosting the [`Ramp`] widget in isolation, for
2 //! iterating on the widget's look without driving a full client around it.
3 //! `make install` puts it on PATH; run it inside a Wayland session. Edits print
4 //! their ramp spec to stdout, so the popup doubles as a curve scratchpad.
5 //!
6 //! `--key <dotted.key>` (with optional `--config <path>`, default the shared
7 //! config.kdl) turns the scratchpad into the `(ramp)` VALUE editor: the curve
8 //! seeds from that key's ramp spec, Save writes the spec back to that one key
9 //! with the `(ramp)` annotation, and Cancel closes without saving — the
10 //! `cce-relief --key` convention. cce-data-editor spawns it this way from a
11 //! ramp value's inline preview.
12 //!
13 //! Architecture mirrors the reference `DemoApp` (`src/main.rs`): display-list
14 //! frame, routed events, in-frame popovers.
15
16 use cce_ui::widget::Handle;
17 use cce_ui::engine::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
18 use cce_ui::scene::layout::Rect;
19 use cce_ui::scene::paint::{DisplayList, PaintCtx};
20 use cce_ui::widget::{
21 Adapted, Button, ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, Ramp,
22 WidgetHost,
23 };
24
25 /// Transparent rim between the surface edge and the plate: room for the ramp's
26 /// key pegs (r=28, +45 selected halo) to render outside the window frame
27 /// instead of being clipped at the buffer edge.
28 const OVERFLOW_MARGIN: f32 = 40.0;
29
30 #[derive(Debug, Clone)]
31 enum RampMsg {
32 Exit,
33 }
34
35 struct RampPopup {
36 ramp: Handle<Adapted<Ramp>>,
37 /// Last spec printed to stdout — edits log their curve for copy/paste.
38 last_spec: String,
39 /// `--key` mode only; parked off-screen in the scratchpad.
40 save_button: Handle<Adapted<Button>>,
41 cancel_button: Handle<Adapted<Button>>,
42 /// `--key <dotted.key>`: Save writes the spec as a `(ramp)` value at
43 /// this key; the curve seeds from it. None = the stdout scratchpad.
44 target_key: Option<String>,
45 config_path: std::path::PathBuf,
46 /// Status line under the buttons (key mode): what the last save did.
47 status: String,
48 /// Set by the cancel click in `drain_changes` (no exit access there);
49 /// `handle_mouse_input` turns it into `RampMsg::Exit`.
50 exit_requested: bool,
51 ui_context: cce_ui::context::UiContext,
52 width: u32,
53 height: u32,
54 scale_factor: f64,
55 needs_rebuild: bool,
56 }
57
58 impl RampPopup {
59 fn drain_changes(&mut self) {
60 let spec = self.ui_context[self.ramp].inner().spec_string();
61 if spec != self.last_spec {
62 println!("{spec}");
63 self.last_spec = spec;
64 self.needs_rebuild = true;
65 }
66 if self.ui_context[self.save_button].take_click() {
67 self.save_to_key();
68 self.needs_rebuild = true;
69 }
70 if self.ui_context[self.cancel_button].take_click() {
71 // Discard-and-close: nothing persisted without Save.
72 self.exit_requested = true;
73 }
74 }
75
76 /// Persist the current curve as a `(ramp)` value at the target key.
77 fn save_to_key(&mut self) {
78 let Some(key) = self.target_key.clone() else { return };
79 let p = self.config_path.to_string_lossy().into_owned();
80 let spec = self.ui_context[self.ramp].inner().spec_string();
81 let ok = cce_ui::config::write_config_value_typed(&p, &key, &spec, "style", Some("ramp"));
82 self.status = if ok {
83 println!("saved {key} -> {p}");
84 format!("Saved — {key} holds this curve.")
85 } else {
86 "Save FAILED — see config permissions.".to_string()
87 };
88 }
89 }
90
91 impl Application for RampPopup {
92 type Message = RampMsg;
93
94 fn create(_sender: AppSender<Self::Message>) -> Self {
95 cce_ui::scale::set_scale_factor(1.0);
96 let mut ramp = Ramp::new();
97
98 // `--key <dotted.key>` / `--config <path>`: edit one `(ramp)` value
99 // in place — seed the curve from it, Save writes it back.
100 let mut config_path = cce_ui::config::get_config_path();
101 let mut target_key: Option<String> = None;
102 let args: Vec<String> = std::env::args().collect();
103 let mut i = 1;
104 while i < args.len() {
105 if args[i] == "--config" && i + 1 < args.len() {
106 config_path = std::path::PathBuf::from(&args[i + 1]);
107 i += 1;
108 } else if args[i] == "--key" && i + 1 < args.len() {
109 target_key = Some(args[i + 1].clone());
110 i += 1;
111 }
112 i += 1;
113 }
114 if let Some(key) = &target_key {
115 let seed = std::fs::read_to_string(&config_path)
116 .ok()
117 .map(|c| cce_ui::config::parse_kdl_to_json(&c))
118 .and_then(|v| v.pointer(&format!("/{}", key.replace('.', "/"))).cloned())
119 .and_then(|v| v.as_str().map(String::from));
120 if let Some(spec) = seed {
121 ramp.inner_mut().set_spec(&spec);
122 }
123 }
124
125 let last_spec = ramp.inner().spec_string();
126 // The context owns the widgets; the app keeps their handles.
127 let mut ui_context = cce_ui::context::UiContext::new();
128 Self {
129 ramp: ui_context.insert(ramp),
130 last_spec,
131 save_button: ui_context.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Save")),
132 cancel_button: ui_context.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel")),
133 status: match &target_key {
134 Some(k) => format!("Edits are live in the curve; Save writes the {k} key."),
135 None => String::new(),
136 },
137 target_key,
138 config_path,
139 exit_requested: false,
140 ui_context,
141 width: 540,
142 height: 420,
143 scale_factor: 1.0,
144 needs_rebuild: true,
145 }
146 }
147
148 // Buffer-larger-than-geometry mode: the runner publishes the plate rect
149 // as the xdg window geometry + input region, so key pegs painted on the
150 // rim render outside the window frame and clicks there fall through.
151 fn overflow_margin(&self) -> u32 {
152 OVERFLOW_MARGIN as u32
153 }
154
155 fn settings(&self) -> WindowSettings {
156 let title = match &self.target_key {
157 Some(k) => {
158 let parts: Vec<&str> = k.split('.').collect();
159 format!("Ramp — {}", parts[parts.len().saturating_sub(2)..].join("."))
160 }
161 None => "Ramp".to_string(),
162 };
163 // The key mode adds a Save/Cancel row under the curve.
164 let extra = if self.target_key.is_some() { 56 } else { 0 };
165 WindowSettings {
166 title,
167 app_id: "cce-ramp".to_string(),
168 width: 460,
169 height: 340 + extra,
170 fullscreen: false,
171 min_size: Some((420, 300 + extra)),
172 }
173 }
174
175 fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
176 match msg {
177 RampMsg::Exit => *exit = true,
178 }
179 }
180
181 fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
182 if self.ui_context.tick(dt) {
183 // Tick-driven edits (hover-scroll glide) log their spec too.
184 self.drain_changes();
185 *needs_rebuild = true;
186 self.needs_rebuild = true;
187 }
188 }
189
190 fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
191
192 let size_changed = self.width != size.width as u32
193 || self.height != size.height as u32
194 || self.scale_factor != scale;
195 if self.needs_rebuild || size_changed {
196 self.width = size.width as u32;
197 self.height = size.height as u32;
198 self.scale_factor = scale;
199 cce_ui::scale::set_scale_factor(scale as f32);
200
201 // One widget, one rect: the ramp fills the plate inside half the
202 // DE pad (this popup runs tighter than a full client). The plate
203 // itself is inset by OVERFLOW_MARGIN so key pegs can render past
204 // the window frame into the transparent surface rim. Key mode
205 // reserves a Save/Cancel band under the curve.
206 let pad = OVERFLOW_MARGIN + cce_ui::layout::root_plate_padding() / 2.0;
207 let band = if self.target_key.is_some() { 56.0 } else { 0.0 };
208 self.ui_context[self.ramp].set_rect(
209 pad,
210 pad,
211 (self.width as f32 - 2.0 * pad).max(0.0),
212 (self.height as f32 - 2.0 * pad - band).max(0.0),
213 );
214 if self.target_key.is_some() {
215 let by = self.height as f32 - pad - 30.0;
216 self.ui_context[self.save_button].set_rect(pad, by, 96.0, 28.0);
217 self.ui_context[self.cancel_button].set_rect(pad + 96.0 + 12.0, by, 96.0, 28.0);
218 } else {
219 self.ui_context[self.save_button].set_rect(-1000.0, -1000.0, 1.0, 1.0);
220 self.ui_context[self.cancel_button].set_rect(-1000.0, -1000.0, 1.0, 1.0);
221 }
222
223 self.needs_rebuild = false;
224 self.ui_context.rebuild_spatial_grid();
225 }
226
227 self.ui_context.clear_popovers();
228 if self.ui_context[self.ramp].popover_rect().is_some() {
229 self.ui_context.register_popover_id(self.ramp.id());
230 }
231
232 let mut pc = PaintCtx::new();
233 let (w, h) = (self.width as f32, self.height as f32);
234
235 // The window plate (the DemoApp idiom): page-low color at the configured
236 // opacity, config corner radius, rolled perimeter — inset by the
237 // overflow margin so widget content (the ramp's key pegs) can spill
238 // past the frame onto the transparent rim.
239 let mut plate = cce_ui::color::page_low_color();
240 if plate[3] > 0.001 {
241 // Half the DE opacity: this popup reads better mostly-glass.
242 plate[3] = cce_ui::color::root_plate_opacity() * 0.5;
243 }
244 let radius = cce_ui::colors::root_plate_corner_radius();
245 let bevel = cce_ui::layout::bevel_width();
246 let m = OVERFLOW_MARGIN;
247 pc.plate(
248 Rect { x: m, y: m, width: w - 2.0 * m, height: h - 2.0 * m },
249 (radius, radius, radius, radius),
250 &cce_ui::scene::Material::from_fill(plate),
251 bevel,
252 );
253
254 cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.ramp], &mut pc);
255 if self.target_key.is_some() {
256 cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.save_button], &mut pc);
257 cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.cancel_button], &mut pc);
258 if !self.status.is_empty() {
259 let pad = OVERFLOW_MARGIN + cce_ui::layout::root_plate_padding() / 2.0;
260 pc.text_with(
261 self.status.clone(),
262 pad + 2.0 * (96.0 + 12.0),
263 self.height as f32 - pad - 24.0,
264 12.0,
265 [0x9a, 0x9a, 0xa4],
266 None,
267 None,
268 );
269 }
270 }
271
272 // The ramp's field-dropdown popover, drawn into the frame on top.
273 if let Some((px, py, pw, ph)) = self.ui_context[self.ramp].popover_rect() {
274 let mut coll = cce_ui::layout::PopoverCollector::new();
275 self.ui_context[self.ramp].inner().preset_dropdown.render_popover(&mut coll);
276 self.ui_context[self.ramp].inner().line_type_dropdown.render_popover(&mut coll);
277 for &(c, x, y, qw, qh) in &coll.rects {
278 pc.quad(Rect { x, y, width: qw, height: qh }, c);
279 }
280 let bounds = Some([px, py, px + pw, py + ph]);
281 for (content, size, tx, ty, color, font, _b) in coll.texts {
282 let color_u8 = [
283 (color[0] * 255.0).clamp(0.0, 255.0) as u8,
284 (color[1] * 255.0).clamp(0.0, 255.0) as u8,
285 (color[2] * 255.0).clamp(0.0, 255.0) as u8,
286 ];
287 pc.text_with(content, tx, ty, size, color_u8, font, bounds);
288 }
289 }
290
291 // The shared context menu (right-click on the graph), drawn last, on
292 // top of everything. Its labels carry the menu rect as bounds — the
293 // runner exempts them from the menu's own text occlusion that way.
294 // The lit plate and the menu font, in one call — the flat quads and
295 // a family-less label loop drew this menu square, opaque and in the
296 // default sans, unlike every app's.
297 cce_ui::widget::context_menu::paint_with_labels(&mut pc);
298
299 Some(pc.finish())
300 }
301
302 fn display_list_text(&self) -> bool {
303 true
304 }
305
306 fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
307 Some(&self.ui_context)
308 }
309
310 fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
311 self.ui_context.drag_allowed_at(px, py)
312 }
313
314 fn clear_color(&self) -> [f32; 4] {
315 [0.0, 0.0, 0.0, 0.0]
316 }
317
318 fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
319 if self.ui_context.cursor_moved_context_menu(pos.x, pos.y) {
320 *needs_rebuild = true;
321 self.needs_rebuild = true;
322 }
323 let ev = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
324 let changed = self.ui_context.propagate_event(&ev, self.ramp.id());
325 self.drain_changes();
326 if changed || self.needs_rebuild {
327 *needs_rebuild = true;
328 self.needs_rebuild = true;
329 }
330 }
331
332 fn handle_mouse_input(
333 &mut self,
334 button: MouseButton,
335 state: ElementState,
336 pos: LogicalPosition,
337 needs_rebuild: &mut bool,
338 ) -> Option<Self::Message> {
339 // The open context menu owns the press (item dispatch / dismiss).
340 if self.ui_context.mouse_input_context_menu(button, state, pos.x, pos.y) {
341 self.drain_changes();
342 *needs_rebuild = true;
343 self.needs_rebuild = true;
344 return None;
345 }
346 let ev = Event::MouseButton {
347 button,
348 state,
349 x: pos.x,
350 y: pos.y,
351 local_x: pos.x,
352 local_y: pos.y,
353 };
354 let mut changed = self.ui_context.propagate_event(&ev, self.ramp.id());
355 changed |= self.ui_context.propagate_event(&ev, self.save_button.id());
356 changed |= self.ui_context.propagate_event(&ev, self.cancel_button.id());
357 self.drain_changes();
358 if self.exit_requested {
359 return Some(RampMsg::Exit);
360 }
361 if changed || self.needs_rebuild {
362 *needs_rebuild = true;
363 self.needs_rebuild = true;
364 }
365 None
366 }
367
368 fn handle_mouse_wheel(
369 &mut self,
370 delta: &MouseScrollDelta,
371 pos: LogicalPosition,
372 needs_rebuild: &mut bool,
373 ) {
374 let ev = Event::MouseWheel {
375 delta: *delta,
376 x: pos.x,
377 y: pos.y,
378 local_x: pos.x,
379 local_y: pos.y,
380 };
381 let changed = self.ui_context.propagate_event(&ev, self.ramp.id());
382 self.drain_changes();
383 if changed || self.needs_rebuild {
384 *needs_rebuild = true;
385 self.needs_rebuild = true;
386 }
387 }
388
389 fn handle_key_input(
390 &mut self,
391 event: &KeyEvent,
392 needs_rebuild: &mut bool,
393 ) -> Option<Self::Message> {
394 use cce_ui::widget::{Key, NamedKey};
395 if event.state == ElementState::Pressed {
396 if event.logical_key == Key::Named(NamedKey::Escape) {
397 return Some(RampMsg::Exit);
398 }
399 if event.ctrl {
400 if let Key::Character(ref c) = event.logical_key {
401 if c == "q" {
402 return Some(RampMsg::Exit);
403 }
404 }
405 }
406 }
407 let ev = Event::KeyInput(event.clone());
408 let handled = self.ui_context.propagate_event(&ev, self.ramp.id());
409 self.drain_changes();
410 if handled || self.needs_rebuild {
411 *needs_rebuild = true;
412 self.needs_rebuild = true;
413 }
414 None
415 }
416 }
417
418 fn main() {
419 cce_ui::engine::run::<RampPopup>();
420 }