diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a19111..418151b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Demo and example smoke tests from another directory run: | cd /tmp - for example in MyGUI MyGUI_hello MyGUI_layout MyGUI_widgets; do + for example in MyGUI MyGUI_hello MyGUI_layout MyGUI_widgets MyGUI_dialogs MyGUI_combobox; do xvfb-run -a "$GITHUB_WORKSPACE/build/${{ matrix.preset }}/$example" --frames 3 done - name: Install and build a find_package consumer diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c504a4..9a001e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,7 +36,7 @@ add_library(MyGUI_core STATIC src/Widget.cpp src/Panel.cpp src/Container.cpp src/UIContext.cpp src/Font.cpp src/Label.cpp src/Button.cpp src/Image.cpp src/ImageView.cpp src/Checkbox.cpp src/Slider.cpp src/ProgressBar.cpp src/ScrollView.cpp - src/TextField.cpp + src/Popup.cpp src/ComboBox.cpp src/Dialog.cpp src/TextField.cpp src/gl/Functions.cpp ) add_library(MyGUI::MyGUI ALIAS MyGUI_core) @@ -73,6 +73,8 @@ if(MYGUI_BUILD_EXAMPLES) mygui_example(MyGUI_hello examples/hello.cpp) mygui_example(MyGUI_layout examples/layout.cpp) mygui_example(MyGUI_widgets examples/widgets.cpp) + mygui_example(MyGUI_dialogs examples/dialogs.cpp) + mygui_example(MyGUI_combobox examples/combobox.cpp) endif() if(MYGUI_INSTALL) @@ -110,6 +112,9 @@ if(MYGUI_TOP_LEVEL AND BUILD_TESTING) endfunction() mygui_test(MyGUI_tests tests/RendererTests.cpp RendererTests) mygui_test(MyGUI_window_tests tests/WindowEventTests.cpp WindowEventTests) + mygui_test(MyGUI_popup_tests tests/PopupTests.cpp PopupTests) + mygui_test(MyGUI_combobox_tests tests/ComboBoxTests.cpp ComboBoxTests) + mygui_test(MyGUI_dialog_tests tests/DialogTests.cpp DialogTests) mygui_test(MyGUI_ui_tests tests/UIContextTests.cpp UIContextTests) mygui_test(MyGUI_font_tests tests/FontTests.cpp FontTests) mygui_test(MyGUI_image_tests tests/ImageTests.cpp ImageTests) @@ -120,5 +125,5 @@ if(MYGUI_TOP_LEVEL AND BUILD_TESTING) mygui_warnings(MyGUI_input_tests) add_test(NAME InputEventTests COMMAND MyGUI_input_tests) set_tests_properties(RendererTests WindowEventTests PROPERTIES LABELS "display") - set_tests_properties(InputEventTests UIContextTests FontTests ImageTests PROPERTIES LABELS "headless") + set_tests_properties(PopupTests ComboBoxTests DialogTests InputEventTests UIContextTests FontTests ImageTests PROPERTIES LABELS "headless") endif() diff --git a/README.md b/README.md index 81de159..54bcb04 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ A small C++20 GUI library and demo using GLFW, OpenGL 3.3, and FreeType. It supports layouts, wrapped text, images, scrolling, themes, and controls including buttons, checkboxes, switches, sliders, progress bars, and text fields -with selection and clipboard support. +with selection and clipboard support. Viewport overlays and reusable modal dialogs +support editable content, focus trapping, cancellation, and stacking. Anchored +popups and non-editable ComboBox controls support scrolling and type-ahead. ![Widget tree rendered by the library](docs/widgets.png) @@ -142,6 +144,8 @@ Small programs in `examples/` show one topic each and build next to the demo (`- - `MyGUI_hello` (`examples/hello.cpp`): a label and a button, the minimum setup. - `MyGUI_layout` (`examples/layout.cpp`): nested layouts, flex weights and wrapped text that reflows on resize. +- `MyGUI_combobox` (`examples/combobox.cpp`): anchored lists, disabled choices, and modal integration. +- `MyGUI_dialogs` (`examples/dialogs.cpp`): confirmation, editable settings, and stacked modal dialogs. - `MyGUI_widgets` (`examples/widgets.cpp`): every built-in control inside a scroll view. `examples/common/Example.hpp` holds their shared font lookup and event loop. Each accepts `--frames N` for smoke tests. The full demo lives in `examples/demo/main.cpp` and builds as `MyGUI`. diff --git a/docs/presentation-investigation.md b/docs/presentation-investigation.md new file mode 100644 index 0000000..dc0f0bf --- /dev/null +++ b/docs/presentation-investigation.md @@ -0,0 +1,131 @@ +# Redraw-on-demand presentation investigation + +## Verdict + +No MyGUI presentation defect was demonstrated, and no production changes were +justified. Controlled captures establish **diagnostic/environment discrepancies**: +GL front-buffer readbacks can be black while both native captures are correct; +post-swap/idle GL reads can perturb the first resized presentation; and the live +desktop permits client capture but rejects root capture. The latter two GL effects +also occur in a minimal GLFW program containing no MyGUI code. + +The originally reported intermittent **all-black Xvfb root montage was not +reproduced** with retained per-observation pixels in this investigation. Its exact +cause remains unproven. These findings must not be retroactively asserted to be +its cause. The earlier conclusion based on a back-buffer sample was insufficient. + +## Controlled evidence + +Baseline: HEAD `b0c42c9cad0178b88449692845ec47d8b902fb61` plus the local dialog, +popup and ComboBox work. Source fingerprints are retained in each report. The +reported 9/9 Debug/Release and 4/4 sanitizer results were treated as the baseline, +not rerun for this harness/documentation-only change. + +Xvfb: X.Org server release 12101025, 1280x1024, root/client depth 24, TrueColor +visual 0x21, RGB masks ff0000/ff00/ff, 32-bit little-endian XImages. Client size is +640x520 initially and 720x560 after resize. GLFW 3.4.0 selected X11 (0x60004), GLX; +Mesa 26.2.5, llvmpipe LLVM 22.1.8, OpenGL 4.6 core. The context configuration +requested by both programs is OpenGL 3.3 core and double buffering, swap interval 1. + +The eight-run matrix used ComboBox/control × demand/continuous × GL reads on/off. +All native observers agreed at every captured checkpoint. With GL reads disabled, +**all 15 ComboBox scene hashes match between continuous and demand modes**: +initial, open, highlight, commit, outside dismissal, Escape cancellation, wheel +scroll, modal popup, modal without popup, focus loss/return, resize, uncover, +unmap/remap, and idle. A static marker confirms scene extent after resizing. +No capture-induced refresh was observed in the isolated runs. + +Every demand run settled to zero swaps during the final 0.5-second idle sample, +with zero refreshes during that sample. The continuous runs produced hundreds or +thousands of swaps in the same interval. Zero OS events is not an acceptance +condition; uncover/remap generated legitimate refreshes and additional frames. + +Explicit GL observations used read FBO 0, original GL_BACK (1029), selected +GL_BACK before swap or GL_FRONT (1028) after swap. Logs include GLFW context, +actual GLX context/drawable and GL errors. Before-swap back images contain the +rendered scene. Front reads returned black with GL error 0 even when both native +captures showed the scene. Neither read alone establishes native presentation. +Post-swap back contents are not treated as specified/presented content. + +Phase isolation on the minimal GLFW control: + +| Read phase | First native resize capture | +| --- | --- | +| No GL read | Correct new marker position | +| Before-swap back only | Correct new marker position | +| After-swap front only | Old marker position | +| Idle front only | Old marker position | +| Idle back only | Old marker position | + +A subsequent legitimate uncover refresh presented the correct resized scene. +This establishes an observer effect in this GLX environment, not a reason to add +swaps, synchronization or repaint timers to MyGUI. No such workaround was added. + +A targeted live X11-on-Wayland desktop comparison used the NVIDIA RTX 4080, +NVIDIA 615.78.08 GLX renderer. Title matching initially found a 740x694 decoration +window (depth 32, visual 0xca). Using GLFW's native ID identified the actual +640x520 client (depth 24, visual 0x9a). Client XGetImage succeeded; root XGetImage +failed with BadMatch, request 73, on root drawable 974, despite an in-bounds crop +and a viewable live client. Root size was 11520x2160, depth 24, visual 0x9a. The +saved client image shows the control scene. A full live-desktop root comparison +therefore remains unavailable; the failure is not evidence of blank app output. + +## Event-to-presentation trace + +- Initial drawing and a swap return occur before the first indefinite wait. +- All exercised visible changes invalidate redraw; dispatch returns with redraw + pending, followed by layout, drawing and a swap on the same current context. +- `UIContext::draw()` clears redraw immediately before drawing, after layout; + no request was observed being cleared without drawing. A later request remains + pending. Exceptions restore the flag in the existing implementation. +- Refresh events from uncover/remap set redraw and lead to presented frames. +- Drawing is followed by the existing swap; both programs then wait when settled. +- **Queue-order caveat:** the shared loop waits before draining the application + queue. It does not drain already-queued application events before an indefinite + wait. In the observed sequences, GLFW wait returned and events were dispatched; + no stranded queued event caused the discrepancy. No speculative loop fix was + made. A focused next test for that separate concern would enqueue an event + outside polling immediately before the loop waits, with no further native event. + +The probes are generated copies of current sources with opt-in trace/readback +hooks. Original UIContext, Window, renderer, example loop and controls are intact. +GL reads are off by default in the corrected harness. Native capture verifies the +actual client ID, coordinates, lifetime and viewability; it fails on mismatched +pixels/missing markers and keeps unique files rather than relying on a montage. + +## Reproduction and retained evidence + +See [diagnostic tool instructions](../tests/diagnostics/README.md) for dependencies, +phase filters and individual commands. Commands used for the final native pair: + +```sh +python3 tests/diagnostics/prepare_presentation.py +for mode in demand continuous; do + presentation_flags="" + if test "$mode" = continuous; then presentation_flags="--continuous"; fi + env -u WAYLAND_DISPLAY -u WAYLAND_SOCKET XDG_SESSION_TYPE=x11 \ + LIBGL_ALWAYS_SOFTWARE=1 xvfb-run -a -s '-screen 0 1280x1024x24' \ + python3 tests/diagnostics/presentation_capture.py \ + --program build/presentation-diagnostics/probe/combobox \ + --out "build/presentation-diagnostics/final-$mode" $presentation_flags +done +``` + +For the eight-way matrix, additionally select `--control` with `probe/control`, +`--continuous` for continuous mode, and `--readbacks` for GL-observer runs. To +isolate reads, add `--read-phase PHASE`. Output directories must be fresh. + +Local evidence is retained under `build/presentation-diagnostics/`: +`matrix-*/report.json`, per-scene native PNGs, GL PPMs, `trace.log`, `phase-*`, +`final-*`, and desktop comparison logs/captures. Preliminary runs are separate +from the final comparisons. Python byte-compilation and warning-enabled probe +compilation pass. No package/feature checks were repeated unnecessarily. + +## Limits and next experiment + +The exact original all-black root symptom is unresolved, not proven harmless. +If it recurs, run the native-only harness without additional GL reads and retain +its first failing client/root images, native IDs, JSON and trace **before any +refresh or repaint**. Then reproduce that same capture order on the minimal +control. That is the missing observation required to attribute the original +symptom. Windows and macOS are outside this investigation. diff --git a/docs/usage.md b/docs/usage.md index e480600..822e41a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -166,3 +166,166 @@ the last key, button, or text event. Single-frame edges can be brief; use `--log-events` to record every transition, including presses and releases within the same frame. Logs include event names, coordinates, actions, modifiers, Unicode code points, and resize sources. + +For redraw-on-demand/native capture diagnostics, see the +[presentation investigation](presentation-investigation.md) and its opt-in tools. + +## Overlays and modal dialogs + +`UIContext` owns overlays independently of the ordinary root. Overlay roots fill +its logical viewport; absolute child bounds use window coordinates. Overlays draw +after ordinary content and escape its clips, but remain clipped to the viewport. +Nonmodal overlays draw in opening order, below modal overlays; hit testing uses +the reverse order. `openOverlay(std::unique_ptr, OverlayOptions)` +accepts ordinary widget trees (`modal` defaults to false). + +```cpp +#include +#include + +auto dialog = std::make_unique("Settings", font); +auto& field = dialog->content().emplace(font, "My name"); +dialog->addAction("save", "Save"); +dialog->addCancelAction(); +auto handle = ui.openDialog(std::move(dialog), + [&, input = &field](gui::Dialog&, const gui::DialogResult& result) { + status.setText(result.action == "save" ? "Saved: " + input->text() + : "Cancelled"); + }); +// Programmatic cancellation, safe even if already closed: +// ui.closeOverlay(handle); +``` + +`content()` is a vertical container inside a scroll view. `actions()` accepts +existing buttons and containers; `addAction(id, label)` creates a button that +closes with that identifier. `addCancelAction(label)` closes with no action. +For custom actions, call `Dialog::close({actionId})` or `close()` to cancel. +Configure initial content and actions before transferring ownership. References +follow normal widget lifetime rules; do not retain them after completion. + +Dialog sheets center and resize with the viewport. Large content scrolls while +normal-size title and action areas remain outside the body scroll view. Actions +stack vertically when their preferred widths cannot fit. At very small sizes, +the whole sheet scrolls as well, keeping its actions reachable. Theme fields +`dialogBackdrop`, `dialogPadding`, `dialogSpacing`, and `dialogMargin` supplement +the existing surface, border, text, corner-radius, and control focus styling. + +`setDismissOnEscape(false)` disables the default Escape cancellation. +`setDismissOnBackdropClick(true)` enables cancellation on a left-button press +outside the sheet; this is disabled by default. Escape is applied only after +widgets decline the event. Enter retains the focused control's behavior: a +text field submits without activating another action. Tab and Shift+Tab are +reserved for traversal within the top visible modal, skipping hidden, disabled, +and non-focusable descendants. A childless modal still blocks background input. + +Opening a modal cancels background hover, presses, and capture, and saves the +previous focus. Closing restores it only when still attached, visible, enabled, +focusable, and allowed by the remaining modal. Otherwise the first eligible +widget in tree order receives focus, or focus clears. Opening another dialog +stacks it above the first; closing it returns control to the lower dialog. +Closing a lower dialog leaves the upper dialog active. Hidden modal roots +suspend their boundary; disabled visible modal roots continue to block input. +Window focus loss cancels interaction; regaining focus enters the active modal. + +Blocked pointer, wheel, key, and text events return handled, so application +shortcuts following `handleEvent()` do not run. Window lifecycle events remain +available to the application. Buttons and keys held across a boundary change +have their remaining repeats/releases swallowed, preventing dismissal click-through. +Consuming input solely at the modal boundary does not request redraw. Settled +dialogs need no continuous frame source. + +`OverlayHandle` is copyable and non-owning; dropping it does not close anything. +`closeOverlay(handle)` returns true only for the first accepted closure. +Default, stale, foreign-context, and already-closed handles return false; +`isOverlayOpen(handle)` queries the same ownership identity safely. + +Completion receives a `DialogResult` whose optional `action` identifies the +selected action; an empty value means cancellation, including programmatic, +Escape, backdrop, and Cancel-button closure. During dispatch, closure cancels +interaction immediately, then waits until the event route has unwound before +running completion. The completed dialog and its remaining children stay alive +through that callback, so reading the text field above is safe. Completion may +open or close other dialogs. It runs exactly once, including if it throws; +exceptions propagate after lifecycle cleanup. Destroying a `UIContext` discards +open dialogs and callbacks **without invoking application callbacks**. + +Try `MyGUI_dialogs` for confirmation, editable settings, nested dialogs, and +background controls that remain inactive while a modal is open. + +## Anchored popups and ComboBox + +`gui::ComboBox` is a non-editable selection control. Identifiers are unique opaque +strings; labels can repeat. Disabled items cannot be selected. New controls start +unselected and display their placeholder. + +```cpp +#include +auto& choice = ui.root().emplace(font); +choice.setItems({{"fast", "Fast"}, {"slow", "Slow"}, {"off", "Unavailable", false}}); +choice.setPlaceholder("Choose a mode…"); +choice.setSelectedId("fast"); // Programmatic: no notification. +choice.setOnChange([&](const gui::ComboBoxItem& item) { + status.setText("Selected: " + item.label); +}); +``` + +`selectedId()` returns an optional string value; `setSelectedId(std::nullopt)` +clears it. Unknown or disabled identifiers return false without changing state. +Valid programmatic selection requests close the popup silently. `setItems()` +closes the popup and preserves selection only if its identifier still names an +enabled item. Duplicate identifiers throw `std::invalid_argument` before changing +anything. Empty and all-disabled lists never open. Labels are sanitized to valid +UTF-8 using the existing font helpers. + +Click, Enter, Space, or Down opens the list. The opening gesture cannot commit. +Up/Down skip disabled items and stop at the ends; Home/End highlight the first/last +enabled item. Hover changes only the highlight. Enter or a completed click on an +enabled row commits; committing a different identifier notifies once, while +committing the same identifier closes silently. Escape and outside presses +cancel. Tab/Shift+Tab cancel and traverse focus once. Logical focus remains on the +ComboBox; rows are not Tab stops. Keyboard navigation scrolls the candidate into +view. Wheel events over the list never scroll content underneath, even at a limit. + +Type-ahead works only while open. Printable `TextInputEvent`s accumulate a prefix +with a 750 ms timeout. Matching chooses the first enabled label prefix in item +order, ignores ASCII case, and compares other Unicode code points exactly (no +Unicode normalization or case folding). A failed match keeps the highlight. +Repeated letters extend the prefix rather than cycling. The timeout is checked +on the next text event and never requires continuous redraw. The constructor's +optional third `TimeSource` argument supplies a `steady_clock::time_point` getter +for deterministic testing; ordinary applications use the default steady clock. + +For reusable hosting, construct `gui::Popup`, populate its `content()` container, +and call `ui.openPopup(owner, std::move(popup), PopupOptions{})`. The context owns +it and returns the existing context-specific `OverlayHandle`. `maxHeight` defaults +to 240 logical units and `gap` to 2. Roots are placed below their owner when they +fit, or above when that provides more room. Width is at least the owner's width +and expands toward content preference, capped to the viewport. Height is capped +by available space and `maxHeight`; overflow uses the existing ScrollView. +Placement updates after layout, resizing, and ancestor scrolling, outside ancestor +clips but within the viewport. Nonpositive usable area prevents opening. + +One transient popup is active per context; another eligible opening replaces it. +An unavailable, foreign-context, fully clipped, or modal-blocked owner returns an +empty handle without replacing an existing popup. Popup descendants cannot own +another popup. Popups close when their owner becomes unavailable or fully clipped, +on focus loss, or when focus moves away from the owner. Generic hosts preserve +logical focus; ComboBox explicitly focuses itself. Popup children receive pointer +input and capture without taking keyboard focus. Handle closure is idempotent. +There is no popup completion callback and no teardown notification. + +A popup inside a modal draws above that dialog and shares its interaction boundary. +Opening a newer modal closes the popup in the blocked scope. Escape closes only +the popup; its repeats/release are consumed, so another Escape is needed to dismiss +the dialog. Outside presses are consumed with their matching releases; a fresh +click works normally. Keyboard/text input while the list is open cannot fall +through to application shortcuts. Wheel input outside the popup may reach other +permitted content, repositioning or closing the popup as its anchor moves. + +Callbacks receive a copied item payload valid through the call. Selection and +closure finish before notification, allowing callbacks to remove the control or +its parent, replace items, or open a dialog or another popup. Do not retain item +references beyond the callback or widget references after removal. Copies of a +ComboBox retain configuration and committed selection, never an open popup or +preview state. `MyGUI_combobox` demonstrates long lists, disabled options, modal +ownership, and edge placement. diff --git a/examples/combobox.cpp b/examples/combobox.cpp new file mode 100644 index 0000000..3e3e86a --- /dev/null +++ b/examples/combobox.cpp @@ -0,0 +1,39 @@ +#include "Example.hpp" +#include "gui/ComboBox.hpp" +#include +namespace { +class Page : public gui::Container { +public: + gui::Container& main=emplace(); + gui::ComboBox& edge=emplace(); + void arrange() override { + const auto b=bounds(); main.setBounds({20,20,std::max(0.0f,b.width-40),std::max(0.0f,b.height-110)}); + edge.setBounds({std::max(0.0f,b.width-200),std::max(0.0f,b.height-52),180,32}); + Container::arrange(); + } +}; +} +int main(int argc,char** argv) {try { + const auto font=example::loadFont(argv[0]); + gui::Window window{640,520,"MyGUI ComboBox"}; window.setSwapInterval(1); + gui::Renderer renderer{window.glProcLoader()}; + auto page=std::make_unique(); auto* p=page.get(); gui::UIContext ui{std::move(page)}; + p->setBackgroundColor(ui.theme().background); + auto& main=p->main; main.setLayout(gui::Layout::Vertical); main.setSpacing(12); main.setBackgroundColor({0,0,0,0}); + auto& status=main.emplace("Choose an option (arrows, Enter, Escape, or type a prefix).",font); + const std::vector choices{{"red","Red"},{"off","Unavailable",false},{"blue","Blue"},{"other","Blue"}}; + auto& normal=main.emplace(font,choices); normal.setPlaceholder("Choose a color…"); + normal.setOnChange([&](const auto& item){status.setText("Selected: "+item.label+" ["+item.id+"]");}); + std::vector many; + for(int i=1;i<=60;++i) many.push_back({std::to_string(i),"Option "+std::to_string(i),i%7!=0}); + auto& longList=main.emplace(font,many); longList.setPlaceholder("Long scrolling list"); + p->edge.setFont(font); p->edge.setItems(many); p->edge.setPlaceholder("Bottom/right edge"); + main.emplace("Open dialog",font).setOnClick([&]{ + auto dialog=std::make_unique("Modal ComboBox",font); + dialog->content().emplace(font,choices); + dialog->addCancelAction("Close"); ui.openDialog(std::move(dialog)); + }); + int clicks=0; + main.emplace("Outside click counter",font).setOnClick([&]{status.setText("Outside clicks: "+std::to_string(++clicks));}); + example::run(window,renderer,ui,example::frameLimit(argc,argv)); +} catch(const std::exception& e){std::cerr< + +int main(int argc, char** argv) { + try { + const auto font = example::loadFont(argv[0]); + gui::Window window{640, 480, "MyGUI dialogs"}; + window.setSwapInterval(1); + gui::Renderer renderer{window.glProcLoader()}; + gui::UIContext ui; + auto& root = ui.root(); root.setLayout(gui::Layout::Vertical); + root.setPadding({24,24,24,24}); root.setSpacing(12); + root.setBackgroundColor(ui.theme().background); + auto& status = root.emplace("Open a dialog; background controls pause.", font); + int count = 0; + root.emplace("Background counter", font).setOnClick([&] { + status.setText("Background clicks: " + std::to_string(++count)); + }); + root.emplace(); + root.emplace("Confirmation", font).setOnClick([&] { + auto dialog = std::make_unique("Confirm", font); + dialog->content().emplace("Continue with this operation?", font); + dialog->addAction("confirm", "Confirm"); dialog->addCancelAction(); + ui.openDialog(std::move(dialog), [&](gui::Dialog&, const gui::DialogResult& result) { + status.setText(result.action ? "Confirmed" : "Cancelled"); + }); + }); + root.emplace("Settings", font).setOnClick([&] { + auto dialog = std::make_unique("Settings", font); + dialog->content().setSpacing(12); + dialog->content().emplace("Name (selection and clipboard work here)", font); + auto& name = dialog->content().emplace(font, "MyGUI"); + dialog->addAction("save", "Save"); dialog->addCancelAction(); + dialog->content().emplace("Open nested confirmation", font).setOnClick([&] { + auto nested = std::make_unique("Nested confirmation", font); + nested->content().emplace("Closing returns to Settings.", font); + nested->addAction("confirm", "Confirm"); nested->addCancelAction(); + ui.openDialog(std::move(nested)); + }); + ui.openDialog(std::move(dialog), [&, field = &name](gui::Dialog&, const gui::DialogResult& result) { + status.setText(result.action == "save" ? "Saved: " + field->text() : "Settings cancelled"); + }); + }); + example::run(window, renderer, ui, example::frameLimit(argc, argv)); + } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } +} diff --git a/include/gui/ComboBox.hpp b/include/gui/ComboBox.hpp new file mode 100644 index 0000000..146c66c --- /dev/null +++ b/include/gui/ComboBox.hpp @@ -0,0 +1,47 @@ +#pragma once +#include "gui/Button.hpp" +#include "gui/Overlay.hpp" +#include +#include +namespace gui { +struct ComboBoxItem { std::string id, label; bool enabled{true}; }; +class ComboBox : public Button { +public: + using TimeSource=std::function; + explicit ComboBox(std::shared_ptr font=nullptr, std::vector items={}, TimeSource clock={}); + ComboBox(const ComboBox&); + ComboBox& operator=(const ComboBox&); + void setItems(std::vector items); + const std::vector& items() const noexcept { return items_; } + bool setSelectedId(std::optional id); + std::optional selectedId() const { return selected_; } + void setPlaceholder(std::string text); + const std::string& placeholder() const noexcept { return placeholder_; } + void setOnChange(std::function callback) { onChange_=std::move(callback); } + bool openPopup(); + bool closePopup(); + bool popupOpen() const noexcept; + std::optional highlightedId() const; + bool onEvent(const Event&,UIContext&) override; + void draw(Renderer&) const override; +private: + class Row; + using Button::setOnClick; + using Label::setText; + void activate() override { openPopup(); } + void caption(); + void highlight(std::size_t); + void commit(std::size_t); + void search(char32_t); + std::vector items_; + std::optional selected_; + std::optional highlighted_; + std::string placeholder_{"Select…"}; + OverlayHandle popup_; + std::vector rows_; + std::function onChange_; + TimeSource clock_; + std::u32string prefix_; + std::chrono::steady_clock::time_point lastInput_{}; +}; +} diff --git a/include/gui/Dialog.hpp b/include/gui/Dialog.hpp new file mode 100644 index 0000000..d0c98ca --- /dev/null +++ b/include/gui/Dialog.hpp @@ -0,0 +1,35 @@ +#pragma once +#include "gui/Button.hpp" +#include "gui/ScrollView.hpp" +#include + +namespace gui { +struct DialogResult { std::optional action; }; +// A viewport-sized modal host; content() and actions() own user widgets. +class Dialog : public Container { +public: + explicit Dialog(std::string title, std::shared_ptr font = nullptr); + void setTitle(std::string title) { title_->setText(std::move(title)); } + Container& content() noexcept { return body_->content(); } + Container& actions() noexcept { return *actions_; } + Button& addAction(std::string id, std::string label); + Button& addCancelAction(std::string label = "Cancel"); + void setDismissOnEscape(bool value) noexcept { escape_ = value; } + void setDismissOnBackdropClick(bool value) noexcept { backdrop_ = value; } + bool close(DialogResult result = {}); + Rect sheetBounds() const noexcept { return sheet_->bounds(); } + void arrange() override; + void draw(Renderer&) const override; + bool onEvent(const Event&, UIContext&) override; +private: + friend class UIContext; + std::function close_; + std::shared_ptr font_; + ScrollView* outer_{}; + Container* sheet_{}; + Label* title_{}; + ScrollView* body_{}; + Container* actions_{}; + bool escape_{true}, backdrop_{false}; +}; +} // namespace gui diff --git a/include/gui/Overlay.hpp b/include/gui/Overlay.hpp new file mode 100644 index 0000000..5bafbde --- /dev/null +++ b/include/gui/Overlay.hpp @@ -0,0 +1,14 @@ +#pragma once +#include +#include + +namespace gui { +class UIContext; +// Copyable, non-owning identity. Dropping a handle does not close its overlay. +class OverlayHandle { + friend class UIContext; + std::weak_ptr owner_; + std::uint64_t id_{}; +}; +struct OverlayOptions { bool modal{false}; }; +} // namespace gui diff --git a/include/gui/Popup.hpp b/include/gui/Popup.hpp new file mode 100644 index 0000000..93044fb --- /dev/null +++ b/include/gui/Popup.hpp @@ -0,0 +1,13 @@ +#pragma once +#include "gui/ScrollView.hpp" +namespace gui { +struct PopupOptions { float maxHeight{240}; float gap{2}; }; +// Owned by UIContext; populate content() before opening. +class Popup : public ScrollView { +public: + Popup(); + Size preferredSize() const noexcept override; + void draw(Renderer&) const override; + bool onEvent(const Event&, UIContext&) override; +}; +} diff --git a/include/gui/Theme.hpp b/include/gui/Theme.hpp index ee76dff..e31b400 100644 --- a/include/gui/Theme.hpp +++ b/include/gui/Theme.hpp @@ -23,5 +23,7 @@ struct Theme { Color selection{0.36f, 0.62f, 0.95f, 0.38f}; float cornerRadius{6}; float focusWidth{2}; + Color dialogBackdrop{0, 0, 0, 0.5f}; + float dialogPadding{16}, dialogSpacing{12}, dialogMargin{16}; }; } // namespace gui diff --git a/include/gui/UIContext.hpp b/include/gui/UIContext.hpp index a6f2273..994820a 100644 --- a/include/gui/UIContext.hpp +++ b/include/gui/UIContext.hpp @@ -1,6 +1,10 @@ #pragma once #include "gui/Clipboard.hpp" +#include "gui/Dialog.hpp" +#include "gui/Overlay.hpp" +#include "gui/Popup.hpp" +#include #include "gui/Container.hpp" #include "gui/Event.hpp" #include "gui/Theme.hpp" @@ -26,6 +30,12 @@ class UIContext { // copy and paste in text widgets. void setClipboard(Clipboard* clipboard) noexcept { clipboard_ = clipboard; } Clipboard* clipboard() const noexcept { return clipboard_; } + OverlayHandle openOverlay(std::unique_ptr overlay, OverlayOptions options = {}); + OverlayHandle openDialog(std::unique_ptr dialog, + std::function completion = {}); + OverlayHandle openPopup(Widget& owner, std::unique_ptr, PopupOptions = {}); + bool closeOverlay(OverlayHandle handle); + bool isOverlayOpen(OverlayHandle handle) const noexcept; void layout(const Viewport& viewport); void draw(Renderer& renderer); bool handleEvent(const Event& event); @@ -59,6 +69,38 @@ class UIContext { void updateHover(); void ensureLayout(); void focusOrder(Widget& widget, std::vector& order); + struct Overlay { + OverlayHandle handle; + std::unique_ptr tree; + bool modal{}, closing{}; + Widget* savedFocus{}; + bool popup{}; + Widget* owner{}; + std::uint64_t scope{}; + PopupOptions placement; + DialogResult result; + std::function completion; + }; + Overlay* activeModal() const noexcept; + Overlay* activePopup() const noexcept; + Rect visibleAnchor(const Widget&) const noexcept; + bool placePopup(Overlay&); + void cancelPopup() noexcept; + void suppressHeld() noexcept; + bool permitted(const Widget*) const noexcept; + Widget* pointerTarget(); + void scopeFocus(Widget* preferred = nullptr); + void reconcileModal(); + void cancelBlocked() noexcept; + bool closeEntry(OverlayHandle, DialogResult); + void settle(); + void traversal(std::vector&); + std::shared_ptr identity_{std::make_shared(0)}; + std::uint64_t nextOverlay_{1}, modalId_{}; + std::vector> overlays_; + bool settling_{false}, modalDuringDispatch_{false}; + std::array heldButtons_{}, suppressedButtons_{}; + std::array(Key::Menu)+1> heldKeys_{}, suppressedKeys_{}; std::unique_ptr root_; Theme theme_{}; Clipboard* clipboard_{nullptr}; diff --git a/include/gui/Widget.hpp b/include/gui/Widget.hpp index 39de365..90c4588 100644 --- a/include/gui/Widget.hpp +++ b/include/gui/Widget.hpp @@ -39,6 +39,7 @@ class Widget { float flex() const noexcept { return flex_; } protected: + UIContext* attachedContext() const noexcept { return context_; } Widget() = default; explicit Widget(const Rect& bounds) noexcept; // Copies retain visual properties, never attachment or interaction state. diff --git a/src/ComboBox.cpp b/src/ComboBox.cpp new file mode 100644 index 0000000..9b96775 --- /dev/null +++ b/src/ComboBox.cpp @@ -0,0 +1,137 @@ +#include "gui/ComboBox.hpp" +#include "gui/UIContext.hpp" +#include "gui/Renderer.hpp" +#include +#include +#include +namespace gui { +class ComboBox::Row : public Button { +public: + Row(ComboBox& owner,std::size_t index) : Button(owner.items_[index].label,owner.font()),owner_{owner},index_{index} { + setFontSize(owner.fontSize()); setFocusable(false); setEnabled(owner.items_[index].enabled); + setAlignment(HorizontalAlign::Left,VerticalAlign::Center); + } + bool marked{}; + void draw(Renderer& renderer) const override { + if (!visible()) return; + if (marked) renderer.drawRoundedRect(bounds(),theme().cornerRadius,theme().accentHovered); + const auto b=bounds(); drawText(renderer,enabled() ? theme().text : theme().disabledText,{b.x+8,b.y,std::max(0.0f,b.width-16),b.height}); + } + bool onEvent(const Event& event,UIContext& context) override { + if (enabled() && event.getIf()) owner_.highlight(index_); + return Button::onEvent(event,context); + } +private: + void activate() override { owner_.commit(index_); } + ComboBox& owner_; + std::size_t index_; +}; +ComboBox::ComboBox(std::shared_ptr font,std::vector items,TimeSource clock) + : Button("",std::move(font)),clock_{clock ? std::move(clock) : TimeSource{std::chrono::steady_clock::now}} { + setAlignment(HorizontalAlign::Left,VerticalAlign::Center); setMinimumSize({120,32}); setItems(std::move(items)); +} +ComboBox::ComboBox(const ComboBox& other) : Button(other),items_{other.items_},selected_{other.selected_},placeholder_{other.placeholder_},onChange_{other.onChange_},clock_{other.clock_} {} +ComboBox& ComboBox::operator=(const ComboBox& other) { + if (this!=&other) { + auto copy=other; + closePopup(); Button::operator=(copy); + items_=std::move(copy.items_); selected_=std::move(copy.selected_); placeholder_=std::move(copy.placeholder_); + onChange_=std::move(copy.onChange_); clock_=std::move(copy.clock_); + rows_.clear(); highlighted_.reset(); prefix_.clear(); popup_={}; + } + return *this; +} +bool ComboBox::popupOpen() const noexcept { return attachedContext() && attachedContext()->isOverlayOpen(popup_); } +bool ComboBox::closePopup() { return attachedContext() && attachedContext()->closeOverlay(popup_); } +void ComboBox::caption() { + const auto it=std::find_if(items_.begin(),items_.end(),[&](const auto& item){return selected_ && item.id==*selected_;}); + Label::setText(it==items_.end() ? placeholder_ : it->label); +} +void ComboBox::setPlaceholder(std::string text) { placeholder_=sanitizeUtf8(text); caption(); } +void ComboBox::setItems(std::vector items) { + std::unordered_set ids; + for (auto& item:items) { + if (!ids.insert(item.id).second) throw std::invalid_argument("ComboBox identifiers must be unique"); + item.label=sanitizeUtf8(item.label); + } + closePopup(); items_=std::move(items); rows_.clear(); highlighted_.reset(); prefix_.clear(); + if (selected_ && std::none_of(items_.begin(),items_.end(),[&](const auto& i){return i.enabled && i.id==*selected_;})) selected_.reset(); + caption(); +} +bool ComboBox::setSelectedId(std::optional id) { + if (id && std::none_of(items_.begin(),items_.end(),[&](const auto& i){return i.enabled && i.id==*id;})) return false; + closePopup(); selected_=std::move(id); caption(); return true; +} +std::optional ComboBox::highlightedId() const { + return popupOpen() && highlighted_ ? std::optional{items_[*highlighted_].id} : std::nullopt; +} +bool ComboBox::openPopup() { + if (popupOpen()) return true; + auto* context=attachedContext(); + if (!context || std::none_of(items_.begin(),items_.end(),[](const auto& i){return i.enabled;})) return false; + auto popup=std::make_unique(); + rows_.clear(); prefix_.clear(); highlighted_.reset(); + for (std::size_t i=0;icontent().emplace(*this,i)); + popup_=context->openPopup(*this,std::move(popup)); + if (!popupOpen()) { rows_.clear(); return false; } + context->requestFocus(this); + auto it=std::find_if(items_.begin(),items_.end(),[&](const auto& i){return i.enabled && (!selected_ || i.id==*selected_);}); + highlight(static_cast(it-items_.begin())); + return true; +} +void ComboBox::highlight(std::size_t index) { + if (!popupOpen() || index>=items_.size() || !items_[index].enabled || highlighted_==index) return; + if (highlighted_) rows_[*highlighted_]->marked=false; + highlighted_=index; rows_[index]->marked=true; + attachedContext()->requestRedraw(); attachedContext()->reveal(*rows_[index]); +} +void ComboBox::commit(std::size_t index) { + if (!popupOpen() || index>=items_.size() || !items_[index].enabled) return; + const auto item=items_[index]; const auto callback=onChange_; + const bool changed=selected_!=item.id; + selected_=item.id; caption(); closePopup(); + if (changed && callback) callback(item); +} +void ComboBox::search(char32_t codepoint) { + if (codepoint<32 || (codepoint>=127 && codepoint<160)) return; + const auto now=clock_(); + if (now=std::chrono::milliseconds(750)) prefix_.clear(); + lastInput_=now; + std::string encoded; appendUtf8(encoded,codepoint); prefix_+=decodeUtf8(encoded); + const auto fold=[](char32_t c){return c>=U'A' && c<=U'Z' ? c+32 : c;}; + for (std::size_t i=0;i=prefix_.size() && std::equal(prefix_.begin(),prefix_.end(),label.begin(),[&](auto a,auto b){return fold(a)==fold(b);})) { highlight(i); return; } + } +} +bool ComboBox::onEvent(const Event& event,UIContext& context) { + if (popupOpen()) { + if (const auto* text=event.getIf()) { search(text->codepoint); return false; } + if (const auto* key=event.getIf()) { + if (key->action!=KeyAction::Release) { + if (key->key==Key::Enter && key->action==KeyAction::Press && highlighted_) { commit(*highlighted_); return true; } + if (key->key==Key::Home || key->key==Key::End || key->key==Key::Up || key->key==Key::Down) { + const bool forward=key->key==Key::Home || key->key==Key::Down; + int i=key->key==Key::Home ? 0 : key->key==Key::End ? static_cast(items_.size())-1 : static_cast(*highlighted_)+(forward ? 1 : -1); + for (;i>=0 && i(items_.size());i+=forward ? 1 : -1) + if (items_[static_cast(i)].enabled) { highlight(static_cast(i)); break; } + } + } + return false; + } + } else if (const auto* key=event.getIf(); key && focused() && key->action==KeyAction::Press + && !key->mods.control && !key->mods.alt && !key->mods.super + && (key->key==Key::Enter || key->key==Key::Space || key->key==Key::Down)) { openPopup(); return true; } + return Button::onEvent(event,context); +} +void ComboBox::draw(Renderer& renderer) const { + if (!visible()) return; + const auto b=bounds(); const auto& t=theme(); + renderer.drawRoundedRect(b,t.cornerRadius,!enabled() ? t.disabled : hovered() || popupOpen() ? t.accentHovered : t.track); + renderer.drawBorder(b,focused() ? t.focusWidth : 1,focused() ? t.focus : t.border,t.cornerRadius); + drawText(renderer,!enabled() ? t.disabledText : selected_ ? t.text : t.mutedText,{b.x+8,b.y,std::max(0.0f,b.width-32),b.height}); + const float x=b.x+b.width-14,y=b.y+b.height/2; + renderer.drawLine({x-4,y-2},{x,y+2},1.5f,t.text); renderer.drawLine({x,y+2},{x+4,y-2},1.5f,t.text); +} +} diff --git a/src/Dialog.cpp b/src/Dialog.cpp new file mode 100644 index 0000000..ec428f0 --- /dev/null +++ b/src/Dialog.cpp @@ -0,0 +1,93 @@ +#include "gui/Dialog.hpp" +#include "gui/Event.hpp" +#include "gui/Renderer.hpp" +#include "gui/Theme.hpp" +#include + +namespace gui { +namespace { +class DialogTitle : public Label { +public: + using Label::Label; + void draw(Renderer& renderer) const override { if (visible()) drawText(renderer, theme().text); } +}; +} +Dialog::Dialog(std::string title, std::shared_ptr font) : font_{std::move(font)} { + setBackgroundColor({0,0,0,0}); + setPreferredSize({480,0}); + outer_ = &emplace(); + outer_->setBackgroundColor({0,0,0,0}); + outer_->setHorizontalScrolling(true); + sheet_ = &outer_->content(); + sheet_->setLayout(Layout::Absolute); + title_ = &sheet_->emplace(std::move(title), font_); + title_->setFontSize(20); title_->setWrap(true); + body_ = &sheet_->emplace(); + body_->setBackgroundColor({0,0,0,0}); + body_->setHorizontalScrolling(true); + actions_ = &sheet_->emplace(); + actions_->setBackgroundColor({0,0,0,0}); +} +Button& Dialog::addAction(std::string id, std::string label) { + auto& button = actions_->emplace