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 }