Skip to main content

multi_select/
multi_select.rs

1//! Multi-select demo — Shift/Ctrl-click and multi-row keyboard ranges.
2//!
3//! Run with:
4//!
5//! ```sh
6//! cargo run --example multi_select -- /path/to/browse
7//! ```
8//!
9//! Defaults to the current directory.
10//!
11//! # The pattern
12//!
13//! iced 0.14's `button::on_press` callback cannot observe modifier
14//! keys. The built-in widget view therefore always emits
15//! `DirectoryTreeEvent::Selected(path, is_dir, SelectionMode::Replace)`
16//! — i.e. it treats every click like a plain click.
17//!
18//! For Shift-click and Ctrl-click to actually do multi-select, the
19//! *application* tracks modifier state separately (via the keyboard
20//! subscription), intercepts `Selected` events in its own update
21//! handler, and rewrites the mode using
22//! [`SelectionMode::from_modifiers`] before forwarding to
23//! `tree.update`.
24//!
25//! This mirrors how most iced apps handle modifier-aware input and
26//! keeps the widget focus-neutral.
27
28use std::collections::HashSet;
29use std::path::{Path, PathBuf};
30
31use iced::keyboard::{self, Modifiers};
32use iced::widget::{Column, column, container, scrollable, text};
33use iced::{Element, Length, Subscription, Task};
34use iced_swdir_tree::{DirectoryFilter, DirectoryTree, DirectoryTreeEvent, SelectionMode};
35
36#[derive(Debug, Clone)]
37enum Message {
38    Tree(DirectoryTreeEvent),
39    ModifiersChanged(Modifiers),
40    Key(keyboard::Key, Modifiers),
41}
42
43struct App {
44    tree: DirectoryTree,
45    /// Most recent modifier state observed. Used to rewrite
46    /// incoming `Selected` clicks into the appropriate mode.
47    modifiers: Modifiers,
48}
49
50impl App {
51    fn new() -> (Self, Task<Message>) {
52        let root = std::env::args()
53            .nth(1)
54            .map(PathBuf::from)
55            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
56        let root_for_task = root.clone();
57        let tree = DirectoryTree::new(root).with_filter(DirectoryFilter::FilesAndFolders);
58        (
59            Self {
60                tree,
61                modifiers: Modifiers::default(),
62            },
63            // Kick off the first expansion so the user sees content.
64            Task::done(Message::Tree(DirectoryTreeEvent::Toggled(root_for_task))),
65        )
66    }
67
68    fn update(&mut self, message: Message) -> Task<Message> {
69        match message {
70            // Intercept plain-click `Selected` events and rewrite the
71            // mode based on the current modifier state. The built-in
72            // view always produces Replace; keyboard events produce
73            // the right mode already (handled by handle_key).
74            Message::Tree(DirectoryTreeEvent::Selected(path, is_dir, _from_view)) => {
75                let mode = SelectionMode::from_modifiers(self.modifiers);
76                let event = DirectoryTreeEvent::Selected(path, is_dir, mode);
77                self.tree.update(event).map(Message::Tree)
78            }
79            Message::Tree(event) => self.tree.update(event).map(Message::Tree),
80            Message::ModifiersChanged(m) => {
81                self.modifiers = m;
82                Task::none()
83            }
84            Message::Key(key, mods) => {
85                if let Some(event) = self.tree.handle_key(&key, mods) {
86                    return self.tree.update(event).map(Message::Tree);
87                }
88                Task::none()
89            }
90        }
91    }
92
93    fn subscription(&self) -> Subscription<Message> {
94        // `keyboard::listen()` surfaces both key events AND modifier
95        // change events. We route both: key presses into the tree's
96        // `handle_key`, modifier changes into our own tracking.
97        keyboard::listen().map(|event| match event {
98            keyboard::Event::KeyPressed { key, modifiers, .. } => Message::Key(key, modifiers),
99            keyboard::Event::ModifiersChanged(modifiers) => Message::ModifiersChanged(modifiers),
100            // Non-press/non-modifier events: route as a harmless key
101            // that handle_key leaves unbound.
102            _ => Message::Key(
103                keyboard::Key::Named(keyboard::key::Named::F35),
104                Modifiers::default(),
105            ),
106        })
107    }
108
109    fn view(&self) -> Element<'_, Message> {
110        let selected = self.tree.selected_paths();
111        let count = selected.len();
112
113        // Human-readable summary of currently-selected rows.
114        let summary_text = if count == 0 {
115            "No selection. Click to select, Shift+click for range, \
116             Ctrl/Cmd+click to toggle."
117                .to_string()
118        } else {
119            format!(
120                "{count} selected (anchor: {})",
121                self.tree
122                    .anchor_path()
123                    .map(short_name)
124                    .unwrap_or_else(|| "-".into())
125            )
126        };
127
128        // Compact list of selected basenames. Capped to avoid the
129        // status bar eating the screen when the user ranges over
130        // a huge folder.
131        const MAX_SHOWN: usize = 10;
132        let names: HashSet<String> = selected.iter().map(|p| short_name(p)).collect();
133        let mut names_sorted: Vec<String> = names.into_iter().collect();
134        names_sorted.sort();
135        let shown: String = if names_sorted.len() <= MAX_SHOWN {
136            names_sorted.join(", ")
137        } else {
138            format!(
139                "{}, +{} more",
140                names_sorted
141                    .iter()
142                    .take(MAX_SHOWN)
143                    .cloned()
144                    .collect::<Vec<_>>()
145                    .join(", "),
146                names_sorted.len() - MAX_SHOWN
147            )
148        };
149
150        let status = Column::new()
151            .push(text(summary_text).size(13))
152            .push(text(shown).size(11))
153            .spacing(2);
154
155        container(
156            column![
157                scrollable(self.tree.view(Message::Tree)).height(Length::Fill),
158                status,
159            ]
160            .spacing(8.0)
161            .padding(8.0),
162        )
163        .width(Length::Fill)
164        .height(Length::Fill)
165        .into()
166    }
167}
168
169fn short_name(p: &Path) -> String {
170    p.file_name()
171        .map(|s| s.to_string_lossy().into_owned())
172        .unwrap_or_else(|| p.display().to_string())
173}
174
175fn main() -> iced::Result {
176    iced::application(App::new, App::update, App::view)
177        .subscription(App::subscription)
178        .title("iced-swdir-tree · multi-select example")
179        .run()
180}