77#include < nui/frontend/api/keyboard_event.hpp>
88
99#include < vector>
10+ #include < set>
1011#include < memory>
1112#include < optional>
13+ #include < functional>
1214
1315namespace NuiFileExplorer
1416{
17+ // / Manages item selection for both icon and table flavors.
18+ // /
19+ // / Two-layer model
20+ // / ---------------
21+ // / Layer 1 — Range layer (anchor_ + flag_)
22+ // / The contiguous shift-selection. Shift+click and Shift+Arrow only ever
23+ // / touch this layer. A plain click sets anchor_ and clears flag_ (single-
24+ // / item range). Everything between anchor_ and flag_ (inclusive, min/max
25+ // / order) is "in range".
26+ // /
27+ // / Layer 2 — Ctrl mask (ctrlAdd_ + ctrlRemove_)
28+ // / An additive/subtractive mask applied on top of the range.
29+ // / ctrlAdd_ – items that are selected regardless of the range.
30+ // / ctrlRemove_ – items that are punched out of the range.
31+ // / Ctrl+click dispatches to one of four cases (see onItemClicked).
32+ // / This layer is NEVER touched by shift or plain-arrow operations.
33+ // /
34+ // / Visible selection = (range(anchor_, flag_) − ctrlRemove_) ∪ ctrlAdd_
35+ // /
36+ // / Box-select (drag rectangle in icon flavor)
37+ // / ------------------------------------------
38+ // / Plain drag → clears both ctrl sets, sets anchor_/flag_ to cover the
39+ // / bounding-box items. Call selectRange(begin, end).
40+ // / Ctrl+drag → leaves anchor_/flag_ alone, inserts intersecting items
41+ // / into ctrlAdd_. Call ctrlAddRange(begin, end).
42+ // /
43+ // / Keyboard navigation — no wrapping, clamped at boundaries
44+ // / ---------------------------------------------------------
45+ // / Arrow (no modifier) → clear both ctrl sets, clear flag_, move anchor_
46+ // / one step; clamps at 0 / last item.
47+ // / Shift+Arrow → move flag_ one step; clamps at 0 / last item.
48+ // / Special boundary fill:
49+ // / Shift+Down at last row → extend flag_ to the
50+ // / last item (rightward fill to end of row).
51+ // / Shift+Up at row 0 → extend flag_ to index 0
52+ // / (leftward fill to start of row).
53+ // / Both ctrl sets are untouched.
54+ // / Ctrl+A → select all (clear ctrl sets, anchor_=0,
55+ // / flag_=last).
1556 class SelectionManager
1657 {
1758 public:
18- constexpr static std::size_t maxSelectableSearchAttempts = 5 ;
19-
2059 SelectionManager (Nui::Observed<std::vector<ItemWithInternals>>& items, Flavor flavor);
2160
22- // / Does not iterate the items to deselect all.
23- void loseTrackToAllSelections ();
24-
61+ // ------------------------------------------------------------------ basic ops
62+ void loseTrackToAllSelections (); // /< Forget tracking without touching DOM state.
2563 void deselectAll ();
2664 void selectAll ();
27- bool select (std::size_t index);
65+ void select (std::size_t index);
2866 void deselect (std::size_t index);
2967 void toggle (std::size_t index);
3068 void select (ItemWithInternals const & item);
3169 void select (std::filesystem::path const & path);
3270 void deselect (ItemWithInternals const & item);
3371 void toggle (ItemWithInternals const & item);
72+
73+ // ------------------------------------------------------------------ queries
3474 std::size_t selectedCount () const ;
3575 bool isSelected (std::size_t index) const ;
3676 bool isAnySelected () const ;
37- void setGrid (std::size_t width, std::size_t height);
77+ std::vector<Item> selectedItems () const ;
78+ std::set<std::filesystem::path> selectedPaths () const ;
79+
80+ // ------------------------------------------------------------------ layout
81+ void setGrid (std::size_t columns, std::size_t rows);
3882 void setFlavor (Flavor flavor);
3983
84+ // ------------------------------------------------------------------ scroll
85+ // / Register a callback that scrolls the item at the given flat index into
86+ // / view. Called automatically after every user-driven selection change
87+ // / (click, keyboard) with the "active index": flag_ if live, else anchor_.
88+ // / Not called by selectAll / deselectAll / selectRange / ctrlAddRange.
89+ void setScrollIntoViewCallback (std::function<void (std::size_t )> callback);
90+
91+ // ------------------------------------------------------------------ interaction
4092 void onItemClicked (ItemWithInternals const & item, Nui::WebApi::MouseEvent const & event);
4193
42- // / Returns true if the event was consumed.
94+ // / Returns true if the keyboard event was consumed.
4395 bool onKeyboardEvent (Nui::WebApi::KeyboardEvent const & event);
4496
97+ // ------------------------------------------------------------------ range helpers (public for icon_flavor
98+ // drag-box)
99+
100+ // / Plain box-drag: replace range layer, clear ctrl mask.
45101 void selectRange (std::size_t begin, std::size_t endInclusive);
46102
47- std::vector<Item> selectedItems () const ;
48- std::set< std::filesystem::path> selectedPaths () const ;
103+ // / Ctrl+box-drag: add items into ctrlAdd_ without touching the range layer.
104+ void ctrlAddRange ( std::size_t begin, std::size_t endInclusive) ;
49105
50106 private:
51- class GridPosition
107+ // ============================================================= Grid helper
108+ // / Abstracts an M×N grid whose last row may be incomplete.
109+ // / All navigation is flat-index based. Steps clamp — no wrapping.
110+ // /
111+ // / ArrowDown/Up boundary fill
112+ // / --------------------------
113+ // / stepDown when already on the last row:
114+ // / Returns itemCount_-1, filling selection to the end of the partial row.
115+ // / stepUp when already on row 0:
116+ // / Returns 0, filling selection to the start of the row.
117+ // /
118+ // / Table flavor: columns_ is treated as 1; Down/Up are ±1 clamped;
119+ // / Right/Left are aliases for Down/Up.
120+ class Grid
52121 {
53122 public:
54- using CoordinateType = std::make_signed<std::size_t >::type;
55-
56- GridPosition (SelectionManager const & manager, std::size_t index)
57- : manager_{&manager}
58- , row_{manager_->currentFlavor_ == Flavor::Icons ? static_cast <CoordinateType>(index / std::max (std::size_t {1 }, manager_->gridColumns_ )) : static_cast <CoordinateType>(index)}
59- , col_{
60- manager_->currentFlavor_ == Flavor::Icons
61- ? static_cast <CoordinateType>(index % std::max (std::size_t {1 }, manager_->gridColumns_ ))
62- : 0
63- }
123+ Grid () = default ;
124+ Grid (std::size_t columns, std::size_t rows, std::size_t itemCount, Flavor flavor)
125+ : columns_{std::max (std::size_t {1 }, columns)}
126+ , rows_{std::max (std::size_t {1 }, rows)}
127+ , itemCount_{itemCount}
128+ , flavor_{flavor}
64129 {}
65130
66- GridPosition (SelectionManager const & manager, CoordinateType row, CoordinateType col)
67- : manager_{&manager}
68- , row_{row}
69- , col_{col}
70- {}
71-
72- bool operator ==(GridPosition const & other) const
131+ std::size_t columns () const
73132 {
74- return normalRow () == other. normalRow () && normalCol () == other. normalCol () ;
133+ return columns_ ;
75134 }
76- std::size_t toIndex () const
135+ std::size_t rows () const
77136 {
78- return static_cast <std::size_t >(
79- normalized ().row_ * static_cast <CoordinateType>(manager_->gridColumns_ ) + normalized ().col_
80- );
137+ return rows_;
81138 }
82- // / Returns whether or not there is a valid item at this position.
83- bool isValid () const
139+ std::size_t items () const
84140 {
85- const auto norm = normalized ();
86- return norm.row_ >= 0 && norm.col_ >= 0 && static_cast <std::size_t >(norm.row_ ) < manager_->gridRows_ &&
87- static_cast <std::size_t >(norm.col_ ) < manager_->gridColumns_ ;
141+ return itemCount_;
88142 }
89- // / Gird positions can become virtual by having negative coordinates or coordinates beyond the grid size.
90- GridPosition normalized () const
143+
144+ // / Number of filled cells in the last row (1 … columns_).
145+ std::size_t itemsInLastRow () const
91146 {
92- return GridPosition{*manager_, normalRow (), normalCol ()};
147+ if (itemCount_ == 0 )
148+ return 0 ;
149+ const auto r = itemCount_ % columns_;
150+ return r == 0 ? columns_ : r;
93151 }
94- CoordinateType normalRow () const
95- {
96- const auto rows = static_cast <CoordinateType>(manager_->gridRows_ );
97- if (rows <= 0 )
98- return row_;
99-
100- auto row = row_;
101152
102- row %= rows;
103- if (row < 0 )
104- row += rows;
105- return row;
106- }
107- CoordinateType normalCol () const
153+ bool isTableFlavor () const
108154 {
109- const auto cols = static_cast <CoordinateType>(manager_->gridColumns_ );
110- if (cols < 0 )
111- return col_;
155+ return flavor_ == Flavor::Table;
156+ }
112157
113- auto col = col_;
158+ // / Move one step right; clamps at last item.
159+ std::size_t stepRight (std::size_t idx) const
160+ {
161+ if (itemCount_ == 0 )
162+ return 0 ;
163+ if (isTableFlavor ())
164+ return stepDown (idx);
165+ return std::min (idx + 1 , itemCount_ - 1 );
166+ }
114167
115- col %= cols;
116- if (col < 0 )
117- col += cols;
118- return col;
168+ // / Move one step left; clamps at 0.
169+ std::size_t stepLeft (std::size_t idx) const
170+ {
171+ if (itemCount_ == 0 )
172+ return 0 ;
173+ if (isTableFlavor ())
174+ return stepUp (idx);
175+ return idx > 0 ? idx - 1 : 0 ;
119176 }
120- void up ()
177+
178+ // / Move one step down (next row, same column).
179+ // / Boundary fill: if already on the last row, return itemCount_-1.
180+ std::size_t stepDown (std::size_t idx) const
121181 {
122- if (static_cast <CoordinateType>(manager_->gridRows_ ) <= 1 )
123- return ;
182+ if (itemCount_ == 0 )
183+ return 0 ;
184+ if (isTableFlavor ())
185+ return std::min (idx + 1 , itemCount_ - 1 );
124186
125- --row_;
187+ const auto next = idx + columns_;
188+ if (next < itemCount_)
189+ return next;
126190
127- // check if were are in the last row now, and outside the regular items range, if so go up one more.
128- if (normalRow () == static_cast <CoordinateType>(manager_->gridRows_ ) - 1 &&
129- normalCol () >= static_cast <CoordinateType>(manager_->itemsInLastRow ()))
130- --row_;
191+ // On the last row — fill rightward to the last item.
192+ return itemCount_ - 1 ;
131193 }
132- void down ()
194+
195+ // / Move one step up (previous row, same column).
196+ // / Boundary fill: if already on row 0, return 0.
197+ std::size_t stepUp (std::size_t idx) const
133198 {
134- if (static_cast <CoordinateType>(manager_->gridRows_ ) <= 1 )
135- return ;
199+ if (itemCount_ == 0 )
200+ return 0 ;
201+ if (isTableFlavor ())
202+ return idx > 0 ? idx - 1 : 0 ;
136203
137- ++row_;
204+ if (idx >= columns_)
205+ return idx - columns_;
138206
139- // check if were are in the last row now, and outside the regular items range, if so go down one more.
140- if (normalRow () == static_cast <CoordinateType>(manager_->gridRows_ ) - 1 &&
141- normalCol () >= static_cast <CoordinateType>(manager_->itemsInLastRow ()))
142- ++row_;
143- }
144- bool isUnselected () const
145- {
146- if (!isValid ())
147- return false ;
148- return manager_->isIndexUnselected (toIndex ());
149- }
150- bool isSelected () const
151- {
152- if (!isValid ())
153- return false ;
154- return manager_->isSelected (toIndex ());
155- }
156- bool isWrapped () const
157- {
158- return row_ < 0 || col_ < 0 ;
207+ // On row 0 — fill leftward to index 0.
208+ return 0 ;
159209 }
160210
161211 private:
162- SelectionManager const * manager_;
163- CoordinateType row_;
164- CoordinateType col_;
212+ std::size_t columns_{1 };
213+ std::size_t rows_{1 };
214+ std::size_t itemCount_{0 };
215+ Flavor flavor_{Flavor::Table};
165216 };
166217
167- std::optional<std::size_t > findFirstSelectable () const ;
168- std::optional<std::size_t > findLastSelectable () const ;
169- std::optional<std::size_t > findNextSelectable (std::size_t index) const ;
170- std::optional<std::size_t > findPreviousSelectable (std::size_t index) const ;
171- bool isIndexUnselected (std::make_signed_t <std::size_t > index) const ;
172- bool isIndexUnselected (std::size_t index) const ;
173- GridPosition calculateGridPositionFromIndex (std::size_t index) const ;
174- std::size_t itemsInLastRow () const ;
218+ // ============================================================= helpers
219+ Grid makeGrid () const ;
220+
221+ // / Rebuild visual isSelected() for every item from the two-layer model.
222+ // / selected[i] = (inRange(i) && !ctrlRemove_.count(i)) || ctrlAdd_.count(i)
223+ void rebuildSelection ();
224+
225+ // / Whether index i falls inside [min(anchor_,flag_), max(anchor_,flag_)].
226+ bool inRange (std::size_t i) const ;
227+
228+ static void rangeMinMax (std::size_t a, std::size_t b, std::size_t & lo, std::size_t & hi);
229+
230+ std::optional<std::size_t > itemIndex (ItemWithInternals const & item) const ;
231+ std::optional<std::size_t > itemIndex (std::filesystem::path const & path) const ;
232+
233+ bool isSelectablePath (std::size_t idx) const ;
234+
235+ // / Apply one navigation step to 'from' according to the event key.
236+ std::size_t navigate (std::size_t from, Nui::WebApi::KeyboardEvent const & event) const ;
237+
238+ // / Returns flag_ if active, else anchor_. nullopt if nothing is selected.
239+ std::optional<std::size_t > activeIndex () const ;
240+
241+ // / Fires scrollIntoView_ for the current activeIndex if it differs from
242+ // / lastScrolledIndex_ and scrollIntoView_ is set.
243+ void maybeScrollActiveIntoView ();
175244
176245 private:
177246 Nui::Observed<std::vector<ItemWithInternals>>* items_;
178- std::set<std::size_t > selectedIndices_;
179- std::optional<std::size_t > currentSelectionStart_{std::nullopt };
247+
248+ // Layer 1 — range
249+ std::optional<std::size_t > anchor_; // /< Fixed end of the shift-range / last plain click.
250+ std::optional<std::size_t > flag_; // /< Moving end; nullopt = single-item range at anchor_.
251+
252+ // Layer 2 — ctrl mask
253+ std::set<std::size_t > ctrlAdd_; // /< Selected regardless of the range.
254+ std::set<std::size_t > ctrlRemove_; // /< Punched out of the range.
255+
256+ // Grid layout (updated by setGrid / setFlavor)
180257 Flavor currentFlavor_;
181258 std::size_t gridColumns_{1 };
182259 std::size_t gridRows_{1 };
260+
261+ // Scroll-into-view
262+ std::function<void (std::size_t )> scrollIntoView_{};
263+ std::optional<std::size_t > lastScrolledIndex_{};
183264 };
184265}
0 commit comments