git.lucas.co / cce-relief
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 }