morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
navigator.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2#pragma once
3
4#include <algorithm>
5#include <cctype>
6#include <cstddef>
7#include <functional>
8#include <memory>
9#include <string>
10#include <string_view>
11#include <utility>
12#include <vector>
13
14#include "backend.hpp"
15#include "bundle.hpp"
16#include "catalogue.hpp"
17#include "view.hpp"
18
19/// @file
20/// @brief The workspace's navigator and pins (spec 6 §7): the catalog listed by module and group, searched by title,
21/// with the person's pinned shortcuts.
22///
23/// Specified in `docs/spec/ui/workspace.md`.
24
25namespace morph::ui {
26
27/// @brief How a `Navigator` lists the catalog and what it reports.
29 /// @brief The catalog's entries, in the order the navigator lists them within a group.
30 std::vector<CatalogEntry> entries;
31 /// @brief An entry's titles, each one a search can match: one per locale (spec 6 §19). Empty uses the entry's
32 /// title, a string or a message reference's `fallback`. The first title is the one shown.
33 std::function<std::vector<std::string>(CatalogEntry const&)> titles;
34 /// @brief The person's pins as stored, in order; unknown ids and those past `maxPins` are dropped.
35 std::vector<std::string> pins;
36 /// @brief The most pins (`app-max-pins`).
37 std::size_t maxPins = 12;
38 /// @brief Called with an available entry's screen id when it, or its pin, is chosen.
39 std::function<void(std::string const&)> onOpen;
40 /// @brief Called with an available entry's screen id when the pointer rests on it or its pin: the time to start
41 /// the screen's prefetch queries.
42 std::function<void(std::string const&)> onPrefetch;
43 /// @brief Called with the whole new list each time the pins change, for the application to store.
44 std::function<void(std::vector<std::string> const&)> onPins;
45};
46
47/// @brief The catalog as a list a person opens screens from, with a title search and pinned shortcuts.
48///
49/// Entries are grouped by module, then group, in the order each first appears; each group has a heading. Each entry
50/// is a list item (`ButtonStyle::Item`) as wide as the navigator, and `setCurrent` marks the open screen's. An
51/// unavailable entry is listed disabled, with its reason as its tooltip. The search shows the entries one of whose
52/// titles contains the text, ignoring ASCII case, and the headings of the groups that still show one. Neither
53/// copyable nor movable; owned by the owner thread of its backend.
54///
55/// Test ids: `navigator`, `navigator_search`, `navigator_pins`, `nav_<screen id>`, `pin_<screen id>`, and
56/// `navigator_group_<module>/<group>` for a heading.
57class Navigator {
58public:
59 /// @brief The navigator, appended to @p parent.
60 /// @param backend The backend its widgets are made with. Borrowed.
61 /// @param parent The container it is appended to; null makes it a backend root.
62 /// @param options The entries, the pins and the handlers.
63 Navigator(IViewBackend& backend MORPH_LIFETIMEBOUND, ContainerWidget* parent, NavigatorOptions options)
64 : _backend{&backend}, _options{std::move(options)}, _root{backend.createStack(parent, Axis::Vertical)} {
65 _root->setTestId("navigator");
66 _search = backend.createTextInput(_root.get(), TextInputMode::SingleLine);
67 _search->setTestId("navigator_search");
68 _search->setPlaceholder("Search");
69 _search->setAccessibleName("Search screens");
70 _search->setOnChange([this](std::string const& text) { filter(text); });
71 _pinRow = backend.createStack(_root.get(), Axis::Horizontal);
72 _pinRow->setTestId("navigator_pins");
73 listEntries();
74 std::vector<std::string> stored = std::move(_options.pins);
75 _options.pins.clear();
76 for (std::string& screenId : stored) {
77 if (_pins.size() < _options.maxPins && known(screenId) && !pinned(screenId)) {
78 _pins.push_back(std::move(screenId));
79 }
80 }
81 showPins();
82 }
83
84 Navigator(Navigator const&) = delete;
85 Navigator& operator=(Navigator const&) = delete;
86 Navigator(Navigator&&) = delete;
87 Navigator& operator=(Navigator&&) = delete;
88 ~Navigator() = default;
89
90 /// @brief The navigator's root widget, which the host places.
91 /// @return It.
92 [[nodiscard]] StackWidget& root() noexcept { return *_root; }
93
94 /// @brief Moves keyboard focus to the search, as the navigator's shortcut does.
95 void focusSearch() { _search->focus(); }
96
97 /// @brief Pins a screen at the end of the pins.
98 /// @param screenId The screen id.
99 /// @return Whether it was pinned: `false` for an unknown or already pinned screen, or when the pins are full.
100 bool pin(std::string const& screenId) {
101 if (!known(screenId) || pinned(screenId) || _pins.size() >= _options.maxPins) {
102 return false;
103 }
104 _pins.push_back(screenId);
105 pinsChanged();
106 return true;
107 }
108
109 /// @brief Removes a pin.
110 /// @param screenId The screen id.
111 /// @return Whether it was pinned.
112 bool unpin(std::string const& screenId) {
113 if (std::erase(_pins, screenId) == 0) {
114 return false;
115 }
116 pinsChanged();
117 return true;
118 }
119
120 /// @brief The pins, in order.
121 /// @return Their screen ids.
122 [[nodiscard]] std::vector<std::string> const& pins() const noexcept { return _pins; }
123
124 /// @brief Marks the entry of the screen that is open, and clears the mark of the one marked before. A pin is not
125 /// marked.
126 /// @param screenId The open screen's id; one the navigator does not list, or empty, leaves no entry marked.
127 void setCurrent(std::string_view screenId) {
128 if (screenId == _current) {
129 return;
130 }
131 if (Item const* const previous = itemOf(_current)) {
132 previous->button->setSelected(false);
133 }
134 _current = std::string{screenId};
135 if (Item const* const next = itemOf(_current)) {
136 next->button->setSelected(true);
137 } else {
138 _current.clear();
139 }
140 }
141
142 /// @brief The screen whose entry is marked.
143 /// @return Its id; empty when none is.
144 [[nodiscard]] std::string const& current() const noexcept { return _current; }
145
146private:
147 struct Item {
148 CatalogEntry const* entry = nullptr;
149 std::vector<std::string> titles;
150 std::unique_ptr<ButtonWidget> button;
151 };
152
153 struct Group {
154 std::string module;
155 std::string group;
156 std::unique_ptr<TextWidget> heading;
157 std::vector<Item> items;
158 };
159
160 [[nodiscard]] static std::string lower(std::string_view text) {
161 std::string out{text};
162 std::ranges::transform(out, out.begin(), [](char byte) {
163 return static_cast<char>(std::tolower(static_cast<unsigned char>(byte)));
164 });
165 return out;
166 }
167
168 [[nodiscard]] std::vector<std::string> titlesOf(CatalogEntry const& entry) const {
169 if (_options.titles) {
170 std::vector<std::string> titles = _options.titles(entry);
171 if (!titles.empty()) {
172 return titles;
173 }
174 }
175 return catalogTitles(entry, nullptr);
176 }
177
178 [[nodiscard]] CatalogEntry const* entryOf(std::string_view screenId) const {
179 auto const found = std::ranges::find(_options.entries, screenId, &CatalogEntry::id);
180 return found == _options.entries.end() ? nullptr : &*found;
181 }
182
183 [[nodiscard]] bool known(std::string_view screenId) const { return entryOf(screenId) != nullptr; }
184
185 [[nodiscard]] Item const* itemOf(std::string_view screenId) const {
186 for (Group const& group : _groups) {
187 auto const found = std::ranges::find_if(
188 group.items, [screenId](Item const& item) { return !screenId.empty() && item.entry->id == screenId; });
189 if (found != group.items.end()) {
190 return &*found;
191 }
192 }
193 return nullptr;
194 }
195
196 [[nodiscard]] bool pinned(std::string_view screenId) const {
197 return std::ranges::find(_pins, screenId) != _pins.end();
198 }
199
200 // Wires a button to open, and prefetch on hover, an available entry.
201 void wire(ButtonWidget& button, CatalogEntry const& entry, std::string const& title) {
202 button.setLabel(title);
203 button.setEnabled(entry.available);
204 if (!entry.available) {
205 button.setTooltip(entry.reason.value_or(std::string{}));
206 return;
207 }
208 std::string const screenId = entry.id;
209 button.setOnClick([this, screenId] {
210 if (_options.onOpen) {
211 _options.onOpen(screenId);
212 }
213 });
214 button.setOnHover([this, screenId] {
215 if (_options.onPrefetch) {
216 _options.onPrefetch(screenId);
217 }
218 });
219 }
220
221 void listEntries() {
222 for (CatalogEntry const& entry : _options.entries) {
223 bool const seen = std::ranges::any_of(_groups, [&entry](Group const& candidate) {
224 return candidate.module == entry.module && candidate.group == entry.group;
225 });
226 if (!seen) {
227 _groups.push_back(Group{.module = entry.module, .group = entry.group, .heading = {}, .items = {}});
228 }
229 }
230 for (Group& group : _groups) {
231 group.heading = _backend->createText(_root.get());
232 group.heading->setRole(TextRole::Section);
233 group.heading->setTestId("navigator_group_" + group.module + "/" + group.group);
234 std::string heading = group.module;
235 if (!group.group.empty()) {
236 heading += heading.empty() ? group.group : " / " + group.group;
237 }
238 group.heading->setText(heading);
239 for (CatalogEntry const& entry : _options.entries) {
240 if (entry.module != group.module || entry.group != group.group) {
241 continue;
242 }
243 Item item{.entry = &entry, .titles = titlesOf(entry), .button = _backend->createButton(_root.get())};
244 item.button->setTestId("nav_" + entry.id);
245 item.button->setStyle(ButtonStyle::Item);
246 item.button->setLayout({.width = Sizing::stretch(), .height = {}});
247 wire(*item.button, entry, item.titles.front());
248 group.items.push_back(std::move(item));
249 }
250 }
251 }
252
253 void filter(std::string const& text) {
254 std::string const wanted = lower(text);
255 for (Group& group : _groups) {
256 bool any = false;
257 for (Item& item : group.items) {
258 bool const shown =
259 wanted.empty() || std::ranges::any_of(item.titles, [&wanted](std::string const& title) {
260 return lower(title).contains(wanted);
261 });
262 item.button->setVisible(shown);
263 any = any || shown;
264 }
265 group.heading->setVisible(any);
266 }
267 }
268
269 void showPins() {
270 _pinButtons.clear();
271 for (std::string const& screenId : _pins) {
272 CatalogEntry const* const entry = entryOf(screenId);
273 auto button = _backend->createButton(_pinRow.get());
274 button->setTestId("pin_" + screenId);
275 wire(*button, *entry, titlesOf(*entry).front());
276 _pinButtons.push_back(std::move(button));
277 }
278 }
279
280 void pinsChanged() {
281 showPins();
282 if (_options.onPins) {
283 _options.onPins(_pins);
284 }
285 }
286
287 IViewBackend* _backend;
288 NavigatorOptions _options;
289 std::unique_ptr<StackWidget> _root;
290 std::unique_ptr<TextInputWidget> _search;
291 std::unique_ptr<StackWidget> _pinRow;
292 std::vector<std::unique_ptr<ButtonWidget>> _pinButtons;
293 std::vector<Group> _groups;
294 std::vector<std::string> _pins;
295 std::string _current;
296};
297
298} // namespace morph::ui
Node button(ButtonOptions const &options)
A button node.
Definition author.hpp:3156
What the UI service's client and server share (spec 5 §12): content hashes, the bundle's manifest,...
@ Item
An entry of a list: as wide as its line, its label at the start, unframed, and marked while selected.
Message catalogues (spec 6 §19): a bundle's messages files read into a TranslationProvider that t res...
std::vector< std::string > catalogTitles(CatalogEntry const &entry, MessageCatalogues const *catalogues)
A catalog entry's title in every locale the catalogues hold: what a search over screen titles compare...
A widget with children. A factory given this container appends the new widget as its last child.
Definition backend.hpp:103
Makes widgets. Each factory appends the new widget to parent, or makes a root when it is null,...
Definition backend.hpp:810
The catalog as a list a person opens screens from, with a title search and pinned shortcuts.
Definition navigator.hpp:57
void setCurrent(std::string_view screenId)
Marks the entry of the screen that is open, and clears the mark of the one marked before....
bool unpin(std::string const &screenId)
Removes a pin.
StackWidget & root() noexcept
The navigator's root widget, which the host places.
Definition navigator.hpp:92
Navigator(IViewBackend &backend, ContainerWidget *parent, NavigatorOptions options)
The navigator, appended to parent.
Definition navigator.hpp:63
std::string const & current() const noexcept
The screen whose entry is marked.
void focusSearch()
Moves keyboard focus to the search, as the navigator's shortcut does.
Definition navigator.hpp:95
std::vector< std::string > const & pins() const noexcept
The pins, in order.
bool pin(std::string const &screenId)
Pins a screen at the end of the pins.
Children stacked along one axis, fixed at creation.
Definition backend.hpp:310
One catalog entry (spec 6 §8).
Definition bundle.hpp:251
std::string id
The screen's id.
Definition bundle.hpp:253
How a Navigator lists the catalog and what it reports.
Definition navigator.hpp:28
std::function< void(std::string const &)> onPrefetch
Called with an available entry's screen id when the pointer rests on it or its pin: the time to start...
Definition navigator.hpp:42
std::size_t maxPins
The most pins (app-max-pins).
Definition navigator.hpp:37
std::function< std::vector< std::string >(CatalogEntry const &)> titles
An entry's titles, each one a search can match: one per locale (spec 6 §19). Empty uses the entry's t...
Definition navigator.hpp:33
std::vector< CatalogEntry > entries
The catalog's entries, in the order the navigator lists them within a group.
Definition navigator.hpp:30
std::function< void(std::vector< std::string > const &)> onPins
Called with the whole new list each time the pins change, for the application to store.
Definition navigator.hpp:44
std::function< void(std::string const &)> onOpen
Called with an available entry's screen id when it, or its pin, is chosen.
Definition navigator.hpp:39
std::vector< std::string > pins
The person's pins as stored, in order; unknown ids and those past maxPins are dropped.
Definition navigator.hpp:35
static constexpr Sizing stretch(int weight=1) noexcept
A share of the leftover space.
Definition view.hpp:294
The backend contract: the retained widgets a mount drives, and the factory that makes them.
The toolkit-agnostic view tree: props, node kinds and their builders.
@ SingleLine
One line; Enter submits.
Definition view.hpp:210
Node text(Text spec)
Static or bound text.
Definition view.hpp:1194
Axis
The direction a stack arranges, or a scroll area scrolls, its content.
Definition view.hpp:222
@ Vertical
Top to bottom.
Definition view.hpp:223
@ Horizontal
Left to right.
Definition view.hpp:224
Node button(Button spec)
A push button.
Definition view.hpp:1199