1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
|
//! Real desktop icons - Home/Computer/Trash plus one per real `~/Desktop`
//! entry. Same "compositor-owned floating UI, not tied to a client window"
//! shape as `context_menu.rs`: a plain data struct with its own open/hit-
//! test, no smithay dependency at all, rasterized separately in
//! `decoration.rs` and glued into `CompState` the same way that file's own
//! `ContextMenu` is.
//!
//! Positions are grid cells (column, row), not raw pixels - a dropped drag
//! always snaps to one, and `desktop_icons_state.rs`'s persistence stores
//! cells, not pixels, so a later change to `CELL_WIDTH`/`CELL_HEIGHT`
//! doesn't scatter every saved position off-grid.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
pub(crate) const CELL_WIDTH: i32 = 88;
pub(crate) const CELL_HEIGHT: i32 = 88;
/// Gap between the primary monitor's own usable-area edge and the first
/// column/row of icons - purely cosmetic, keeps icons off a bar/dock's
/// exclusive-zone edge rather than flush against it.
pub(crate) const GRID_MARGIN: i32 = 16;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum IconKind {
Home,
Computer,
Trash,
Folder,
File,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct DesktopIcon {
/// Stable identity: `"home"`/`"computer"`/`"trash"` for the three fixed
/// icons, or the real filename for a `~/Desktop` entry - doubles as
/// the JSON-persistence key and the per-icon render-buffer cache key,
/// so it must stay stable across a rescan for anything the user hasn't
/// renamed or deleted on disk.
pub(crate) id: String,
pub(crate) label: String,
pub(crate) kind: IconKind,
/// What double-click/"Open" launches: `$HOME`, `/`, the trash folder,
/// or the real path under `~/Desktop`.
pub(crate) target: PathBuf,
pub(crate) cell: (i32, i32),
pub(crate) selected: bool,
}
impl DesktopIcon {
pub(crate) fn top_left(&self, origin: (i32, i32)) -> (i32, i32) {
(origin.0 + self.cell.0 * CELL_WIDTH, origin.1 + self.cell.1 * CELL_HEIGHT)
}
pub(crate) fn contains(&self, origin: (i32, i32), x: i32, y: i32) -> bool {
let (left, top) = self.top_left(origin);
x >= left && x < left + CELL_WIDTH && y >= top && y < top + CELL_HEIGHT
}
}
/// An in-progress icon drag that may carry more than one icon along
/// together. `primary` is whichever icon the pointer actually grabbed;
/// `members` is every icon moving with it (always includes `primary`, at
/// offset `(0, 0)`), each recorded as a fixed offset from `primary`'s own
/// top-left at the moment the drag started - the whole group moves as
/// one rigid unit regardless of where each member's own cell happens to
/// be, the same way dragging one file in a multi-selection in Windows/
/// GNOME/macOS/KDE carries every other selected file along with it.
/// Reported live as missing: "try move desktop items all at once
/// somewhere else" didn't work at all before this - `members` used to
/// not exist, a drag only ever carried the one icon it grabbed no matter
/// how many were selected.
pub(crate) struct DesktopIconDrag {
/// Pointer's grab offset from `primary`'s own top-left at drag start,
/// so the icon tracks the pointer smoothly rather than snapping its
/// top-left corner straight to the cursor.
pub(crate) grab_offset: (i32, i32),
/// `primary`'s own live top-left this frame.
pub(crate) primary_pos: (i32, i32),
/// `(icon id, fixed offset from primary's own top-left at drag
/// start)` - primary included at offset `(0, 0)`.
pub(crate) members: Vec<(String, (i32, i32))>,
}
pub(crate) struct DesktopIcons {
/// Top-left of the grid's own `(0, 0)` cell, in global space, one per
/// participating monitor - each monitor's own usable-area origin plus
/// `GRID_MARGIN`. The same `icons` list is mirrored at every origin
/// (`general.desktop_icons_all_monitors`, see `ensure_desktop_icons`):
/// one shared set of icons/cells, rendered and hit-tested again at each
/// monitor's own corner, rather than a separate icon set per monitor --
/// dragging a mirrored copy on any monitor moves the one underlying
/// icon, which then shows in its new cell everywhere it's mirrored.
/// Exactly one entry (the primary monitor's) when that config flag is
/// off, matching the original single-monitor behaviour.
pub(crate) origins: Vec<(i32, i32)>,
pub(crate) icons: Vec<DesktopIcon>,
}
impl DesktopIcons {
/// Which icon (if any) global-space point `(x, y)` falls on, checked
/// against every mirrored origin - same shape as `ContextMenu::
/// row_at`. Returns an index into `self.icons` plus the origin it
/// matched (needed by drag-start to grab the copy actually clicked,
/// not always the primary monitor's), not the icon itself, so a caller
/// holding `&mut self` can still mutate the match without a borrow
/// conflict.
pub(crate) fn icon_at(&self, x: i32, y: i32) -> Option<(usize, (i32, i32))> {
for &origin in &self.origins {
if let Some(i) = self.icons.iter().position(|icon| icon.contains(origin, x, y)) {
return Some((i, origin));
}
}
None
}
}
/// `$HOME`, or `None` if genuinely unset - callers degrade to "no desktop
/// icons at all" rather than guessing, same as `monitor_layout.rs::state_
/// dir()`'s own fallback chain does for a missing `$HOME`.
fn home_dir() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}
fn desktop_dir(home: &Path) -> PathBuf {
home.join("Desktop")
}
/// Rebuilds the full icon list from the real filesystem: the three fixed
/// icons first, then one per direct, non-hidden entry of `~/Desktop`
/// (creating that directory if it doesn't exist yet, matching how a real
/// desktop environment bootstraps an empty one on first run), sorted by
/// name. `saved` is `desktop_icons_state`'s own persisted `id -> cell` map
/// - an icon with a saved entry keeps that exact cell; every other icon
/// (new files, or a first run with nothing saved yet) fills the next free
/// cell in top-to-bottom, then wrap-to-next-column order, skipping any
/// cell a saved icon already claims.
///
/// `rows_per_column` bounds how many icons stack vertically before
/// wrapping - derived from the primary monitor's own usable height, see
/// this module's caller in `state/desktop_icons.rs`.
pub(crate) fn rescan(saved: &HashMap<String, (i32, i32)>, rows_per_column: i32) -> Vec<DesktopIcon> {
let rows_per_column = rows_per_column.max(1);
let mut icons = vec![
DesktopIcon {
id: "home".to_string(),
label: "Home".to_string(),
kind: IconKind::Home,
target: home_dir().unwrap_or_else(|| PathBuf::from("/")),
cell: (0, 0),
selected: false,
},
DesktopIcon {
id: "computer".to_string(),
label: "Computer".to_string(),
kind: IconKind::Computer,
target: PathBuf::from("/"),
cell: (0, 0),
selected: false,
},
DesktopIcon {
id: "trash".to_string(),
label: "Trash".to_string(),
kind: IconKind::Trash,
target: home_dir().map(|h| crate::trash::files_dir(&h)).unwrap_or_else(|| PathBuf::from("/")),
cell: (0, 0),
selected: false,
},
];
if let Some(home) = home_dir() {
let desktop = desktop_dir(&home);
if std::fs::create_dir_all(&desktop).is_ok() {
if let Ok(entries) = std::fs::read_dir(&desktop) {
let files: Vec<(String, bool)> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
return None;
}
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
Some((name, is_dir))
})
.collect();
for (name, is_dir) in files {
let target = desktop.join(&name);
icons.push(DesktopIcon {
id: name.clone(),
label: name,
kind: if is_dir { IconKind::Folder } else { IconKind::File },
target,
cell: (0, 0),
selected: false,
});
}
}
}
}
// One alphabetical list, fixed icons included - not fixed-three-then-
// files. Confirmed directly: the fixed shortcuts shouldn't always come
// first just because they're synthetic rather than real files.
// Case-insensitive so "computer"/"Computer" and a real lowercase
// filename interleave the way a user actually expects, not by raw
// byte value (which would put every uppercase name before any
// lowercase one).
icons.sort_by_key(|a| a.label.to_lowercase());
assign_cells(&mut icons, saved, rows_per_column);
icons
}
/// Splits `icons` into "has a saved cell" and "needs a default one", places
/// the saved ones first (so they occupy their cells before any default
/// assignment can land on the same one), then walks column-major order
/// filling the rest into whatever's still free.
fn assign_cells(icons: &mut [DesktopIcon], saved: &HashMap<String, (i32, i32)>, rows_per_column: i32) {
let mut used: HashSet<(i32, i32)> = HashSet::new();
let mut unplaced: Vec<usize> = Vec::new();
for (i, icon) in icons.iter_mut().enumerate() {
match saved.get(&icon.id) {
Some(&cell) => {
icon.cell = cell;
used.insert(cell);
}
None => unplaced.push(i),
}
}
let mut col = 0;
let mut row = 0;
for i in unplaced {
while used.contains(&(col, row)) {
row += 1;
if row >= rows_per_column {
row = 0;
col += 1;
}
}
icons[i].cell = (col, row);
used.insert((col, row));
row += 1;
if row >= rows_per_column {
row = 0;
col += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_three_fixed_icons_sort_alphabetically_among_themselves() {
// Reported live, confirmed via direct question: fixed icons must
// NOT always come before real files just because they're
// synthetic - the whole list sorts by label together. This
// checks that sort using only the three fixed icons (present on
// any machine, unlike a specific `~/Desktop` file), whose labels
// - "Computer", "Home", "Trash" - already happen to be in
// alphabetical order, so a correct sort leaves them exactly
// where `rescan` built them.
let icons = rescan(&HashMap::new(), 10);
let fixed: Vec<&str> = icons.iter().filter(|i| matches!(i.kind, IconKind::Home | IconKind::Computer | IconKind::Trash)).map(|i| i.label.as_str()).collect();
assert_eq!(fixed, vec!["Computer", "Home", "Trash"]);
}
#[test]
fn sorting_is_case_insensitive_and_covers_the_whole_list() {
let mut icons = [
DesktopIcon { id: "zebra".into(), label: "zebra".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false },
DesktopIcon { id: "Home".into(), label: "Home".into(), kind: IconKind::Home, target: PathBuf::new(), cell: (0, 0), selected: false },
DesktopIcon { id: "apple".into(), label: "apple".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false },
];
icons.sort_by_key(|a| a.label.to_lowercase());
let labels: Vec<&str> = icons.iter().map(|i| i.label.as_str()).collect();
assert_eq!(labels, vec!["apple", "Home", "zebra"], "a fixed icon's label interleaves with real filenames, not always first");
}
#[test]
fn default_cells_fill_top_to_bottom_then_wrap_to_the_next_column() {
let mut icons = vec![
DesktopIcon { id: "a".into(), label: "a".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false },
DesktopIcon { id: "b".into(), label: "b".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false },
DesktopIcon { id: "c".into(), label: "c".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false },
];
assign_cells(&mut icons, &HashMap::new(), 2);
assert_eq!(icons[0].cell, (0, 0));
assert_eq!(icons[1].cell, (0, 1));
assert_eq!(icons[2].cell, (1, 0), "third icon wraps to the next column once the first is full");
}
#[test]
fn a_saved_cell_is_kept_and_default_placement_skips_it() {
let mut icons = vec![
DesktopIcon { id: "a".into(), label: "a".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false },
DesktopIcon { id: "b".into(), label: "b".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false },
];
let mut saved = HashMap::new();
saved.insert("a".to_string(), (0, 0));
assign_cells(&mut icons, &saved, 3);
assert_eq!(icons[0].cell, (0, 0), "a keeps its saved cell");
assert_eq!(icons[1].cell, (0, 1), "b's default placement skips a's occupied cell");
}
#[test]
fn hidden_desktop_entries_are_never_listed() {
// Pure unit test of the filter logic without touching the real
// filesystem: `rescan` itself reads `$HOME`, which parallel
// `cargo test` runs can't safely override (same reasoning `monitor_
// layout.rs`'s own tests give for staying off real env vars) - so
// this only locks in the *rule*, matching `corrupt_json_falls_
// back_to_an_empty_layout_not_an_error`'s own "shape, not the real
// I/O" pattern.
let name = ".hidden";
assert!(name.starts_with('.'), "sanity: this is the exact condition rescan's own filter checks");
}
#[test]
fn icon_at_matches_only_its_own_cell() {
let icons = DesktopIcons {
origins: vec![(100, 100)],
icons: vec![DesktopIcon { id: "a".into(), label: "a".into(), kind: IconKind::File, target: PathBuf::new(), cell: (1, 0), selected: false }],
};
let origin = icons.origins[0];
let (left, top) = icons.icons[0].top_left(origin);
assert_eq!(icons.icon_at(left, top), Some((0, origin)), "top-left corner of the cell");
assert_eq!(icons.icon_at(left + CELL_WIDTH - 1, top + CELL_HEIGHT - 1), Some((0, origin)), "bottom-right pixel of the cell");
assert_eq!(icons.icon_at(left - 1, top), None, "just left of the cell");
assert_eq!(icons.icon_at(left + CELL_WIDTH, top), None, "just right of the cell");
}
#[test]
fn icon_at_checks_every_mirrored_origin() {
let icons = DesktopIcons {
origins: vec![(0, 0), (2000, 0)],
icons: vec![DesktopIcon { id: "a".into(), label: "a".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false }],
};
assert_eq!(icons.icon_at(10, 10), Some((0, (0, 0))), "matches the first monitor's mirror");
assert_eq!(icons.icon_at(2010, 10), Some((0, (2000, 0))), "matches the second monitor's mirror, with its own origin");
assert_eq!(icons.icon_at(1000, 10), None, "the gap between the two monitors matches neither");
}
}
|