Skip to content

Commit c1c89a3

Browse files
committed
Improved selection behavior.
1 parent 2935a38 commit c1c89a3

5 files changed

Lines changed: 572 additions & 517 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ This project contains a cross platform (Linux & Windows for now) SSH and SFTP cl
118118
- [ ] Image preview (image as icon?)
119119
- [ ] Text preview
120120
- [ ] Show link target in properties window.
121-
- [ ] Improve arrow navigation with shift and control.
122-
- [ ] Scroll into view of recently selected item when using keyboard controls.
121+
- [X] Improve arrow navigation with shift and control.
122+
- [X] Scroll into view of recently selected item when using keyboard controls.
123123
- [ ] Mouse side keys
124124
- [ ] Ctrl + C / Ctrl + V for copy paste.
125125

nui-file-explorer/include/nui-file-explorer/item_with_internals.hpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ namespace NuiFileExplorer
2929
return selected_->value();
3030
}
3131

32+
void isSelected(bool value)
33+
{
34+
*selected_ = value;
35+
}
36+
3237
explicit ItemWithInternals(Item const& item)
3338
: item{item}
3439
, element{}

nui-file-explorer/include/nui-file-explorer/side/selection_manager.hpp

Lines changed: 188 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -7,178 +7,259 @@
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

1315
namespace 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

Comments
 (0)