git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

src/pages/network.rs (17.9K)

  1 use std::path::{Path, PathBuf};
  2 use crate::pages::PageContent;
  3 use crate::pages::browse::{BrowseState, DirEntry};
  4 use cce_ui::context::UiContext;
  5 use cce_ui::widget::Handle;
  6 use cce_ui::widget::{Adapted, Graph, GraphNode, Breadcrumb, GraphController, PathController};
  7 
  8 pub struct NetworkState {
  9     pub graph: Handle<Adapted<Graph>>,
 10     pub breadcrumb: Handle<Adapted<Breadcrumb>>,
 11     pub last_dir: PathBuf,
 12     /// The graph pane `(x, y, w, h)` the nodes were last laid out for: a
 13     /// pane of another size is laid out again (`view`), so the columns fit
 14     /// it. Panning moves the lattice's origin and is kept until then.
 15     pub laid_out_for: (f32, f32, f32, f32),
 16     /// The configured pitch at 100% — the floor a column's width starts from.
 17     base_pitch: (f32, f32),
 18     /// The cce-icons glyph each graph node wears, by node index (`folder` /
 19     /// `file`) — drawn over the node body by `view`, since a `GraphNode`
 20     /// carries a name and no glyph.
 21     pub node_glyphs: Vec<&'static str>,
 22 }
 23 
 24 impl NetworkState {
 25     /// The page's state, its widgets inserted into `ctx`.
 26     pub fn new(ctx: &mut UiContext) -> Self {
 27         // The configured lattice and node body (`style.surface.graph`), as
 28         // the designer's network uses them: a node is CENTRED on a lattice
 29         // crossing and its name hangs off its right side. The pitch across is
 30         // widened per directory to hold the names (`layout`); the origin is
 31         // set from the pane. Until 2026-10-06 this set the retired cell model
 32         // (140 x 70 cells, 35 px gaps, origin 60, 60), which under the
 33         // lattice put column 0 left of the pane, ran seven columns past its
 34         // right edge and left names 35 px to sit in.
 35         let mut graph = Graph::new();
 36         graph.set_show_network_grid(true);
 37         graph.set_grid_snap_enabled(true);
 38         graph.set_network_opacity(0.95);
 39         // A directory has no geometry to show: no toggle disc on the nodes.
 40         graph.inner_mut().set_show_toggles(false);
 41         let base_pitch = graph.inner().grid_pitch();
 42 
 43         let mut breadcrumb = Breadcrumb::new();
 44         breadcrumb.set_network_opacity(0.95);
 45 
 46         Self {
 47             graph: ctx.insert(graph),
 48             breadcrumb: ctx.insert(breadcrumb),
 49             last_dir: PathBuf::new(),
 50             node_glyphs: Vec::new(),
 51             laid_out_for: (0.0, 0.0, 0.0, 0.0),
 52             base_pitch,
 53         }
 54     }
 55 }
 56 
 57 /// Margin between the pane's edge and the nearest node or name.
 58 const MARGIN: f32 = 16.0;
 59 /// The most a column gives a name, at 100%: a longer one is cut with an
 60 /// ellipsis (`fit_name`), so one long file name cannot widen every column.
 61 const NAME_MAX: f32 = 180.0;
 62 
 63 /// A node name's font size and its gap from the body, for a body `node_w`
 64 /// wide — `Graph::node_labels`' rule, which this has to agree with.
 65 fn label_metrics(node_w: f32) -> (f32, f32) {
 66     let k = node_w / 80.0;
 67     ((14.0 * k).clamp(6.0, 48.0), 8.0 * k)
 68 }
 69 
 70 /// `name`, cut with an ellipsis to `max` px at `font_size`.
 71 fn fit_name(name: &str, font_size: f32, max: f32) -> String {
 72     use cce_ui::widget::display::TextLabel;
 73     if TextLabel::estimate_width(name, font_size) <= max {
 74         return name.to_string();
 75     }
 76     let mut out = String::new();
 77     for c in name.chars() {
 78         let next = format!("{out}{c}\u{2026}");
 79         if TextLabel::estimate_width(&next, font_size) > max {
 80             break;
 81         }
 82         out.push(c);
 83     }
 84     out.push('\u{2026}');
 85     out
 86 }
 87 
 88 /// Where a directory's nodes go in a pane `(x, y, w, h)`: the pitch across
 89 /// (a body, its name's gap and the widest name the column holds, or the
 90 /// configured pitch if that is wider), how many columns fit, and the lattice
 91 /// origin that puts column 1 and row 1 — where the nodes begin — a margin
 92 /// inside the pane: a node is centred on its crossing.
 93 fn layout(pane: (f32, f32, f32, f32), node: (f32, f32), base_pitch: (f32, f32), names: &[String]) -> ((f32, f32), usize, (f32, f32)) {
 94     use cce_ui::widget::display::TextLabel;
 95     let (px, py, pw, _) = pane;
 96     let (nw, nh) = node;
 97     let (font, gap) = label_metrics(nw);
 98     let widest = names.iter().map(|n| TextLabel::estimate_width(n, font)).fold(0.0f32, f32::max);
 99     let pitch_x = (nw + gap + widest.min(NAME_MAX) + MARGIN).max(base_pitch.0);
100     let cols = (((pw - 2.0 * MARGIN) - (nw + gap + widest.min(NAME_MAX))) / pitch_x).floor().max(0.0) as usize + 1;
101     // The lattice's (0, 0) crossing a pitch above and left of the first
102     // node, so its two heavy axes fall outside the pane: on the first
103     // column they lay over its wires, which run down the lattice lines.
104     let origin = (px + MARGIN + nw * 0.5 - pitch_x, py + MARGIN + nh * 0.5 - base_pitch.1);
105     ((pitch_x, base_pitch.1), cols, origin)
106 }
107 
108 impl NetworkState {
109     /// Lay the directory out for the pane `pane` and fill the graph — see
110     /// [`layout`]. The parent and the directory stand over the middle column,
111     /// its entries in rows below them.
112     pub fn lay_out(&mut self, pane: (f32, f32, f32, f32), current_dir: &Path, entries: &[DirEntry], ctx: &mut UiContext) {
113         let node = ctx[self.graph].inner().node_size();
114         let (font, _) = label_metrics(node.0);
115         let names: Vec<String> = entries.iter().map(|e| fit_name(&e.name, font, NAME_MAX)).collect();
116         let ((pitch_x, pitch_y), cols, origin) = layout(pane, node, self.base_pitch, &names);
117         ctx[self.graph].set_grid_pitch(pitch_x, pitch_y);
118         ctx[self.graph].set_grid_origin(origin.0, origin.1);
119         self.populate_graph(current_dir, entries, cols, &names, ctx);
120         self.laid_out_for = pane;
121     }
122 
123     /// Fill the graph: the parent at row 1 and the directory at row 2 over
124     /// the middle column, then `entries` (shown as `names`) `cols` to a row,
125     /// columns counted from 1.
126     pub fn populate_graph(&mut self, current_dir: &Path, entries: &[DirEntry], cols: usize, names: &[String], ctx: &mut UiContext) {
127         let cols = cols.max(1);
128         // Columns and rows from 1: the lattice's 0 lines are off the pane.
129         let mid = ((cols - 1) / 2) as f32 + 1.0;
130         let mut nodes = Vec::new();
131         let mut glyphs = Vec::new();
132 
133         // 1. Parent directory (if any)
134         let parent_node_name = if let Some(parent) = current_dir.parent() {
135             let parent_name = parent
136                 .file_name()
137                 .map(|s| s.to_string_lossy().to_string())
138                 .unwrap_or_else(|| "/".to_string());
139             let name = format!(".. ({})", parent_name);
140             glyphs.push("folder");
141             nodes.push(GraphNode {
142                 id: String::new(),
143                 name: name.clone(),
144                 position: (mid, 1.0),
145                 parameters: Vec::new(),
146                 geom_visible: true,
147                 node_type: String::new(),
148                 inputs: 0,
149                 outputs: 1,
150             });
151             Some(name)
152         } else {
153             None
154         };
155 
156         // 2. Current directory node
157         let current_node_name = current_dir
158             .file_name()
159             .map(|s| s.to_string_lossy().to_string())
160             .unwrap_or_else(|| "/".to_string());
161         glyphs.push("folder");
162 
163         let current_params = if let Some(ref p_name) = parent_node_name {
164             vec![("input".to_string(), p_name.clone(), "string".to_string())]
165         } else {
166             Vec::new()
167         };
168 
169         nodes.push(GraphNode {
170             id: String::new(),
171             name: current_node_name.clone(),
172             position: (mid, 2.0),
173             parameters: current_params,
174             geom_visible: true,
175             node_type: String::new(),
176             inputs: 1,
177             outputs: 1,
178         });
179 
180         // 3. Children nodes
181         for (idx, entry) in entries.iter().enumerate() {
182             let node_name = names.get(idx).cloned().unwrap_or_else(|| entry.name.clone());
183             glyphs.push(if entry.is_dir { "folder" } else { "file" });
184 
185             // `cols` to a row, from row 3.
186             let col = 1.0 + (idx % cols) as f32;
187             let row = 3.0 + (idx / cols) as f32;
188 
189             nodes.push(GraphNode {
190                 id: String::new(),
191                 name: node_name,
192                 position: (col, row),
193                 parameters: vec![("input".to_string(), current_node_name.clone(), "string".to_string())],
194                 geom_visible: true,
195                 node_type: String::new(),
196                 inputs: 1,
197                 outputs: 1,
198             });
199         }
200 
201         ctx[self.graph].set_nodes(&nodes);
202         self.node_glyphs = glyphs;
203         self.last_dir = current_dir.to_path_buf();
204     }
205 }
206 
207 pub fn view(state: &mut NetworkState, browse: &BrowseState, cx: f32, cy: f32, cw: f32, ch: f32, ctx: &mut cce_ui::context::UiContext) -> PageContent {
208     let mut pc = PageContent::new();
209 
210     let top = crate::pages::breadcrumb_header(&mut pc, state.breadcrumb, cx, cy, cw, ctx);
211 
212     // The pane the graph is laid out in, under the breadcrumb.
213     let pane = (cx, top, cw, (cy + ch - top).max(0.0));
214     let moved = (pane.0 - state.laid_out_for.0).abs() > 0.5
215         || (pane.1 - state.laid_out_for.1).abs() > 0.5
216         || (pane.2 - state.laid_out_for.2).abs() > 0.5
217         || (pane.3 - state.laid_out_for.3).abs() > 0.5;
218 
219     // Check if directory changed, or if last_dir is empty, and repopulate
220     if state.last_dir != browse.current_dir || ctx[state.graph].get_nodes().is_empty() {
221         state.lay_out(pane, &browse.current_dir, &browse.entries, ctx);
222 
223         // Update breadcrumb path
224         let mut segments = Vec::new();
225         for component in browse.current_dir.components() {
226             let s = component.as_os_str().to_string_lossy().to_string();
227             if s != "/" && !s.is_empty() {
228                 segments.push(s);
229             }
230         }
231         ctx[state.breadcrumb].set_path(&segments);
232 
233         // Map browse selection to graph node selection if any
234         let has_parent = browse.current_dir.parent().is_some();
235         let offset = if has_parent { 2 } else { 1 };
236         if let Some(sel) = browse.selected {
237             ctx[state.graph].set_selected_node(Some(sel + offset));
238         } else {
239             ctx[state.graph].set_selected_node(None);
240         }
241     } else {
242         // A pane of another size: the same nodes, laid out to fit it.
243         if moved {
244             let selected = ctx[state.graph].selected_node();
245             state.lay_out(pane, &browse.current_dir, &browse.entries, ctx);
246             ctx[state.graph].set_selected_node(selected);
247         }
248         // Sync graph selection with browse selection when they are in sync
249         let has_parent = browse.current_dir.parent().is_some();
250         let offset = if has_parent { 2 } else { 1 };
251 
252         if let Some(sel) = browse.selected {
253             let expected_node_idx = sel + offset;
254             if ctx[state.graph].selected_node() != Some(expected_node_idx) {
255                 if !ctx[state.graph].is_dragging() {
256                     ctx[state.graph].set_selected_node(Some(expected_node_idx));
257                 }
258             }
259         } else {
260             if let Some(graph_sel) = ctx[state.graph].selected_node() {
261                 if graph_sel >= offset {
262                     ctx[state.graph].set_selected_node(None);
263                 }
264             }
265         }
266     }
267 
268     // Render the Graph widget into its pane under the breadcrumb — cut to
269     // it, since a graph panned or zoomed past its edge would otherwise draw
270     // over the preview beside it.
271     let mut graph_pc = PageContent::new();
272     cce_ui::layout::render_widget_h(&mut graph_pc, state.graph, pane.0, pane.1, pane.2, pane.3, ctx);
273     pc.absorb(graph_pc.clipped_to([cx, top, cx + cw, cy + ch]));
274 
275     // Each node's glyph on the left of its body, at the size and inset the
276     // graph's geometry toggle has on the right (hidden here): scaled with
277     // the zoom. Cut to the graph's rect, as its labels are.
278     let canvas = [cx, top, cx + cw, cy + ch];
279     let glyph_color = [0xcc as f32 / 255.0, 0xcc as f32 / 255.0, 0xd4 as f32 / 255.0, 1.0];
280     for (idx, glyph) in state.node_glyphs.iter().enumerate() {
281         let Some((nx, ny, nw, nh)) = ctx[state.graph].node_rect(idx) else { continue };
282         let scale_f = nw / 80.0;
283         let side = (18.0 * scale_f).clamp(6.0, 50.0);
284         pc.icon_bounded(glyph, nx + 6.0 * scale_f, ny + (nh - side) / 2.0, side, side, glyph_color, Some(canvas));
285     }
286 
287     pc
288 }
289 
290 #[cfg(test)]
291 mod tests {
292     use super::*;
293 
294     #[test]
295     fn test_populate_graph_has_parent() {
296         let mut ui = UiContext::new();
297         let mut state = NetworkState::new(&mut ui);
298         let current_dir = Path::new("/home/user/project");
299         let entries = vec![
300             DirEntry {
301                 name: "file1.txt".to_string(),
302                 path: PathBuf::from("/home/user/project/file1.txt"),
303                 is_dir: false,
304                 size: 100,
305                 permissions: 0o644,
306                 modified: String::new(),
307                 origin: None,
308             },
309             DirEntry {
310                 name: "subdir".to_string(),
311                 path: PathBuf::from("/home/user/project/subdir"),
312                 is_dir: true,
313                 size: 4096,
314                 permissions: 0o755,
315                 modified: String::new(),
316                 origin: None,
317             },
318         ];
319 
320         state.lay_out((0.0, 0.0, 800.0, 600.0), current_dir, &entries, &mut ui);
321         let nodes = ui[state.graph].get_nodes();
322 
323         // 1 parent + 1 current + 2 children = 4 nodes
324         assert_eq!(nodes.len(), 4);
325 
326         // Check node names
327         assert_eq!(nodes[0].name, ".. (user)");
328         assert_eq!(nodes[1].name, "project");
329         assert_eq!(nodes[2].name, "file1.txt");
330         assert_eq!(nodes[3].name, "subdir");
331 
332         // The kind rides as a glyph, not as a character in the name.
333         assert_eq!(state.node_glyphs, vec!["folder", "folder", "file", "folder"]);
334 
335         // Check connection parameters
336         // Current directory points to parent
337         assert_eq!(nodes[1].parameters.len(), 1);
338         assert_eq!(nodes[1].parameters[0].0, "input");
339         assert_eq!(nodes[1].parameters[0].1, ".. (user)");
340 
341         // Children point to current directory
342         assert_eq!(nodes[2].parameters.len(), 1);
343         assert_eq!(nodes[2].parameters[0].0, "input");
344         assert_eq!(nodes[2].parameters[0].1, "project");
345 
346         assert_eq!(nodes[3].parameters.len(), 1);
347         assert_eq!(nodes[3].parameters[0].0, "input");
348         assert_eq!(nodes[3].parameters[0].1, "project");
349     }
350 
351     /// The page fits its pane: column 0 starts a margin inside the left
352     /// edge, the rightmost column's body AND its name end inside the right
353     /// edge, a name never reaches the next column's body, and a name too long
354     /// for a column is cut with an ellipsis. Under the retired cell model
355     /// (cce-ui's lattice centring nodes on crossings) column 0 started left
356     /// of the pane, seven columns ran past its right edge and every name lay
357     /// across its neighbour.
358     #[test]
359     fn the_graph_fits_its_pane_and_its_names_fit_their_columns() {
360         let mut ui = UiContext::new();
361         use cce_ui::widget::display::TextLabel;
362         let mut state = NetworkState::new(&mut ui);
363         let names = ["Adwaita", "AdwaitaMono-BoldItalic.ttf", "a-really-quite-extraordinarily-long-font-family-name.otf", "x", "gnu-free", "noto-cjk", "liberation", "spleen", "xscreensaver"];
364         let entries: Vec<DirEntry> = names
365             .iter()
366             .map(|n| DirEntry { name: n.to_string(), path: PathBuf::from(format!("/f/{n}")), is_dir: false, size: 1, permissions: 0o644, modified: String::new(), origin: None })
367             .collect();
368         let pane = (20.0, 60.0, 600.0, 640.0);
369         state.lay_out(pane, Path::new("/usr/share/fonts"), &entries, &mut ui);
370         let g = ui[state.graph].inner();
371         let (nw, _) = g.node_size();
372         let (font, gap) = label_metrics(nw);
373         let (pitch_x, _) = g.grid_pitch();
374         let nodes = ui[state.graph].get_nodes();
375         for (i, node) in nodes.iter().enumerate() {
376             let (x, _, w, _) = g.node_rect(i).unwrap();
377             let label_w = TextLabel::estimate_width(&node.name, font);
378             assert!(x >= pane.0 + MARGIN - 0.01, "{:?} starts left of the pane: {x}", node.name);
379             assert!(x + w + gap + label_w <= pane.0 + pane.2 + 0.01, "{:?} runs past the pane", node.name);
380             assert!(w + gap + label_w < pitch_x, "{:?}'s name reaches the next column", node.name);
381             assert!(label_w <= NAME_MAX + 0.01, "{:?} is not cut to a column", node.name);
382         }
383         assert!(nodes.iter().any(|n| n.name.ends_with('\u{2026}')), "the long name is cut with an ellipsis");
384         assert!(nodes.iter().skip(2).any(|n| n.position.0 > 1.0), "more than one column fits 600 px");
385         let (ox, oy) = g.grid_origin();
386         assert!(ox < pane.0 && oy < pane.1, "the lattice's heavy axes lie outside the pane");
387         assert!(g.toggle_rect(0).is_none(), "a directory's nodes wear no geometry toggle");
388     }
389 
390     #[test]
391     fn test_populate_graph_root_no_parent() {
392         let mut ui = UiContext::new();
393         let mut state = NetworkState::new(&mut ui);
394         let current_dir = Path::new("/");
395         let entries = vec![
396             DirEntry {
397                 name: "bin".to_string(),
398                 path: PathBuf::from("/bin"),
399                 is_dir: true,
400                 size: 4096,
401                 permissions: 0o755,
402                 modified: String::new(),
403                 origin: None,
404             },
405         ];
406 
407         state.lay_out((0.0, 0.0, 800.0, 600.0), current_dir, &entries, &mut ui);
408         let nodes = ui[state.graph].get_nodes();
409 
410         // No parent, so 1 current + 1 child = 2 nodes
411         assert_eq!(nodes.len(), 2);
412         assert_eq!(nodes[0].name, "/");
413         assert_eq!(nodes[1].name, "bin");
414         assert_eq!(state.node_glyphs, vec!["folder", "folder"]);
415 
416         // Current has no parent parameter
417         assert!(nodes[0].parameters.is_empty());
418 
419         // Child points to current
420         assert_eq!(nodes[1].parameters.len(), 1);
421         assert_eq!(nodes[1].parameters[0].0, "input");
422         assert_eq!(nodes[1].parameters[0].1, "/");
423     }
424 }