git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/bin/cce-ramp.rs (16.1K)

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