Widgets: full widget set and node editor as a components library #13
225 changed files with 43611 additions and 3156 deletions
|
|
@ -1,17 +1,67 @@
|
|||
project(Widgets)
|
||||
|
||||
### ---------------------- Static Library --------------------- ###
|
||||
file(GLOB SOURCES "./private/*.cpp" "./private/*/*.cpp")
|
||||
file(GLOB HEADERS "./public/*.hpp")
|
||||
function(widgets_library TARGET_NAME LIBRARY_DIR)
|
||||
set(DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/${LIBRARY_DIR})
|
||||
file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS ${DIR}/private/*.cpp ${DIR}/private/*.hpp ${DIR}/public/*.hpp)
|
||||
add_library(${TARGET_NAME} STATIC ${SOURCES})
|
||||
target_include_directories(${TARGET_NAME} PUBLIC ${DIR}/public)
|
||||
target_include_directories(${TARGET_NAME} PRIVATE ${DIR}/private)
|
||||
endfunction()
|
||||
|
||||
add_library(${PROJECT_NAME} STATIC ${SOURCES} ${HEADERS})
|
||||
target_include_directories(${PROJECT_NAME} PUBLIC ./public/ ./public/layouts ./public/mangers ./public/widgets)
|
||||
target_link_libraries(${PROJECT_NAME} PUBLIC Math GraphicApplication Imgui)
|
||||
### ---------------------- Core --------------------- ###
|
||||
widgets_library(${PROJECT_NAME}Core core)
|
||||
target_link_libraries(${PROJECT_NAME}Core PUBLIC Math GraphicApplication)
|
||||
|
||||
### -------------------------- Applications -------------------------- ###
|
||||
### ---------------------- Layouts --------------------- ###
|
||||
widgets_library(${PROJECT_NAME}Layouts layouts)
|
||||
target_link_libraries(${PROJECT_NAME}Layouts PUBLIC ${PROJECT_NAME}Core)
|
||||
|
||||
add_executable(WidgetsExample examples/Example.cpp)
|
||||
target_link_libraries(WidgetsExample ${PROJECT_NAME} ${GLEW_LIB})
|
||||
graphic_application_resources(WidgetsExample)
|
||||
target_include_directories(WidgetsExample PUBLIC ../Externals/glfw/include ${GLEW_INCLUDE_DIR})
|
||||
### ---------------------- Components (all widgets + nodes, by category) --------------------- ###
|
||||
file(GLOB_RECURSE COMPONENT_SOURCES CONFIGURE_DEPENDS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/components/*/private/*.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/components/*/private/*.hpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/components/*/public/*.hpp)
|
||||
file(GLOB COMPONENT_PUBLIC_DIRS LIST_DIRECTORIES true ${CMAKE_CURRENT_SOURCE_DIR}/src/components/*/public)
|
||||
file(GLOB COMPONENT_PRIVATE_DIRS LIST_DIRECTORIES true ${CMAKE_CURRENT_SOURCE_DIR}/src/components/*/private)
|
||||
add_library(${PROJECT_NAME}Components STATIC ${COMPONENT_SOURCES})
|
||||
target_include_directories(${PROJECT_NAME}Components PUBLIC ${COMPONENT_PUBLIC_DIRS})
|
||||
target_include_directories(${PROJECT_NAME}Components PRIVATE ${COMPONENT_PRIVATE_DIRS})
|
||||
target_link_libraries(${PROJECT_NAME}Components PUBLIC ${PROJECT_NAME}Core ${PROJECT_NAME}Layouts Imgui PRIVATE Md4c)
|
||||
|
||||
### ---------------------- Window application adapter --------------------- ###
|
||||
widgets_library(${PROJECT_NAME}App app)
|
||||
target_link_libraries(${PROJECT_NAME}App PUBLIC ${PROJECT_NAME}Components GraphicApplication Imgui)
|
||||
|
||||
### ---------------------- Umbrella --------------------- ###
|
||||
add_library(${PROJECT_NAME} INTERFACE)
|
||||
target_link_libraries(${PROJECT_NAME} INTERFACE
|
||||
${PROJECT_NAME}Core ${PROJECT_NAME}Layouts ${PROJECT_NAME}App ${PROJECT_NAME}Components)
|
||||
|
||||
### -------------------------- Gallery -------------------------- ###
|
||||
add_library(${PROJECT_NAME}Gallery STATIC ./examples/Gallery.cpp ./examples/Gallery.hpp ./examples/ThemeEditor.cpp ./examples/ThemeEditor.hpp)
|
||||
target_include_directories(${PROJECT_NAME}Gallery PUBLIC ./examples)
|
||||
target_link_libraries(${PROJECT_NAME}Gallery PUBLIC ${PROJECT_NAME}Components)
|
||||
|
||||
if (NOT ANDROID)
|
||||
add_executable(${PROJECT_NAME}Example ./examples/GalleryApplication.cpp)
|
||||
target_link_libraries(${PROJECT_NAME}Example ${PROJECT_NAME}Gallery ${PROJECT_NAME}App)
|
||||
endif ()
|
||||
|
||||
### -------------------------- Tests -------------------------- ###
|
||||
file(GLOB TEST_SOURCES CONFIGURE_DEPENDS "./tests/*.cpp")
|
||||
add_executable(Test${PROJECT_NAME} ${TEST_SOURCES})
|
||||
target_link_libraries(Test${PROJECT_NAME} ${PROJECT_NAME}Core ${PROJECT_NAME}Layouts UnitTest++)
|
||||
add_test(NAME Test${PROJECT_NAME} COMMAND Test${PROJECT_NAME})
|
||||
|
||||
if (NOT ANDROID)
|
||||
# Component tests live with the components (src/components/tests).
|
||||
file(GLOB COMPONENT_TEST_SOURCES CONFIGURE_DEPENDS "./src/components/tests/*.cpp")
|
||||
file(GLOB TEST_SUPPORT_SOURCES CONFIGURE_DEPENDS "./src/components/tests/support/*.cpp")
|
||||
# Note: these two need a live Application (the SessionRecorder is Application-coupled); re-enable once that is testable headless.
|
||||
list(FILTER COMPONENT_TEST_SOURCES EXCLUDE REGEX "SessionRecordingControlTests.cpp")
|
||||
list(FILTER COMPONENT_TEST_SOURCES EXCLUDE REGEX "DebugToolsTests.cpp")
|
||||
add_executable(Test${PROJECT_NAME}Components ${COMPONENT_TEST_SOURCES} ${TEST_SUPPORT_SOURCES} ./tests/Main.cpp)
|
||||
target_include_directories(Test${PROJECT_NAME}Components PRIVATE ./src/components/tests ./src/components/tests/support)
|
||||
target_link_libraries(Test${PROJECT_NAME}Components ${PROJECT_NAME}Components ${PROJECT_NAME}Gallery ${PROJECT_NAME}App UnitTest++)
|
||||
add_test(NAME Test${PROJECT_NAME}Components COMMAND Test${PROJECT_NAME}Components)
|
||||
endif ()
|
||||
|
|
|
|||
453
Widgets/docs/ARCHITECTURE.md
Normal file
453
Widgets/docs/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
# Widgets
|
||||
|
||||
A small retained-mode UI library: minimal, flexible, headless-testable.
|
||||
|
||||
- `Widgets` — the library. Depends on `Math` only (plus the `InputCodes` /
|
||||
`Rect` / `Color` headers). No window, no GPU.
|
||||
- `WidgetsApp` — the adapter that runs a `Ui` inside a `Graphics` window:
|
||||
`CanvasPainter`, `InputTranslator`, `WidgetApplication`.
|
||||
- `WidgetsGallery` / `WidgetsExample` — a gallery of every widget, used both
|
||||
as a demo application and as an end-to-end test subject.
|
||||
- `TestWidgets` — headless tests.
|
||||
|
||||
## Five concepts
|
||||
|
||||
| Concept | Responsibility |
|
||||
|---|---|
|
||||
| `Widget` | A node: children, sizing request, assigned rect, state, hooks |
|
||||
| `Layout` | Optional strategy on a container: measure children, then arrange them |
|
||||
| `Event` | A value delivered to widgets in their local coordinates; bubbles to ancestors until accepted |
|
||||
| `Popup` | A widget shown above the tree, anchored to a rect, with placement and dismissal rules |
|
||||
| `Ui` | The context: owns layout, input routing, popups, ticking, tooltips, stats |
|
||||
|
||||
## Frame
|
||||
|
||||
```
|
||||
ui.dispatch(event) input: hit test, hover diff, capture, click synthesis, bubbling, popup dismissal
|
||||
ui.advance(deltaMs) ticks: widget animations, geometry springs, tooltip dwell
|
||||
ui.updateLayout() only if something requested layout: measure (cached) + arrange (skips clean subtrees)
|
||||
ui.paint(painter) tree, then popups, each subtree translated and clipped
|
||||
ui.needsFrame() true while layout/paint is pending, something animates, or a tooltip is pending
|
||||
```
|
||||
|
||||
Layout always runs before an event is routed and after it is handled, so
|
||||
handlers and hit testing never see stale geometry.
|
||||
|
||||
## Writing a widget
|
||||
|
||||
A custom widget overrides only what it needs:
|
||||
|
||||
```cpp
|
||||
class Swatch : public ui::Widget {
|
||||
public:
|
||||
explicit Swatch(RGBA color) : mColor(color) {}
|
||||
|
||||
protected:
|
||||
ui::Extent measureContent(ui::Axis, halnf) const override { return ui::Extent::flexible(24); }
|
||||
void paint(ui::Painter& painter) override { painter.fillRect(localRect(), mColor, 4); }
|
||||
void onEvent(ui::Event& event) override {
|
||||
if (event.type() == ui::Event::Type::Click) event.accept();
|
||||
}
|
||||
|
||||
private:
|
||||
RGBA mColor;
|
||||
};
|
||||
```
|
||||
|
||||
Hooks: `measureContent`, `onEvent`, `onTick`, `paint`, `paintOverlay`,
|
||||
`hitTest`, `acceptsKeyboardFocus`, `onHoverChanged`, `onAttached`,
|
||||
`onDetached`. Requests: `requestLayout`, `requestPaint`, `startTicking`,
|
||||
`stopTicking`. Every built-in widget is written against exactly this API.
|
||||
|
||||
## Sizing
|
||||
|
||||
Each axis has one `AxisSizing`:
|
||||
|
||||
| Mode | Meaning |
|
||||
|---|---|
|
||||
| `fit()` | Preferred size comes from content (text, icon, children) |
|
||||
| `fixed(length)` | Exactly this length |
|
||||
| `fill(weight)` | Content size, then grows with `weight` share of leftover space |
|
||||
|
||||
Each can be bounded with `.atLeast(min)` and `.atMost(max)`. Resolution
|
||||
produces an `Extent { minimum, preferred, maximum, grow }` that layouts work
|
||||
with.
|
||||
|
||||
`measureContent` declares how compressible the content is:
|
||||
|
||||
| Extent | Use |
|
||||
|---|---|
|
||||
| `Extent::natural(length)` | Never smaller than its content: text, buttons, toggles, menu items |
|
||||
| `Extent::shrinkable(preferred, minimum)` | Can give up space down to `minimum`: slider tracks, separators, generic boxes |
|
||||
| `Extent::rigid(length)` | Exactly this length |
|
||||
|
||||
When a container overflows, space is taken back only from items with slack
|
||||
above their minimum. A `ScrollView` reports a zero minimum along its scroll
|
||||
axis, so a long list scrolls instead of squashing the controls around it.
|
||||
|
||||
## Algorithms
|
||||
|
||||
**Measure** (bottom-up, cached per axis and cross length): a leaf reports
|
||||
`measureContent`; a container asks its `Layout`; the widget's `AxisSizing`
|
||||
then resolves the result. The cache is invalidated up the ancestor chain by
|
||||
`requestLayout`.
|
||||
|
||||
**Distribute** (`distributeSpace`): start each item at its preferred length,
|
||||
then repeatedly give out free space by `grow` weight (or take it back
|
||||
proportionally to each item's slack above its minimum), clamping to
|
||||
`[minimum, maximum]`. Free space is recomputed from scratch every round, so
|
||||
the loop converges in at most `n + 1` rounds without epsilon tricks.
|
||||
|
||||
**Snap** (`placeSnapped`): edges are rounded, not lengths, so adjacent
|
||||
children always tile without gaps.
|
||||
|
||||
**Arrange** (top-down): a parent assigns each child a rect. A child whose
|
||||
rect is unchanged and which has no pending layout is skipped entirely.
|
||||
|
||||
**Popup placement** (`placePopup`): place on the requested side, flip to the
|
||||
opposite side if it doesn't fit, then slide inside the viewport.
|
||||
|
||||
**Hover**: diff the old and new root-to-target paths and notify only the
|
||||
widgets that entered or left.
|
||||
|
||||
## Layouts
|
||||
|
||||
| Layout | Use |
|
||||
|---|---|
|
||||
| `RowLayout` / `ColumnLayout` (`BoxLayout`) | Flex-like stacking with gap, padding, main/cross alignment |
|
||||
| `StackLayout` | Every child fills the whole area (pages, overlays) |
|
||||
| `FreeLayout` | Children at `requestPosition()` with their preferred size (floating panels, desks) |
|
||||
| `GridLayout` | Equal columns, rows as tall as their tallest cell |
|
||||
|
||||
`SplitView` and `ScrollView` own private layouts with typed slots instead of
|
||||
"child number N means X".
|
||||
|
||||
## Docking
|
||||
|
||||
`DockArea` is a CLion-style tool-window frame around a center widget, built
|
||||
from `DockPanel`s:
|
||||
|
||||
- **Stripes.** A thin rail on the left and right lists every panel. Left and
|
||||
bottom panels share the left rail (bottom panels at its lower end); right
|
||||
panels use the right rail. Clicking a stripe button toggles its panel.
|
||||
- **One panel per side.** Opening a panel closes the other one docked on the
|
||||
same side, as in CLion.
|
||||
- **Header.** Title, a "⋮" options button and a "−" hide button, as in
|
||||
CLion. The options menu has *View Mode* (Dock Pinned, Dock Unpinned,
|
||||
Float), *Move To* (Left, Right, Bottom) and *Hide*.
|
||||
- **View modes:**
|
||||
- *Docked / pinned*: the panel takes a zone and pushes the center aside.
|
||||
- *Undocked / unpinned*: the panel overlays the center and hides as soon as
|
||||
the user activates something outside it (pressing the center, another
|
||||
panel, a toolbar). Its stripe button can still toggle it.
|
||||
- *Float*: a movable window inside the dock area, resizable from its
|
||||
corner grip, independent of side zones.
|
||||
- **Resize.** Drag a docked panel's inner edge; length is clamped so the
|
||||
center always stays visible.
|
||||
- **Drag to dock.** Dragging a header beyond a small threshold detaches the
|
||||
panel, which follows the pointer as a raised window. While any panel is
|
||||
dragged, docked or floating, a hot target square is shown for every side.
|
||||
Moving the pointer onto one grows an animated preview of the zone the
|
||||
panel will take; releasing there docks it on that side and moves its
|
||||
stripe button. Releasing anywhere else floats the panel at the drop
|
||||
position; nothing docks without a hot target.
|
||||
|
||||
"Activates something outside" comes from `Ui::activeWidget()`, the widget
|
||||
last pressed. The active path follows popup owners, so using a panel's
|
||||
options menu counts as activity inside the panel. Every ancestor reports `containsActiveWidget()` and gets
|
||||
`onActiveWithinChanged()`; that is how unpinned panels auto-hide without
|
||||
polling.
|
||||
|
||||
## Kanban
|
||||
|
||||
`KanbanBoard` shows the items of a `KanbanModel` in columns and lets the user
|
||||
drag them between and within columns.
|
||||
|
||||
- **Columns** are configured in code with `setColumns({ KanbanColumn... })`:
|
||||
title, accent, optional card limit (WIP), whether drops are accepted, and
|
||||
ordering. `Manual` columns place a dropped card where the placeholder opens;
|
||||
`ModelDefined` columns highlight as a whole and leave the order to the model.
|
||||
- **Data** comes from the model: `items(column)` lists ids in display order and
|
||||
`canMove(move)` may veto a move. The board never edits the model; it reports
|
||||
`onMoved(KanbanMove)` and re-reads the model, so the model is the single source
|
||||
of truth. Call `modelChanged()` after external edits; cards are kept per id and
|
||||
animate when they change column.
|
||||
- **Cards** are `KanbanCard` widgets (title, detail, accent strip, badge) made by
|
||||
an optional factory and refreshed by a card updater, as in `TreeView`.
|
||||
`setCardHeight(h)` gives every card the same height: the title keeps one line
|
||||
and the detail as many lines as fit, ending in an ellipsis
|
||||
(`Label::setMaximumLines`). Without it cards grow with their text.
|
||||
- **Drag** starts after a small pointer movement (a long press with touch). The
|
||||
card is lifted above the board and follows the pointer, edges auto-scroll, a
|
||||
refused target is outlined in red, Esc cancels. Click selects, double-click
|
||||
activates.
|
||||
- **Column visibility**: every column header has an options ("...") menu with a
|
||||
toggle per column, so any column can be shown or hidden from any other one.
|
||||
`KanbanColumn::visible` sets the initial state, `setColumnVisible` changes it and
|
||||
`setOnColumnVisibilityChanged` reports it so the host can persist it. The last
|
||||
visible column cannot be hidden. Hidden columns take no space and receive no drops.
|
||||
- **Width**: visible columns share the width equally, never narrower than the
|
||||
minimum of `setColumnWidthRange`; when they don't fit, the strip scrolls
|
||||
horizontally (scroll bar, trackpad swipe or horizontal wheel).
|
||||
|
||||
## Tab views
|
||||
|
||||
`TabView` shows one page at a time under a row of tab buttons. The style is
|
||||
fixed at construction:
|
||||
|
||||
- `TabView::Style::Tabs` (default) switches pages instantly.
|
||||
- `TabView::Style::Carousel` adds Previous/Next arrow buttons around the tabs and
|
||||
slides pages horizontally: the current page leaves to one side while the next
|
||||
comes in from the other. Navigation is cyclic, so Next on the last page shows
|
||||
the first. A horizontal wheel (or Shift+wheel) switches pages only when nothing
|
||||
inside the page accepted it, because wheel events bubble from the hovered
|
||||
widget up. Mouse notches switch at once; trackpad deltas pull the pages with
|
||||
the same magnetic step as `TreeView` (see below).
|
||||
|
||||
## Magnetic step
|
||||
|
||||
`MagneticStep` (in `ScrollPhysics.hpp`, next to `FlingSpline` and
|
||||
`StretchEffect`) is the one implementation of a discrete step reached by wheel
|
||||
or pinch input. Widgets feed it input in step units with `push()` and advance it
|
||||
every tick; it owns the accumulated pull, the resistance curve
|
||||
`give * (1 - exp(-|pull| / give))`, the commit threshold, the release countdown
|
||||
and a critically damped spring without overshoot. The visible `offset()` moves
|
||||
from the first delta. Crossing `commitFraction` commits a step and starts a new
|
||||
pull, so one gesture can pass several steps; `moveBy()` steps directly (mouse
|
||||
notches, buttons). Tuning comes from one `MagneticStepTuning` that the step
|
||||
reads live; `MagneticStepTuning::standard()` is the shared default and
|
||||
`TreeViewStyle::step` lets a tree view adjust its own.
|
||||
|
||||
Users: `TreeView` level pull, camera rest between compact and detailed cards,
|
||||
and the pull into the full view; `TabView::Style::Carousel` pages.
|
||||
|
||||
Each carousel page sits in its own slot with geometry animation off; the carousel
|
||||
layout places the current slot and, while sliding, its neighbour, and the page
|
||||
area clips them.
|
||||
|
||||
## Application storage
|
||||
|
||||
Every application keeps its own data in one folder, `~/<application>/`. A
|
||||
`WidgetApplication` subclass names it by implementing
|
||||
`persistentAppStorage()` (part of `WindowHost`), for example
|
||||
`AppStorage::inHome("taskboard")`. Widgets reach it through
|
||||
`ui()->windowHost()->persistentAppStorage()`:
|
||||
|
||||
- `images/` holds images pasted into a `MarkdownEditor`. Without an explicit
|
||||
`setImageStore`, a pasted image is saved there and linked by absolute path.
|
||||
- `sessions/` is the default folder for session recordings
|
||||
(`$MODULES_SESSION_DIR` overrides it).
|
||||
- `hangs/` holds hang reports (`$MODULES_HANG_DIR` overrides it).
|
||||
- Other files are the application's own, such as taskboard's
|
||||
`session-presets.json` or sketch2d's `sketches/` library.
|
||||
|
||||
Tests and benchmarks that create a whole application give it a temporary
|
||||
storage folder (for example `Sketch2DOptions::forTesting`) so they never
|
||||
write into the user's real data.
|
||||
|
||||
## Popups
|
||||
|
||||
One mechanism serves menus, submenus, dropdowns, popup buttons and tooltips:
|
||||
|
||||
```cpp
|
||||
ui::PopupRequest request;
|
||||
request.content = &menu; // any widget; attached while open
|
||||
request.owner = &button; // pressing the owner doesn't dismiss; owner removal closes it
|
||||
request.anchor = button.windowRect();
|
||||
request.options.placement = ui::Placement::Below;
|
||||
ui->openPopup(request);
|
||||
```
|
||||
|
||||
The stack tracks chains: a popup opened from inside another popup is its
|
||||
child. Opening from the main tree replaces unrelated popups. Pressing outside
|
||||
closes popups above the pressed one. Escape closes the topmost. `modal`
|
||||
blocks input below. `interactive = false` (tooltips) makes it ignore the
|
||||
pointer.
|
||||
|
||||
## Input
|
||||
|
||||
- Pointer events go to the deepest visible widget under the pointer (popups
|
||||
first), then bubble to ancestors until one calls `accept()`.
|
||||
- A widget that calls `ui()->capturePointer(*this)` receives moves and the
|
||||
release even outside its bounds. Only the capturing widget can release.
|
||||
- `Click` is synthesized when press and release land on the same widget.
|
||||
- Keyboard events go to the focused widget (set when a focusable widget is
|
||||
pressed), else the topmost popup, else the root.
|
||||
- Disabled widgets receive nothing; their ancestors still do.
|
||||
- `PointerDown` and `Click` carry `clickCount()`: presses of the same button
|
||||
within 400 ms and 4 px count up, so double-click is `clickCount() == 2`.
|
||||
The clock comes from `Ui::advance`, which keeps it deterministic in tests.
|
||||
- `setCursor(shape)` gives a widget a pointer cursor; the hovered widget or its
|
||||
nearest ancestor with a cursor wins and is sent to the window host.
|
||||
|
||||
## Window
|
||||
|
||||
The window has no system decorations. `Ui::setWindowHost` connects the tree to
|
||||
the window (`WidgetApplication` is the host in apps; tests use
|
||||
`FakeWindowHost`). Without a host the window controls stay hidden.
|
||||
|
||||
- `MenuBar` ends with `WindowControls`: monochrome minimize, maximize/restore
|
||||
and close buttons (close hovers red, the one non-gray color).
|
||||
- Pressing empty menu-bar space starts a compositor window move, double-click
|
||||
toggles maximize, and secondary press opens the system window menu.
|
||||
- The frame itself (rounded corners, outline, shadow, edge resizing) belongs to
|
||||
`Graphics`: `WindowFrame` owns the geometry and edge hit-testing, the Wayland
|
||||
backend reserves a shadow margin, sets the window geometry and input region,
|
||||
and the canvas cuts the corners and paints the shadow last. Maximized and
|
||||
tiled windows drop the margin and corners. Without compositor alpha support
|
||||
the frame falls back to an opaque square window that still resizes from its
|
||||
edges.
|
||||
|
||||
## Animation
|
||||
|
||||
Geometry is animated by default, as in the original library. Every widget
|
||||
has two rects:
|
||||
|
||||
- `targetRect()`: where layout wants the widget.
|
||||
- `rect()`: where the widget is now. Painting, hit testing, `windowRect()`
|
||||
and the arrangement of its children all use this one.
|
||||
|
||||
When layout assigns a new target, a spring (`Motion::fluid()`, the same
|
||||
stiffness 0.0041 and damping 0.25 per millisecond as the original
|
||||
`SpringRect`) moves `rect()` towards it. Each step re-arranges the widget's
|
||||
children inside the current rect, so a panel that is opening pushes the
|
||||
main view and lays out its own content as it grows; nothing is faked at
|
||||
paint time. Children of a moving widget follow it directly instead of
|
||||
running their own springs, so motion never compounds. The curve has no
|
||||
overshoot: 90% of the travel takes about 135 ms, and it settles to within
|
||||
a quarter pixel in about 300 ms.
|
||||
|
||||
Appearing is animated too:
|
||||
|
||||
- Popups grow out of the edge of their anchor.
|
||||
- Dock panels slide out of their side.
|
||||
- A widget that becomes visible grows from its center.
|
||||
|
||||
`setGeometryAnimated(false)` opts a widget out; scroll view content and
|
||||
dock panels being resized by the pointer use it so they track input
|
||||
exactly. `finishGeometryAnimation()` jumps to the target.
|
||||
|
||||
`Math/SpringAnimations.hpp` steps springs in substeps of at most 1 ms with
|
||||
an explicit delta, so the curve is the same at any frame rate and tests are
|
||||
deterministic. Controls animate hover, press and toggle visuals with
|
||||
`AnimatedFloat` / `AnimatedColor` and the `Motion::quick()` /
|
||||
`Motion::smooth()` presets.
|
||||
|
||||
## Text styles
|
||||
|
||||
`Painter::styledText` / `TextMetrics::styledTextWidth` take a `TextStyle`
|
||||
(`fontSize` + `FontFace`: Regular, Bold, Italic, BoldItalic, Monospace).
|
||||
`text()` / `textWidth()` are Regular shorthands. `Canvas` loads
|
||||
`rsc/FontBold.ttf` (Cantarell Bold) and `rsc/FontMono.ttf` (JetBrains Mono),
|
||||
both OFL (`Graphics/rsc/licenses`); italic is a synthetic skew. A missing
|
||||
face falls back to the regular font.
|
||||
|
||||
## Markdown
|
||||
|
||||
Parsing is md4c (`Externals/md4c`, MIT), linked privately into `Widgets`.
|
||||
|
||||
| Piece | Responsibility |
|
||||
|---|---|
|
||||
| `markdown::MarkdownDocument` | md4c callbacks → `Block` tree (kind, children, styled `TextRun`s, byte `SourceRange` into the source), outline, task toggling |
|
||||
| `RichText` | One paragraph of `TextSpan`s: per-span face, colour, background chip, link, strike/underline; word wrap, alignment, link hover/click, click → source offset |
|
||||
| `MarkdownView` | Builds widgets from the document (structure ported from lv_markdown, tables/links/images from imgui_md): headings, lists, task checkboxes, quotes, admonitions, code, tables, rules |
|
||||
| `MarkdownImage` | An image paragraph: loaded through `Ui::imageLoader()`, shrinks to the column width, alt-text placeholder when missing |
|
||||
| `MarkdownEditor` | CLion-like Editor / Split / Preview: monospace `TextArea` with markdown highlighting, debounced re-render, scroll-synced panes, Ctrl+wheel zoom of both panes, clicking preview text moves the caret, task checkboxes rewrite the source |
|
||||
|
||||
Rebuilding happens on `setMarkdown`; widgets replaced while one of them is
|
||||
handling an event are retired and destroyed on the next tick. Task toggles
|
||||
are applied on the next tick for the same reason.
|
||||
|
||||
Scroll sync: whichever pane scrolls first leads until the next tick; the
|
||||
other jumps to the block or source line at the leader's top edge.
|
||||
`ScrollView::setOnScrolled` reports every applied offset change.
|
||||
|
||||
Images: `ImageLoader` is an optional `Ui` service (like `TextMetrics`).
|
||||
`WidgetsApp` installs `CanvasImageLoader`, which caches nanovg images by
|
||||
path. Relative image paths resolve against `MarkdownView::setBaseDirectory`.
|
||||
|
||||
Pasting images: `WindowHost::clipboardImage()` returns the clipboard's
|
||||
image bytes and MIME type (Wayland: PNG preferred, any `image/*` otherwise;
|
||||
Android: none yet). Ctrl+V in the editor asks `MarkdownEditor`'s
|
||||
`ImageStore` callback to save the bytes and return a path, then inserts
|
||||
`` as its own paragraph. Without an image on the clipboard, Ctrl+V
|
||||
pastes text as before. The gallery stores pasted images in `images/` next
|
||||
to the open file (or in `rsc` for the sample) and copies them along on
|
||||
Save as.
|
||||
|
||||
Opening links: clicking a link in the preview, or Ctrl+clicking it in the
|
||||
source, calls `MarkdownEditor::openLink`. An optional `LinkHandler` may
|
||||
claim the link first. `#anchor` links scroll to the heading with that
|
||||
GitHub-style anchor (`markdown::headingAnchor`, duplicates get `-1`, `-2`).
|
||||
Everything else goes to `WindowHost::openUrl`: links with a scheme as they
|
||||
are, relative paths resolved against the base directory into absolute file
|
||||
paths. Wayland spawns `xdg-open` with an `xdg-activation` token so the
|
||||
compositor lets the opened app take focus; Android starts an
|
||||
`ACTION_VIEW` intent.
|
||||
|
||||
## Debugging
|
||||
|
||||
- `ui.dumpTree()` prints every widget's name, rect and state.
|
||||
- `ui.stats()` counts layout passes and measured, arranged, skipped,
|
||||
delivered and painted widgets.
|
||||
- `WidgetApplication` toggles the widget debugger with D. It takes its own column on the right; the Ui viewport shrinks to the area left of it, and pointer input over the panel goes to ImGui only, so the panel never covers or steals input from the UI.
|
||||
- `setDebugName` names widgets in dumps and the inspector.
|
||||
|
||||
### Debug menu and session recording
|
||||
|
||||
Every `WidgetApplication` owns `DebugTools` and attaches a **Debug** menu
|
||||
to its menu bar by default: *Debug UI* (the inspector), *Record session*
|
||||
and *Stop recording and save*. It is a trailing menu, so it stays last
|
||||
when the app adds its own menus later. Apps with their own menu bar call
|
||||
`debugTools().attachTo(theirBar)` (and `detach()` before the bar is
|
||||
destroyed); `setDebugToolsEnabled(false)` removes it. `DebugTools::addMenuItems`
|
||||
adds the same entries to any other menu.
|
||||
|
||||
Recording (`SessionRecorder`, GraphicsInput) taps every raw window event
|
||||
before coalescing and turns the profiler on. While it runs, a `● REC m:ss`
|
||||
badge (`SessionRecordingControl::badge`) shows next to the window
|
||||
controls; clicking it stops. Stopping asks where to save, defaulting to
|
||||
`~/<application>/sessions/` (or `$MODULES_SESSION_DIR`); cancelling asks
|
||||
before discarding. A session folder holds:
|
||||
|
||||
| file | contents |
|
||||
|---|---|
|
||||
| `environment.txt` | application, revision, GPU, date, window size and scale |
|
||||
| `input.txt` | every raw input event with its time (`InputRecording`) |
|
||||
| `recorded-frames.tsv` | per frame wall and CPU time, profiled sections, GPU times, counters |
|
||||
|
||||
Apps add their own state with `SessionRecorder::setSnapshot`: it is
|
||||
captured when recording starts and written into the same folder.
|
||||
Sketch2D uses it for `session.txt` and layer tiles, which its bench
|
||||
replays.
|
||||
|
||||
## Testing
|
||||
|
||||
`tests/support` provides everything needed to test any widget tree headlessly:
|
||||
|
||||
- `UiDriver` owns a `Ui` and drives it like a user would: `moveTo`, `press`,
|
||||
`release`, `click`, `drag`, `wheel`, `key`, `advance`, `settle`,
|
||||
`hoverFor`, `paint`.
|
||||
- `RecordingPainter` records paint commands with their window rects and
|
||||
clips, so tests can assert what text is visible and that clip/translation
|
||||
stacks are balanced.
|
||||
- `MonospaceTextMetrics` makes text measurement deterministic.
|
||||
- `Box` / `EventProbe` are minimal widgets for exact layout and event checks.
|
||||
|
||||
Suites: algorithms (sizing, distribution, snapping, placement, springs),
|
||||
layouts, input routing, popups and tooltips, every widget, menus, window
|
||||
chrome, and the gallery application end to end. `Sketch2D/tests` reuses the same support code to
|
||||
test the Sketch2D UI with a fake renderer.
|
||||
|
||||
## What changed from the old library
|
||||
|
||||
| Old | New |
|
||||
|---|---|
|
||||
| Four copies of a rect (`mArea` spring target/current, `mAreaCache`, layout area) copied back and forth each frame | A target rect from layout and a sprung current rect that children are laid out in |
|
||||
| Size policy and min/max stored on the child's own layout object; every leaf allocated a layout | `AxisSizing` on the widget; only containers have a layout |
|
||||
| Child picks its own rect from parent, children and itself; dependency graph, topological sort, dead policy table | Measure up, arrange down; no graph |
|
||||
| `UpdateManager` mixed hover, event delivery, layout scoping, animation and redraw; `process()` polled every frame | Separate `InputRouter`, `TickScheduler`, layout dirty flags, paint request; widgets only run on events, ticks and paint |
|
||||
| Popups re-parented under a shared layer plus a focus lock to fake modality | `PopupStack` with owners, chains and dismissal rules |
|
||||
| Four friend managers and many `dynamic_cast`s | A narrow public API; one internal access class for the engine |
|
||||
| Layouts keyed on child count (`ToolBarLayout`, `FixedSizeBarsLayout`, `ScrollableLayout`) | Generic `Row`/`Column`/`Stack`/`Free`/`Grid` plus typed slots |
|
||||
| `std::to_string` debug reasons built on every `setArea` | No hot-path strings; `dumpTree` and stats on demand |
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
|
||||
#include "WidgetApplication.hpp"
|
||||
|
||||
#include "RootWidget.hpp"
|
||||
#include "FloatingWidget.hpp"
|
||||
#include "DockWidget.hpp"
|
||||
#include "ColorPickerWidget.hpp"
|
||||
|
||||
#include "ScrollableLayout.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
|
||||
class Example : public WidgetApplication {
|
||||
public:
|
||||
Example() {
|
||||
exampleColorPicker();
|
||||
}
|
||||
|
||||
void exampleAll() {
|
||||
static DockWidget dock;
|
||||
static LabelWidget centralWidget;
|
||||
static FloatingMenu menus[4];
|
||||
static FloatingMenu nestedMenus[4];
|
||||
static FloatingMenu buttons[10];
|
||||
|
||||
dock.setCenterWidget(¢ralWidget);
|
||||
|
||||
for (auto& menu : menus) {
|
||||
dock.addChild(&menu);
|
||||
}
|
||||
|
||||
for (auto& menu : nestedMenus) {
|
||||
menus[0].addToMenu(&menu);
|
||||
}
|
||||
|
||||
setRoot(&dock);
|
||||
}
|
||||
|
||||
void exampleColorPicker() {
|
||||
static DockWidget dock;
|
||||
static RGBPickerWidget colorPicker;
|
||||
|
||||
dock.dockWidget(&colorPicker, DockLayout::RIGHT);
|
||||
|
||||
setRoot(&dock);
|
||||
}
|
||||
|
||||
void exampleBasic() {
|
||||
static Widget widget;
|
||||
static LabelWidget label;
|
||||
static SliderWidget slider;
|
||||
static ButtonWidget button;
|
||||
|
||||
widget.addChild(&label);
|
||||
widget.addChild(&button);
|
||||
widget.addChild(&slider);
|
||||
|
||||
setRoot(&widget);
|
||||
}
|
||||
|
||||
void exampleScrolling() {
|
||||
static ScrollableWidget widget;
|
||||
static ButtonWidget buttons[10];
|
||||
|
||||
widget.setDirection(false);
|
||||
|
||||
setRoot(&widget);
|
||||
|
||||
for (auto& button : buttons) {
|
||||
widget.getContainer()->addChild(&button);
|
||||
}
|
||||
|
||||
RootWidget::setWidgetArea(*widget.getContainer(), { 333, 333, 522, 522 });
|
||||
}
|
||||
|
||||
void examplePopup() {
|
||||
static Widget root;
|
||||
static Widget popup;
|
||||
|
||||
static ButtonWidget closeButton;
|
||||
static ButtonWidget openButton;
|
||||
static ButtonWidget buttons[3];
|
||||
|
||||
for (auto& button : buttons) {
|
||||
popup.addChild(&button);
|
||||
}
|
||||
|
||||
popup.addChild(&closeButton);
|
||||
|
||||
root.addChild(&openButton);
|
||||
((BasicLayout*)root.getLayout())->setLayoutPolicy(LayoutPolicy::Passive);
|
||||
|
||||
openButton.setAction([&](){
|
||||
popup.setArea({ 50, 50, 300, 300 });
|
||||
openButton.openPopup(&popup);
|
||||
});
|
||||
|
||||
closeButton.setAction([&](){
|
||||
closeButton.closePopup(&popup);
|
||||
});
|
||||
|
||||
openButton.setText("open");
|
||||
closeButton.setText("close");
|
||||
|
||||
RootWidget::setWidgetArea(openButton, { 333, 333, 222, 222 });
|
||||
|
||||
setRoot(&root);
|
||||
}
|
||||
|
||||
void exampleNestedMenus() {
|
||||
static DockWidget dock;
|
||||
static FloatingMenu menu1;
|
||||
static FloatingMenu menu2;
|
||||
static ButtonWidget buttons[15];
|
||||
|
||||
setRoot(&dock);
|
||||
|
||||
dock.addChild(&menu1);
|
||||
dock.addChild(&menu2);
|
||||
|
||||
dock.setCenterWidget(&buttons[5]);
|
||||
dock.dockWidget(&buttons[6], DockLayout::RIGHT);
|
||||
|
||||
menu1.addToMenu(&buttons[0]);
|
||||
|
||||
menu2.addToMenu(&buttons[2]);
|
||||
menu2.addToMenu(&buttons[3]);
|
||||
menu2.addToMenu(&buttons[4]);
|
||||
|
||||
menu2.addToMenu(&menu1);
|
||||
|
||||
// popup
|
||||
{
|
||||
buttons[0].setAction([&](){
|
||||
buttons[10].setArea({ 30, 30, 100, 100 });
|
||||
buttons[0].openPopup(&buttons[10]);
|
||||
});
|
||||
|
||||
buttons[0].setText("open popup");
|
||||
|
||||
buttons[10].setAction([&](){
|
||||
buttons[10].closePopup(&buttons[10]);
|
||||
});
|
||||
|
||||
buttons[10].setText("close popup");
|
||||
}
|
||||
|
||||
RootWidget::setWidgetArea(menu1, { 300, 100, 150, 500 });
|
||||
RootWidget::setWidgetArea(menu2, { 100, 100, 150, 300 });
|
||||
}
|
||||
|
||||
void exampleLayouts() {
|
||||
static Widget widgets[15];
|
||||
static ButtonWidget buttons[15];
|
||||
|
||||
setRoot(&widgets[0]);
|
||||
|
||||
widgets[0].addChild(&widgets[1]);
|
||||
|
||||
widgets[1].addChild(&buttons[1]);
|
||||
widgets[1].addChild(&buttons[2]);
|
||||
widgets[1].addChild(&widgets[2]);
|
||||
|
||||
RootWidget::setWidgetArea(buttons[1], { 300, 100, 350, 800 });
|
||||
RootWidget::setWidgetArea(buttons[2], { 100, 100, 150, 300 });
|
||||
|
||||
widgets[2].addChild(&buttons[3]);
|
||||
widgets[2].addChild(&buttons[4]);
|
||||
widgets[2].addChild(&buttons[5]);
|
||||
widgets[2].addChild(&buttons[6]);
|
||||
|
||||
buttons[1].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding);
|
||||
buttons[2].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding);
|
||||
buttons[3].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding);
|
||||
buttons[4].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding);
|
||||
buttons[5].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding);
|
||||
buttons[6].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding);
|
||||
|
||||
widgets[2].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding);
|
||||
((BasicLayout*)widgets[1].getLayout())->setLayoutPolicy(LayoutPolicy::Horizontal);
|
||||
}
|
||||
|
||||
void exampleDock() {
|
||||
static DockWidget dock;
|
||||
static FloatingMenu menu1;
|
||||
static FloatingMenu menu2;
|
||||
static ButtonWidget buttons[15];
|
||||
|
||||
setRoot(&dock);
|
||||
|
||||
dock.addChild(&menu2);
|
||||
dock.addChild(&menu1);
|
||||
|
||||
dock.setCenterWidget(&buttons[5]);
|
||||
dock.dockWidget(&buttons[6], DockLayout::RIGHT);
|
||||
|
||||
menu1.addToMenu(&buttons[0]);
|
||||
menu2.addToMenu(&buttons[2]);
|
||||
//menu2.addToMenu(&menu1);
|
||||
menu2.addToMenu(&buttons[3]);
|
||||
menu2.addToMenu(&buttons[4]);
|
||||
|
||||
buttons[0].setAction([&]() { buttons[2].setColor(RGBA::random()); });
|
||||
buttons[2].setAction([&]() { buttons[0].setColor(RGBA::random()); });
|
||||
|
||||
buttons[5].setAction([&]() { dock.dockWidget(&menu1, DockLayout::LEFT); });
|
||||
buttons[6].setAction([&]() { dock.undockWidget(DockLayout::LEFT); });
|
||||
|
||||
buttons[3].setAction([&]() { dock.toggleWidgetVisibility(DockLayout::LEFT); });
|
||||
// buttons[4].setAction([this]() { mDockLayout.undockWidget(DockLayout::LEFT); });
|
||||
|
||||
buttons[3].setText("toggle");
|
||||
buttons[5].setText("dock");
|
||||
buttons[6].setText("undock");
|
||||
|
||||
RootWidget::setWidgetArea(menu2, { 300, 100, 150, 500 });
|
||||
RootWidget::setWidgetArea(menu1, { 100, 100, 150, 300 });
|
||||
}
|
||||
};
|
||||
|
||||
int main() {
|
||||
{
|
||||
Example gui;
|
||||
gui.run();
|
||||
}
|
||||
}
|
||||
1012
Widgets/examples/Gallery.cpp
Normal file
1012
Widgets/examples/Gallery.cpp
Normal file
File diff suppressed because it is too large
Load diff
384
Widgets/examples/Gallery.hpp
Normal file
384
Widgets/examples/Gallery.hpp
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
#pragma once
|
||||
|
||||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Dialog.hpp"
|
||||
#include "FileBrowser.hpp"
|
||||
#include "Docking.hpp"
|
||||
#include "FloatingPanel.hpp"
|
||||
#include "KanbanBoard.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "MarkdownEditor.hpp"
|
||||
#include "Menus.hpp"
|
||||
#include "MediaControls.hpp"
|
||||
#include "NodeEditor.hpp"
|
||||
#include "Notice.hpp"
|
||||
#include "ReorderableList.hpp"
|
||||
#include "ThemeEditor.hpp"
|
||||
#include "Shortcut.hpp"
|
||||
#include "Slider.hpp"
|
||||
#include "Collapsible.hpp"
|
||||
#include "ColorPicker.hpp"
|
||||
#include "TextArea.hpp"
|
||||
#include "TextField.hpp"
|
||||
#include "Toggles.hpp"
|
||||
#include "TreeView.hpp"
|
||||
#include "Views.hpp"
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace tp::ui::gallery {
|
||||
|
||||
struct GalleryState {
|
||||
int buttonClicks = 0;
|
||||
bool notificationsEnabled = true;
|
||||
bool darkMode = true;
|
||||
long qualityIndex = 1;
|
||||
halnf volume = 0.6f;
|
||||
RGBA brushColor = { 0.9f, 0.4f, 0.2f, 1.0f };
|
||||
std::string lastAction;
|
||||
std::string userName = "the reference app";
|
||||
halnf brushSize = 14;
|
||||
int confirmations = 0;
|
||||
};
|
||||
|
||||
class InputPage : public Widget {
|
||||
public:
|
||||
explicit InputPage(GalleryState& state);
|
||||
|
||||
Label nameLabel{ "Name" };
|
||||
TextField name;
|
||||
Label sizeLabel{ "Brush size" };
|
||||
NumberField size{ 1, 300, 14 };
|
||||
Label greeting;
|
||||
Label notesLabel{ "Notes (drag the bottom edge to resize)" };
|
||||
TextArea notes;
|
||||
Label colorLabel{ "Accent color" };
|
||||
ColorPickerButton color;
|
||||
Button openDialog{ "Show dialog" };
|
||||
Button askConfirm{ "Clear history..." };
|
||||
Button showNotice{ "Show notice" };
|
||||
Notice notice;
|
||||
Label confirmations;
|
||||
Dialog dialog{ "About" };
|
||||
ConfirmDialog confirm;
|
||||
|
||||
private:
|
||||
void refresh();
|
||||
|
||||
GalleryState& mState;
|
||||
Widget mDialogRow;
|
||||
};
|
||||
|
||||
class ControlsPage : public Widget {
|
||||
public:
|
||||
explicit ControlsPage(GalleryState& state);
|
||||
|
||||
Button clickMe{ "Click me" };
|
||||
Button accent{ "Accent" };
|
||||
Button disabled{ "Disabled" };
|
||||
Label clickCount;
|
||||
|
||||
Checkbox notifications{ "Enable notifications" };
|
||||
Switch darkMode{ "Dark mode" };
|
||||
|
||||
RadioButton low{ "Low" };
|
||||
RadioButton medium{ "Medium" };
|
||||
RadioButton high{ "High" };
|
||||
RadioGroup quality;
|
||||
|
||||
LabeledSlider volume{ "Volume", 0, 1, 0.6f };
|
||||
LabeledSlider brushSize{ "Brush size (logarithmic)", 1, 1000, 10 };
|
||||
ProgressBar progress;
|
||||
Dropdown blendMode{ { "Normal", "Multiply", "Screen", "Overlay" } };
|
||||
Dropdown accentColor{ { "Blue", "Purple", "Green", "Orange", "Pink" } };
|
||||
|
||||
private:
|
||||
void refreshClickCount();
|
||||
|
||||
GalleryState& mState;
|
||||
Widget mButtonRow;
|
||||
Widget mRadioRow;
|
||||
Separator mFirstSeparator;
|
||||
Separator mSecondSeparator;
|
||||
};
|
||||
|
||||
class ReorderPage : public Widget {
|
||||
public:
|
||||
ReorderPage();
|
||||
|
||||
Label hint{ "Drag the dots, or a row, up or down to reorder. Esc cancels." };
|
||||
ReorderableList list;
|
||||
std::vector<std::unique_ptr<Checkbox>> tasks;
|
||||
Label status;
|
||||
|
||||
private:
|
||||
void refreshStatus();
|
||||
};
|
||||
|
||||
class FilesPage : public Widget {
|
||||
public:
|
||||
explicit FilesPage(FileSystem& fileSystem = FileSystem::local());
|
||||
|
||||
Label hint{ "Browse folders, then open, save or choose a folder with the file dialog. Double-click enters a "
|
||||
"folder; Backspace goes up." };
|
||||
FileBrowser browser;
|
||||
Widget actions;
|
||||
Button openFile{ "Open file..." };
|
||||
Button saveFile{ "Save as..." };
|
||||
Button chooseFolder{ "Choose folder..." };
|
||||
Label status;
|
||||
FileDialog dialog;
|
||||
|
||||
private:
|
||||
void report(const std::string& action, const std::string& path);
|
||||
};
|
||||
|
||||
class MediaPage : public Widget {
|
||||
public:
|
||||
MediaPage();
|
||||
|
||||
Label hint{ "A music player bar built from transport buttons, a seek bar and a volume control." };
|
||||
std::vector<NowPlaying> playlist;
|
||||
size_t current = 0;
|
||||
PlayerBar player;
|
||||
Label status;
|
||||
|
||||
protected:
|
||||
void onTick(halnf deltaMs) override;
|
||||
|
||||
private:
|
||||
void load(size_t index);
|
||||
void togglePlaying();
|
||||
};
|
||||
|
||||
class ListPage : public Widget {
|
||||
public:
|
||||
ListPage();
|
||||
|
||||
ScrollView scroll;
|
||||
Widget content;
|
||||
ReorderableList list{ 2 };
|
||||
std::vector<std::unique_ptr<Collapsible>> groups;
|
||||
std::vector<std::unique_ptr<Collapsible>> subgroups;
|
||||
std::vector<std::unique_ptr<Button>> groupRows;
|
||||
std::vector<std::unique_ptr<ReorderableList>> favoriteLists;
|
||||
Collapsible allRows{ "All rows" };
|
||||
std::vector<std::unique_ptr<Button>> rows;
|
||||
|
||||
Collapsible draggableSection{ "Draggable list" };
|
||||
ReorderableList draggable;
|
||||
std::vector<std::unique_ptr<Checkbox>> draggableItems;
|
||||
Label draggableStatus;
|
||||
|
||||
Collapsible staticSection{ "Static list" };
|
||||
Widget staticList;
|
||||
std::vector<std::unique_ptr<Checkbox>> staticItems;
|
||||
};
|
||||
|
||||
class CanvasPage : public Widget {
|
||||
public:
|
||||
explicit CanvasPage(GalleryState& state);
|
||||
|
||||
SplitView split{ Axis::Horizontal };
|
||||
Panel desk{ Panel::Surface::Window };
|
||||
FloatingPanel colorPanel{ "Brush color" };
|
||||
ColorWheel wheel;
|
||||
Panel inspector{ Panel::Surface::Base };
|
||||
Label inspectorTitle{ "Inspector" };
|
||||
Label colorReadout;
|
||||
|
||||
private:
|
||||
void refreshReadout();
|
||||
|
||||
GalleryState& mState;
|
||||
};
|
||||
|
||||
class DockPage : public Widget {
|
||||
public:
|
||||
DockPage();
|
||||
|
||||
DockArea dock;
|
||||
Panel editor{ Panel::Surface::Base };
|
||||
Label editorText{ "Main view: drag panel headers to dock them on another side" };
|
||||
|
||||
DockPanel project{ "Project", DockSide::Left };
|
||||
DockPanel structure{ "Structure", DockSide::Left };
|
||||
DockPanel terminal{ "Terminal", DockSide::Bottom };
|
||||
DockPanel properties{ "Properties", DockSide::Right };
|
||||
|
||||
Label projectText{ "src/ tests/ docs/" };
|
||||
Label structureText{ "class Gallery" };
|
||||
Label terminalText{ "$ cmake --build build" };
|
||||
Label propertiesText{ "width 240 height 120" };
|
||||
};
|
||||
|
||||
class NodesPage : public Widget {
|
||||
public:
|
||||
NodesPage();
|
||||
|
||||
Label hint{ "Drag from a port to connect, drag a linked input to detach. Drag empty space to select, Alt or "
|
||||
"middle drag to pan, wheel to zoom. Ctrl+C/X/V/D, Delete, Home." };
|
||||
NodeGraph graph;
|
||||
NodeEditor editor{ graph };
|
||||
Label status;
|
||||
|
||||
private:
|
||||
void populate();
|
||||
void refreshStatus();
|
||||
};
|
||||
|
||||
class CarouselPage : public Widget {
|
||||
public:
|
||||
CarouselPage();
|
||||
|
||||
Label hint{ "A cyclic carousel: the arrows, the tabs, a horizontal swipe or Shift+wheel slide between pages. "
|
||||
"The wheel goes to the carousel only when nothing inside the page scrolls with it." };
|
||||
TabView carousel{ TabView::Style::Carousel };
|
||||
Label colorsText;
|
||||
Label notesText;
|
||||
ScrollView longListScroll;
|
||||
Widget longList;
|
||||
std::vector<std::unique_ptr<Label>> longListRows;
|
||||
};
|
||||
|
||||
class KanbanPage : public Widget {
|
||||
public:
|
||||
KanbanPage();
|
||||
|
||||
struct Story {
|
||||
std::string title;
|
||||
std::string detail;
|
||||
RGBA accent;
|
||||
};
|
||||
|
||||
class Model : public KanbanModel {
|
||||
public:
|
||||
std::vector<std::vector<KanbanItemId>> columns;
|
||||
std::map<KanbanItemId, Story> stories;
|
||||
|
||||
[[nodiscard]] std::vector<KanbanItemId> items(size_t column) const override { return columns[column]; }
|
||||
[[nodiscard]] bool canMove(const KanbanMove& move) const override;
|
||||
void apply(const KanbanMove& move);
|
||||
};
|
||||
|
||||
Label hint{ "Drag cards between columns or within one. Review holds two cards; Released only takes cards "
|
||||
"from Review and keeps its own order. Esc cancels a drag, double-click opens a card." };
|
||||
Model model;
|
||||
KanbanBoard board{ model };
|
||||
Label status;
|
||||
|
||||
private:
|
||||
void populate();
|
||||
void refreshStatus(const std::string& action);
|
||||
};
|
||||
|
||||
class TreePage : public Widget {
|
||||
public:
|
||||
static constexpr size_t kLevels = 10;
|
||||
|
||||
TreePage();
|
||||
|
||||
class Model : public TreeModel {
|
||||
public:
|
||||
std::map<TreeItemId, std::optional<TreeItemId>> parents;
|
||||
|
||||
[[nodiscard]] std::vector<TreeItemId> roots() const override;
|
||||
[[nodiscard]] std::vector<TreeItemId> children(TreeItemId id) const override;
|
||||
[[nodiscard]] size_t depthOf(TreeItemId id) const;
|
||||
};
|
||||
|
||||
Label hint{ "A generated tree, ten levels deep. Wheel up/down or pinch zooms the camera; horizontal wheel "
|
||||
"or Shift+wheel opens and folds levels of the selected card, or of the whole tree without a "
|
||||
"selection. Tap a selected card to open or close it. Arrow keys walk the hierarchy, drag a "
|
||||
"children port to empty space to add a child, drag a parent port onto another card to move it." };
|
||||
Model model;
|
||||
TreeView view{ model };
|
||||
Label status;
|
||||
|
||||
private:
|
||||
struct CardControls {
|
||||
Slider amount{ 0, 100, 50 };
|
||||
Checkbox enabled{ "Enabled" };
|
||||
};
|
||||
|
||||
void populate();
|
||||
void refreshStatus();
|
||||
[[nodiscard]] std::unique_ptr<TreeCard> createCard(TreeItemId id);
|
||||
void updateCard(TreeCard& card) const;
|
||||
|
||||
std::map<TreeItemId, std::unique_ptr<CardControls>> mControls;
|
||||
};
|
||||
|
||||
class MarkdownPage : public Widget {
|
||||
public:
|
||||
static const char* const kSampleDocument;
|
||||
static const char* const kSampleDirectory;
|
||||
|
||||
explicit MarkdownPage(FileSystem& fileSystem = FileSystem::local());
|
||||
|
||||
Widget actions;
|
||||
Button openFile{ "Open..." };
|
||||
Button saveFile{ "Save as..." };
|
||||
Button reset{ "Reset sample" };
|
||||
Label status;
|
||||
MarkdownEditor editor;
|
||||
FileDialog dialog;
|
||||
|
||||
private:
|
||||
void showSample();
|
||||
[[nodiscard]] std::optional<std::string> storePastedImage(const ClipboardImage& image);
|
||||
void copyPastedImages(const std::string& fromFolder, const std::string& toFolder);
|
||||
void open(const std::string& path);
|
||||
void save(const std::string& path);
|
||||
|
||||
FileSystem& mFileSystem;
|
||||
std::string mDirectory;
|
||||
bool mOpenedFile = false;
|
||||
};
|
||||
|
||||
class Gallery : public Panel {
|
||||
public:
|
||||
Gallery();
|
||||
|
||||
GalleryState state;
|
||||
|
||||
MenuBar menuBar;
|
||||
Menu fileMenu;
|
||||
Menu viewMenu;
|
||||
Menu recentMenu;
|
||||
TabView tabs;
|
||||
Panel statusArea{ Panel::Surface::None };
|
||||
Label statusBar;
|
||||
|
||||
ControlsPage controls{ state };
|
||||
ScrollView controlsScroll;
|
||||
InputPage inputPage{ state };
|
||||
ListPage listPage;
|
||||
CanvasPage canvasPage{ state };
|
||||
DockPage dockPage;
|
||||
ReorderPage reorderPage;
|
||||
ThemeEditorPage themePage;
|
||||
MediaPage mediaPage;
|
||||
FilesPage filesPage;
|
||||
CarouselPage carouselPage;
|
||||
NodesPage nodesPage;
|
||||
TreePage treePage;
|
||||
KanbanPage kanbanPage;
|
||||
MarkdownPage markdownPage;
|
||||
|
||||
enum class Action { New, Open, Quit, NextTab };
|
||||
ShortcutMap<Action> shortcuts;
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
|
||||
private:
|
||||
void buildMenus();
|
||||
void run(Action action);
|
||||
void report(const std::string& action);
|
||||
};
|
||||
}
|
||||
35
Widgets/examples/GalleryAndroid.cpp
Normal file
35
Widgets/examples/GalleryAndroid.cpp
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#include "AndroidEntry.hpp"
|
||||
#include "Gallery.hpp"
|
||||
#include "WidgetApplication.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
class GalleryApplication : public WidgetApplication {
|
||||
public:
|
||||
GalleryApplication() {
|
||||
setMenuBarVisible(false);
|
||||
debugTools().attachTo(mGallery.menuBar);
|
||||
setRoot(mGallery);
|
||||
}
|
||||
|
||||
~GalleryApplication() override { debugTools().detach(); }
|
||||
|
||||
[[nodiscard]] AppStorage persistentAppStorage() const override { return AppStorage::inHome("widgets-gallery"); }
|
||||
|
||||
private:
|
||||
gallery::Gallery mGallery;
|
||||
};
|
||||
|
||||
int runGallery() {
|
||||
GalleryApplication application;
|
||||
application.run();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" void android_main(android_app* app) {
|
||||
const char* assets[] = { "rsc" };
|
||||
runAndroidApplication(app, runGallery, assets, 1);
|
||||
}
|
||||
31
Widgets/examples/GalleryApplication.cpp
Normal file
31
Widgets/examples/GalleryApplication.cpp
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#include "Gallery.hpp"
|
||||
#include "DebugInspector.hpp"
|
||||
#include "DebugTools.hpp"
|
||||
#include "WidgetApplication.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
class GalleryApplication : public WidgetApplication {
|
||||
public:
|
||||
GalleryApplication() {
|
||||
setMenuBarVisible(false);
|
||||
mDebugTools.attachTo(mGallery.menuBar);
|
||||
setRoot(mGallery);
|
||||
}
|
||||
|
||||
~GalleryApplication() override { mDebugTools.detach(); }
|
||||
|
||||
[[nodiscard]] AppStorage persistentAppStorage() const override { return AppStorage::inHome("widgets-gallery"); }
|
||||
|
||||
private:
|
||||
gallery::Gallery mGallery;
|
||||
DebugInspector mInspector{ ui() };
|
||||
DebugTools mDebugTools{ application(), mInspector, mGallery };
|
||||
};
|
||||
|
||||
int main() {
|
||||
GalleryApplication application;
|
||||
application.run();
|
||||
return 0;
|
||||
}
|
||||
272
Widgets/examples/ThemeEditor.cpp
Normal file
272
Widgets/examples/ThemeEditor.cpp
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
#include "ThemeEditor.hpp"
|
||||
|
||||
#include "Ui.hpp"
|
||||
#include "WindowHost.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kSwatchSize = 18;
|
||||
constexpr halnf kColorColumnWidth = 300;
|
||||
constexpr halnf kEditorColumnWidth = 300;
|
||||
|
||||
std::string number(halnf value) {
|
||||
char text[32];
|
||||
std::snprintf(text, sizeof(text), "%.3ff", value);
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string twoDecimals(halnf value) {
|
||||
char text[32];
|
||||
std::snprintf(text, sizeof(text), "%.2f", value);
|
||||
return text;
|
||||
}
|
||||
|
||||
const RGBA kAccentPresets[] = { accents::blue, accents::purple, accents::green, accents::orange, accents::pink };
|
||||
}
|
||||
|
||||
const std::vector<ThemeColorField>& tp::ui::themeColorFields() {
|
||||
static const std::vector<ThemeColorField> fields = {
|
||||
{ "window", &Palette::window },
|
||||
{ "surface", &Palette::surface },
|
||||
{ "raisedSurface", &Palette::raisedSurface },
|
||||
{ "border", &Palette::border },
|
||||
{ "focusRing", &Palette::focusRing },
|
||||
{ "control", &Palette::control },
|
||||
{ "controlHovered", &Palette::controlHovered },
|
||||
{ "controlPressed", &Palette::controlPressed },
|
||||
{ "controlBorder", &Palette::controlBorder },
|
||||
{ "field", &Palette::field },
|
||||
{ "track", &Palette::track },
|
||||
{ "accent", &Palette::accent },
|
||||
{ "accentHovered", &Palette::accentHovered },
|
||||
{ "accentPressed", &Palette::accentPressed },
|
||||
{ "onAccent", &Palette::onAccent },
|
||||
{ "selection", &Palette::selection },
|
||||
{ "neutralSelection", &Palette::neutralSelection },
|
||||
{ "menuHighlight", &Palette::menuHighlight },
|
||||
{ "sliderFill", &Palette::sliderFill },
|
||||
{ "toggleOn", &Palette::toggleOn },
|
||||
{ "toggleOnHovered", &Palette::toggleOnHovered },
|
||||
{ "onToggle", &Palette::onToggle },
|
||||
{ "text", &Palette::text },
|
||||
{ "mutedText", &Palette::mutedText },
|
||||
{ "disabledText", &Palette::disabledText },
|
||||
{ "warningText", &Palette::warningText },
|
||||
{ "separator", &Palette::separator },
|
||||
{ "tooltip", &Palette::tooltip },
|
||||
{ "scrim", &Palette::scrim },
|
||||
{ "shadow", &Palette::shadow },
|
||||
{ "windowCloseHovered", &Palette::windowCloseHovered },
|
||||
{ "windowClosePressed", &Palette::windowClosePressed },
|
||||
{ "onWindowClose", &Palette::onWindowClose },
|
||||
};
|
||||
return fields;
|
||||
}
|
||||
|
||||
const std::vector<ThemeMetricField>& tp::ui::themeMetricFields() {
|
||||
static const std::vector<ThemeMetricField> fields = {
|
||||
{ "fontSize", &Metrics::fontSize, 9, 20 },
|
||||
{ "cornerRadius", &Metrics::cornerRadius, 0, 16 },
|
||||
{ "popupCornerRadius", &Metrics::popupCornerRadius, 0, 20 },
|
||||
{ "surfaceCornerRadius", &Metrics::surfaceCornerRadius, 0, 24 },
|
||||
{ "controlHeight", &Metrics::controlHeight, 18, 40 },
|
||||
{ "spacing", &Metrics::spacing, 0, 16 },
|
||||
};
|
||||
return fields;
|
||||
}
|
||||
|
||||
std::string tp::ui::colorHex(const RGBA& color) {
|
||||
const auto channel = [](halnf value) { return (int) std::lround(std::fmin(std::fmax(value, 0.0f), 1.0f) * 255); };
|
||||
char text[16];
|
||||
std::snprintf(
|
||||
text, sizeof(text), "#%02X%02X%02X%02X", channel(color.r), channel(color.g), channel(color.b), channel(color.a)
|
||||
);
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string tp::ui::themeSource(const Theme& theme) {
|
||||
std::string source = "Theme theme;\n";
|
||||
for (const ThemeColorField& field : themeColorFields()) {
|
||||
const RGBA& color = theme.palette.*field.member;
|
||||
source += "theme.palette." + std::string(field.name) + " = { " + number(color.r) + ", " + number(color.g) + ", " +
|
||||
number(color.b) + ", " + number(color.a) + " };\n";
|
||||
}
|
||||
for (const ThemeMetricField& field : themeMetricFields())
|
||||
source += "theme.metrics." + std::string(field.name) + " = " + number(theme.metrics.*field.member) + ";\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
ColorSwatch::ColorSwatch() { setDebugName("color swatch"); }
|
||||
|
||||
void ColorSwatch::setColor(const RGBA& color) {
|
||||
mColor = color;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
Extent ColorSwatch::measureContent(Axis, halnf) const { return Extent::rigid(kSwatchSize); }
|
||||
|
||||
void ColorSwatch::paint(Painter& painter) {
|
||||
const RectF box = localRect();
|
||||
const halnf radius = theme().metrics.indicatorCornerRadius;
|
||||
const halnf half = box.size.x / 2;
|
||||
painter.fillRect(box, { 1, 1, 1, 1 }, radius);
|
||||
painter.fillRect(makeRect(0, 0, half, half), { 0.75f, 0.75f, 0.75f, 1 }, radius / 2);
|
||||
painter.fillRect(makeRect(half, half, half, half), { 0.75f, 0.75f, 0.75f, 1 }, radius / 2);
|
||||
painter.fillRect(box, mColor, radius);
|
||||
painter.strokeRect(box, theme().palette.controlBorder, theme().metrics.indicatorCornerRadius);
|
||||
}
|
||||
|
||||
ThemeColorRow::ThemeColorRow(const ThemeColorField& field) :
|
||||
mField(field) {
|
||||
setDebugName(std::string("theme color: ") + field.name);
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
emplaceLayout<RowLayout>(6).setCrossAlign(CrossAlign::Center);
|
||||
|
||||
name.setText(field.name);
|
||||
name.setStyle(Button::Style::Flat);
|
||||
name.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
hex.setTone(Label::Tone::Muted);
|
||||
|
||||
addChild(swatch);
|
||||
addChild(name);
|
||||
addChild(hex);
|
||||
}
|
||||
|
||||
void ThemeColorRow::refresh(const Theme& theme) {
|
||||
const RGBA& color = theme.palette.*mField.member;
|
||||
swatch.setColor(color);
|
||||
hex.setText(colorHex(color));
|
||||
}
|
||||
|
||||
void ThemeColorRow::setSelected(bool selected) { name.setSelected(selected); }
|
||||
|
||||
ThemeEditorPage::ThemeEditorPage() {
|
||||
setDebugName("theme editor page");
|
||||
RowLayout& layout = emplaceLayout<RowLayout>(16);
|
||||
layout.setPadding(Insets::uniform(16));
|
||||
|
||||
colorList.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
colorList.emplaceLayout<ColumnLayout>(1);
|
||||
for (size_t index = 0; index < themeColorFields().size(); index++) {
|
||||
colorRows.push_back(std::make_unique<ThemeColorRow>(themeColorFields()[index]));
|
||||
colorRows.back()->name.setOnActivate([this, index]() { selectColor(index); });
|
||||
colorRows.back()->swatch.setOnActivate([this, index]() { selectColor(index); });
|
||||
colorList.addChild(*colorRows.back());
|
||||
}
|
||||
colorScroll.setContent(&colorList);
|
||||
colorScroll.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
|
||||
mColorColumn.setSizing(AxisSizing::fixed(kColorColumnWidth), AxisSizing::fill());
|
||||
mColorColumn.emplaceLayout<ColumnLayout>(6);
|
||||
mColorColumn.addChild(colorScroll);
|
||||
|
||||
wheel.setSizing(AxisSizing::fill(), AxisSizing::fixed(200));
|
||||
wheel.setOnChanged([this](const RGBA& color) {
|
||||
edit([this, &color](Theme& theme) {
|
||||
RGBA& target = theme.palette.*themeColorFields()[mSelected].member;
|
||||
target = { color.r, color.g, color.b, target.a };
|
||||
});
|
||||
});
|
||||
alpha.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
alpha.setValueFormatter(twoDecimals);
|
||||
alpha.setOnChanged([this](halnf value) {
|
||||
edit([this, value](Theme& theme) { (theme.palette.*themeColorFields()[mSelected].member).a = value; });
|
||||
});
|
||||
|
||||
mEditorColumn.setSizing(AxisSizing::fixed(kEditorColumnWidth), AxisSizing::fit());
|
||||
mEditorColumn.emplaceLayout<ColumnLayout>(8);
|
||||
mEditorColumn.addChild(editingTitle);
|
||||
mEditorColumn.addChild(wheel);
|
||||
mEditorColumn.addChild(alpha);
|
||||
mEditorColumn.addChild(mSeparator);
|
||||
|
||||
for (const ThemeMetricField& field : themeMetricFields()) {
|
||||
metricSliders.push_back(std::make_unique<LabeledSlider>(field.name, field.minimum, field.maximum, 0));
|
||||
LabeledSlider& slider = *metricSliders.back();
|
||||
slider.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
slider.setValueFormatter([](halnf value) { return std::to_string((int) std::lround(value)); });
|
||||
slider.slider().setStep(1);
|
||||
halnf Metrics::* member = field.member;
|
||||
slider.setOnChanged([this, member](halnf value) {
|
||||
edit([member, value](Theme& theme) { theme.metrics.*member = value; });
|
||||
});
|
||||
mEditorColumn.addChild(slider);
|
||||
}
|
||||
|
||||
accentPreset.setTooltip("Accent preset");
|
||||
accentPreset.setOnSelected([this](long index) {
|
||||
if (index < 0 || index >= 5) return;
|
||||
edit([index](Theme& theme) { theme.palette.setAccent(kAccentPresets[index]); });
|
||||
});
|
||||
reset.setOnActivate([this]() {
|
||||
edit([](Theme& theme) { theme = Theme::standard(); });
|
||||
status.setText("Reset to the standard theme");
|
||||
});
|
||||
copySource.setOnActivate([this]() {
|
||||
if (ui() && ui()->windowHost()) ui()->windowHost()->setClipboardText(themeSource(editedTheme()));
|
||||
status.setText("Copied theme as C++");
|
||||
});
|
||||
status.setTone(Label::Tone::Muted);
|
||||
|
||||
mActionRow.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mActionRow.emplaceLayout<RowLayout>(6).setCrossAlign(CrossAlign::Center);
|
||||
mActionRow.addChild(accentPreset);
|
||||
mActionRow.addChild(reset);
|
||||
mActionRow.addChild(copySource);
|
||||
mEditorColumn.addChild(mActionRow);
|
||||
mEditorColumn.addChild(status);
|
||||
|
||||
addChild(mColorColumn);
|
||||
addChild(mEditorColumn);
|
||||
refreshEditors();
|
||||
}
|
||||
|
||||
Theme ThemeEditorPage::editedTheme() const { return ui() ? ui()->theme() : Theme::standard(); }
|
||||
|
||||
void ThemeEditorPage::onAttached() { refreshEditors(); }
|
||||
|
||||
void ThemeEditorPage::onThemeChanged() {
|
||||
Widget::onThemeChanged();
|
||||
refreshEditors();
|
||||
}
|
||||
|
||||
void ThemeEditorPage::selectColor(size_t index) {
|
||||
if (index >= colorRows.size()) return;
|
||||
mSelected = index;
|
||||
refreshEditors();
|
||||
}
|
||||
|
||||
template <typename Edit>
|
||||
void ThemeEditorPage::edit(Edit&& change) {
|
||||
if (mRefreshing || !ui()) return;
|
||||
|
||||
Theme theme = ui()->theme();
|
||||
change(theme);
|
||||
ui()->setTheme(theme);
|
||||
}
|
||||
|
||||
void ThemeEditorPage::refreshEditors() {
|
||||
const Theme theme = editedTheme();
|
||||
mRefreshing = true;
|
||||
|
||||
for (size_t index = 0; index < colorRows.size(); index++) {
|
||||
colorRows[index]->refresh(theme);
|
||||
colorRows[index]->setSelected(index == mSelected);
|
||||
}
|
||||
|
||||
const ThemeColorField& field = themeColorFields()[mSelected];
|
||||
const RGBA& color = theme.palette.*field.member;
|
||||
editingTitle.setText(std::string("Editing ") + field.name);
|
||||
wheel.setColor(color);
|
||||
alpha.setValue(color.a);
|
||||
|
||||
for (size_t index = 0; index < metricSliders.size(); index++)
|
||||
metricSliders[index]->setValue(theme.metrics.*themeMetricFields()[index].member);
|
||||
|
||||
mRefreshing = false;
|
||||
}
|
||||
102
Widgets/examples/ThemeEditor.hpp
Normal file
102
Widgets/examples/ThemeEditor.hpp
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
#pragma once
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "Menus.hpp"
|
||||
#include "Slider.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Views.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
struct ThemeColorField {
|
||||
const char* name;
|
||||
RGBA Palette::* member;
|
||||
};
|
||||
|
||||
struct ThemeMetricField {
|
||||
const char* name;
|
||||
halnf Metrics::* member;
|
||||
halnf minimum;
|
||||
halnf maximum;
|
||||
};
|
||||
|
||||
const std::vector<ThemeColorField>& themeColorFields();
|
||||
const std::vector<ThemeMetricField>& themeMetricFields();
|
||||
std::string colorHex(const RGBA& color);
|
||||
std::string themeSource(const Theme& theme);
|
||||
|
||||
class ColorSwatch : public Control {
|
||||
public:
|
||||
ColorSwatch();
|
||||
|
||||
void setColor(const RGBA& color);
|
||||
[[nodiscard]] const RGBA& color() const { return mColor; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Painter& painter) override;
|
||||
|
||||
private:
|
||||
RGBA mColor;
|
||||
};
|
||||
|
||||
class ThemeColorRow : public Widget {
|
||||
public:
|
||||
explicit ThemeColorRow(const ThemeColorField& field);
|
||||
|
||||
void refresh(const Theme& theme);
|
||||
void setSelected(bool selected);
|
||||
|
||||
ColorSwatch swatch;
|
||||
Button name;
|
||||
Label hex;
|
||||
|
||||
private:
|
||||
const ThemeColorField& mField;
|
||||
};
|
||||
|
||||
class ThemeEditorPage : public Widget {
|
||||
public:
|
||||
ThemeEditorPage();
|
||||
|
||||
[[nodiscard]] size_t selectedColor() const { return mSelected; }
|
||||
void selectColor(size_t index);
|
||||
[[nodiscard]] Theme editedTheme() const;
|
||||
|
||||
ScrollView colorScroll;
|
||||
Widget colorList;
|
||||
std::vector<std::unique_ptr<ThemeColorRow>> colorRows;
|
||||
|
||||
Label editingTitle;
|
||||
ColorWheel wheel;
|
||||
LabeledSlider alpha{ "Alpha", 0, 1, 1 };
|
||||
std::vector<std::unique_ptr<LabeledSlider>> metricSliders;
|
||||
Dropdown accentPreset{ { "Blue", "Purple", "Green", "Orange", "Pink" } };
|
||||
Button reset{ "Reset to standard" };
|
||||
Button copySource{ "Copy as C++" };
|
||||
Label status;
|
||||
|
||||
protected:
|
||||
void onAttached() override;
|
||||
void onThemeChanged() override;
|
||||
|
||||
private:
|
||||
template <typename Edit>
|
||||
void edit(Edit&& change);
|
||||
void refreshEditors();
|
||||
|
||||
size_t mSelected = 0;
|
||||
bool mRefreshing = false;
|
||||
Widget mColorColumn;
|
||||
Widget mEditorColumn;
|
||||
Widget mActionRow;
|
||||
Separator mSeparator;
|
||||
};
|
||||
}
|
||||
BIN
Widgets/examples/rsc/markdown/pipeline.png
Normal file
BIN
Widgets/examples/rsc/markdown/pipeline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -1,96 +0,0 @@
|
|||
#include <algorithm>
|
||||
#include "Layout.hpp"
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
const RectF& WidgetLayout::getArea() const {
|
||||
return mWidget->mAreaCache;
|
||||
}
|
||||
|
||||
RectF WidgetLayout::getAnimatedArea() const {
|
||||
return mWidget->getArea();
|
||||
}
|
||||
|
||||
void WidgetLayout::setArea(const RectF& area) {
|
||||
mWidget->setAreaCache(area);
|
||||
}
|
||||
|
||||
Widget* WidgetLayout::parent() const { return mWidget->mParent; }
|
||||
|
||||
const std::vector<Widget*>& WidgetLayout::children() const { return mWidget->mChildren; }
|
||||
|
||||
const Vec2F& WidgetLayout::getMinSize() { return mMinSize; }
|
||||
|
||||
void WidgetLayout::setMinSize(const Vec2F& size) { mMinSize = size; }
|
||||
|
||||
const Vec2<SizePolicy>& WidgetLayout::getSizePolicy() const { return mSizePolicy; }
|
||||
|
||||
void WidgetLayout::setSizePolicy(SizePolicy x, SizePolicy y) { mSizePolicy = { x, y }; }
|
||||
|
||||
RangeF WidgetLayout::pickRange(const RangeF& current, const RangeF& children, const RangeF& parent, bool vertical) const {
|
||||
RangeF out;
|
||||
|
||||
switch (mSizePolicy[vertical]) {
|
||||
case SizePolicy::Fixed: out = current; break;
|
||||
case SizePolicy::Expanding: out = parent; break;
|
||||
case SizePolicy::Minimal: out = children; break;
|
||||
}
|
||||
|
||||
out = clampRange(out, children, parent, vertical);
|
||||
return out;
|
||||
}
|
||||
|
||||
void WidgetLayout::clampMinMaxSize() {
|
||||
auto current = getArea();
|
||||
current.size.clamp(mMinSize, mMaxSize);
|
||||
setArea(current);
|
||||
}
|
||||
|
||||
RangeF WidgetLayout::clampRange(const RangeF& current, const RangeF& child, const RangeF& parent, bool vertical) const {
|
||||
auto out = current;
|
||||
|
||||
if (!children().empty()) {
|
||||
auto clampedChild = child;
|
||||
clampedChild.clamp(parent);
|
||||
out.clamp(clampedChild, parent);
|
||||
} else {
|
||||
out.clamp(parent);
|
||||
}
|
||||
|
||||
// clamp min max sizes
|
||||
auto len = clamp(current.size(), mMinSize[vertical], mMaxSize[vertical]);
|
||||
if (len != current.size()) {
|
||||
out.resizeFromCenter(len);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
RectF WidgetLayout::getChildrenEnclosure() const {
|
||||
RectF out;
|
||||
|
||||
if (children().empty()) {
|
||||
out = { getArea().center(), { 0, 0 } };
|
||||
} else {
|
||||
out = children().front()->getLayout()->getArea();
|
||||
for (auto child : children()) {
|
||||
out.expand(child->getLayout()->getArea());
|
||||
}
|
||||
out.pos += getArea().pos;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
RectF WidgetLayout::getAvailableChildArea() const {
|
||||
return getArea().relative();
|
||||
}
|
||||
|
||||
RectF WidgetLayout::getParentEnclosure() const {
|
||||
DEBUG_ASSERT(parent())
|
||||
if (!parent()) return { { 0, 0 }, mMaxSize };
|
||||
auto out = parent()->getLayout()->getAvailableChildArea();
|
||||
return out;
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
|
||||
#include "RootWidget.hpp"
|
||||
|
||||
#include "BasicLayout.hpp"
|
||||
#include "SimpleLayouts.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace tp;
|
||||
|
||||
WidgetLayout* WidgetManagerInterface::defaultLayout(Widget* widget) {
|
||||
return new BasicLayout(widget);
|
||||
}
|
||||
|
||||
RootWidget::RootWidget() {
|
||||
|
||||
addChild(&mRoot);
|
||||
|
||||
mRoot.addChild(&mPopups);
|
||||
mRoot.setLayout(new OverlayLayout(&mRoot));
|
||||
|
||||
dynamic_cast<BasicLayout*>(mPopups.getLayout())->setLayoutPolicy(LayoutPolicy::Passive);
|
||||
}
|
||||
|
||||
void RootWidget::setRootWidget(Widget* widget) {
|
||||
mRoot.removeChild(mUserRoot);
|
||||
mRoot.addChild(widget);
|
||||
|
||||
widget->bringToBack();
|
||||
|
||||
mUserRoot = widget;
|
||||
}
|
||||
|
||||
void RootWidget::processFrame(const InputState& input, const RectF& screenArea) {
|
||||
mRoot.setArea(screenArea);
|
||||
|
||||
mUpdateManager.updateTreeToProcess(&mRoot);
|
||||
|
||||
updateAnimations();
|
||||
updateAreaCache(&mRoot, true);
|
||||
|
||||
mUpdateManager.processWidgets(&mRoot, input);
|
||||
|
||||
updateAreaCache(&mRoot, true);
|
||||
|
||||
mLayoutManager.adjust(&mRoot);
|
||||
|
||||
updateAreaCache(&mRoot, false);
|
||||
|
||||
// trigger some widgets by moise pointer
|
||||
mUpdateManager.handleFocusChanges(&mRoot, InputView(input));
|
||||
|
||||
// check triggered widgets for removal
|
||||
mUpdateManager.clean();
|
||||
}
|
||||
|
||||
bool RootWidget::needsUpdate() const {
|
||||
return mUpdateManager.isPendingUpdates();
|
||||
}
|
||||
|
||||
void RootWidget::drawFrame(Canvas& canvas) {
|
||||
canvas.rect(mRoot.getAreaT(), RGBA(0, 0, 0, 1));
|
||||
drawRecursion(canvas, &mRoot, { 0, 0 });
|
||||
}
|
||||
|
||||
void RootWidget::drawRecursion(Canvas& canvas, Widget* active, const Vec2F& pos) {
|
||||
if (!active->mFlags.get(ENABLED)) return;
|
||||
|
||||
{
|
||||
const Canvas::Placement placement(canvas, pos - canvas.origin(), active->getArea().size);
|
||||
if (!canvas.isClippedAway()) {
|
||||
if (active->getArea().sizeVecW() > active->getArea().pos) {
|
||||
active->draw(canvas);
|
||||
}
|
||||
|
||||
for (auto child = active->mDepthOrder.lastNode(); child; child = child->prev) {
|
||||
drawRecursion(canvas, child->data, pos + child->data->getArea().pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Canvas::Translation overlay(canvas, pos - canvas.origin());
|
||||
active->drawOverlay(canvas);
|
||||
}
|
||||
|
||||
void RootWidget::setWidgetArea(Widget& widget, const RectF& rect) {
|
||||
widget.mArea.setTargetRect(rect);
|
||||
widget.mArea.endAnimation();
|
||||
}
|
||||
|
||||
void RootWidget::updateAnimations() {
|
||||
dfs(&mRoot, [](Widget* widget) {
|
||||
widget->updateAnimations();
|
||||
});
|
||||
}
|
||||
|
||||
void RootWidget::updateAreaCache(Widget* iter, bool read) {
|
||||
if (!iter || !iter->isUpdate()) return;
|
||||
|
||||
if (read) {
|
||||
iter->mAreaCache = iter->getAreaT();
|
||||
} else {
|
||||
iter->setArea(iter->mAreaCache);
|
||||
iter->mArea.updateCurrentRect();
|
||||
}
|
||||
|
||||
for (auto child : iter->mChildren) {
|
||||
if (read) {
|
||||
child->mAreaCache = child->getAreaT();
|
||||
} else {
|
||||
child->setArea(child->mAreaCache);
|
||||
child->mArea.updateCurrentRect();
|
||||
}
|
||||
}
|
||||
|
||||
for (auto child : iter->mDepthOrder) {
|
||||
updateAreaCache(child.data(), read);
|
||||
}
|
||||
}
|
||||
|
||||
void RootWidget::updateWidget(Widget* widget, const char* reason) {
|
||||
DEBUG_ASSERT(reason)
|
||||
mUpdateManager.scheduleUpdate(widget, reason);
|
||||
}
|
||||
|
||||
void RootWidget::openPopup(Widget* widget) {
|
||||
auto relativeArea = widget->getAreaT();
|
||||
for (auto iter = widget->mParent; iter && iter->mParent; iter = iter->mParent) {
|
||||
relativeArea.pos += iter->getAreaT().pos;
|
||||
}
|
||||
setWidgetArea(*widget, relativeArea);
|
||||
|
||||
mPopups.addChild(widget);
|
||||
mUpdateManager.lockFocus(widget);
|
||||
}
|
||||
|
||||
void RootWidget::closePopup(Widget* widget) {
|
||||
mPopups.removeChild(widget);
|
||||
mUpdateManager.freeFocus(widget);
|
||||
}
|
||||
|
||||
void RootWidget::lockFocus(Widget* widget) { mUpdateManager.lockFocus(widget); }
|
||||
void RootWidget::freeFocus(Widget* widget) { mUpdateManager.freeFocus(widget); }
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
#include "Widget.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace tp;
|
||||
|
||||
Widget::Widget() {
|
||||
mArea.setTargetRect({ 100, 100, 10, 10 });
|
||||
setLayout(WidgetManagerInterface::defaultLayout(this));
|
||||
mArea.endAnimation();
|
||||
|
||||
mFlags.set(ENABLED, true);
|
||||
}
|
||||
|
||||
Widget::~Widget() {
|
||||
delete mLayout;
|
||||
}
|
||||
|
||||
void Widget::addChild(Widget* child, bool front) {
|
||||
if (auto node = mDepthOrder.find(child)) {
|
||||
node->data->bringToFront();
|
||||
return;
|
||||
}
|
||||
|
||||
if (child->mParent) {
|
||||
child->mParent->removeChild(child);
|
||||
}
|
||||
|
||||
mChildren.push_back(child);
|
||||
|
||||
if (front) {
|
||||
mDepthOrder.pushFront(child);
|
||||
} else {
|
||||
mDepthOrder.pushBack(child);
|
||||
}
|
||||
|
||||
child->mParent = this;
|
||||
|
||||
triggerWidgetUpdate("add child");
|
||||
child->triggerWidgetUpdate("new parent");
|
||||
}
|
||||
|
||||
void Widget::removeChild(Widget* child) {
|
||||
auto node = mDepthOrder.find(child);
|
||||
if (!node) return;
|
||||
|
||||
mDepthOrder.removeNode(node);
|
||||
mChildren.erase(std::remove(mChildren.begin(), mChildren.end(), child), mChildren.end());
|
||||
|
||||
triggerWidgetUpdate("removed child");
|
||||
child->triggerWidgetUpdate("parent changed");
|
||||
}
|
||||
|
||||
void Widget::clear() {
|
||||
mChildren.clear();
|
||||
mDepthOrder.removeAll();
|
||||
}
|
||||
|
||||
void Widget::endAnimations() { mArea.endAnimation(); }
|
||||
bool Widget::processesEvents() const { return false; }
|
||||
void Widget::updateAnimations() { mArea.updateCurrentRect(); }
|
||||
bool Widget::needsNextFrame() const { return !mArea.shouldEndTransition(); }
|
||||
|
||||
void Widget::bringToFront() {
|
||||
if (!mParent) return;
|
||||
auto& order = mParent->mDepthOrder;
|
||||
auto node = order.find(this);
|
||||
DEBUG_ASSERT(node)
|
||||
order.detach(node);
|
||||
order.pushFront(node);
|
||||
}
|
||||
|
||||
void Widget::bringToBack() {
|
||||
if (!mParent) return;
|
||||
auto& order = mParent->mDepthOrder;
|
||||
auto node = order.find(this);
|
||||
DEBUG_ASSERT(node)
|
||||
order.detach(node);
|
||||
order.pushBack(node);
|
||||
}
|
||||
|
||||
void Widget::mouseEnter() { mFlags.set(IN_FOCUS, true); }
|
||||
void Widget::mouseLeave() { mFlags.set(IN_FOCUS, false); }
|
||||
|
||||
bool Widget::propagateEventsToChildren() const { return true; }
|
||||
|
||||
void Widget::setLayout(tp::WidgetLayout* layout) {
|
||||
delete mLayout;
|
||||
mLayout = layout;
|
||||
triggerWidgetUpdate("chane layout");
|
||||
}
|
||||
|
||||
WidgetLayout* Widget::getLayout() { return mLayout; }
|
||||
|
||||
const WidgetLayout* Widget::getLayout() const { return mLayout; }
|
||||
|
||||
WidgetManagerInterface* Widget::getRoot() {
|
||||
Widget* iter = mParent;
|
||||
while (iter && iter->mParent) {
|
||||
iter = iter->mParent;
|
||||
}
|
||||
return dynamic_cast<WidgetManagerInterface*>(iter);
|
||||
}
|
||||
|
||||
void Widget::setAreaCache(const tp::RectF& area) { mAreaCache = area; }
|
||||
|
||||
void Widget::setArea(const RectF& area) {
|
||||
if (mArea.getTargetRect() == area) return;
|
||||
|
||||
mArea.setTargetRect(area);
|
||||
triggerWidgetUpdate("new area");
|
||||
}
|
||||
|
||||
RectF Widget::getArea() const { return mArea.getCurrentRect(); }
|
||||
|
||||
RectF Widget::getAreaT() const { return mArea.getTargetRect(); }
|
||||
|
||||
RectF Widget::getRelativeArea() const { return { {}, mArea.getCurrentRect().size }; }
|
||||
|
||||
RectF Widget::getRelativeAreaT() const { return { {}, mArea.getTargetRect().size }; }
|
||||
|
||||
void Widget::setSizePolicy(SizePolicy x, SizePolicy y) {
|
||||
getLayout()->setSizePolicy(x, y);
|
||||
}
|
||||
|
||||
void Widget::triggerWidgetUpdate(const char* reason) {
|
||||
if (auto root = getRoot()) {
|
||||
root->updateWidget(this, reason);
|
||||
}
|
||||
}
|
||||
|
||||
void Widget::openPopup(Widget* widget) {
|
||||
addChild(widget);
|
||||
getRoot()->openPopup(widget);
|
||||
}
|
||||
|
||||
void Widget::closePopup(Widget* widget) {
|
||||
getRoot()->closePopup(widget);
|
||||
}
|
||||
|
||||
void Widget::lockFocus() {
|
||||
getRoot()->lockFocus(this);
|
||||
}
|
||||
|
||||
void Widget::freeFocus() {
|
||||
getRoot()->freeFocus(this);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
#include "WidgetApplication.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
WidgetApplication::WidgetApplication(const ApplicationOptions& options) :
|
||||
mApplication(*this, options) {}
|
||||
|
||||
void WidgetApplication::setRoot(Widget* widget) {
|
||||
mRootWidget.setRootWidget(widget);
|
||||
|
||||
tp::RootWidget::setWidgetArea(*widget, { {}, window().contentSize() });
|
||||
}
|
||||
|
||||
void WidgetApplication::processFrame(const InputState& input, halnf) {
|
||||
mRootWidget.processFrame(input, RectF({ 0, 0 }, window().contentSize()));
|
||||
}
|
||||
|
||||
void WidgetApplication::drawFrame(Canvas& canvas) { mRootWidget.drawFrame(canvas); }
|
||||
|
||||
bool WidgetApplication::wantsFrame() { return mRootWidget.needsUpdate(); }
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
|
||||
#include "BasicLayout.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace tp;
|
||||
|
||||
|
||||
void BasicLayout::pickRect(bool vertical) {
|
||||
auto current = getArea();
|
||||
auto children = getChildrenEnclosure();
|
||||
auto parent = getParentEnclosure();
|
||||
|
||||
children.shrinkFromCenter(-mLayoutMargin, true);
|
||||
|
||||
RectF newArea;
|
||||
if (vertical) {
|
||||
auto rangeY = pickRange(current.getRangeY(), children.getRangeY(), parent.getRangeY(), true);
|
||||
newArea = RectF(current.getRangeX(), rangeY);
|
||||
} else {
|
||||
auto rangeX = pickRange(current.getRangeX(), children.getRangeX(), parent.getRangeX(), false);
|
||||
newArea = RectF(rangeX, current.getRangeY());
|
||||
}
|
||||
|
||||
setArea(newArea);
|
||||
}
|
||||
|
||||
void BasicLayout::clampRect() {
|
||||
DEBUG_ASSERT(0)
|
||||
|
||||
auto current = getArea();
|
||||
auto children = getChildrenEnclosure();
|
||||
auto parent = getParentEnclosure();
|
||||
|
||||
auto rangeX = clampRange(current.getRangeX(), children.getRangeX(), parent.getRangeX(), false);
|
||||
auto rangeY = clampRange(current.getRangeY(), children.getRangeY(), parent.getRangeY(), true);
|
||||
|
||||
setArea(RectF(rangeX, rangeY));
|
||||
}
|
||||
|
||||
void BasicLayout::updateLayout(bool vertical) {
|
||||
if (children().empty()) return;
|
||||
|
||||
if (vertical) {
|
||||
switch (mLayoutPolicy) {
|
||||
case LayoutPolicy::Vertical: adjustLayout(true); break;
|
||||
case LayoutPolicy::Passive:
|
||||
case LayoutPolicy::Horizontal: break;
|
||||
}
|
||||
} else {
|
||||
switch (mLayoutPolicy) {
|
||||
case LayoutPolicy::Horizontal: adjustLayout(false); break;
|
||||
case LayoutPolicy::Passive:
|
||||
case LayoutPolicy::Vertical: break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
halnf BasicLayout::changeChildSize(tp::Widget* widget, halnf diff, bool vertical) {
|
||||
auto prevSize = widget->getLayout()->getArea().size[vertical];
|
||||
{
|
||||
auto area = widget->getLayout()->getArea();
|
||||
area.size[vertical] += diff;
|
||||
widget->setAreaCache(area);
|
||||
|
||||
widget->getLayout()->clampMinMaxSize();
|
||||
}
|
||||
auto newSize = widget->getLayout()->getArea().size[vertical];
|
||||
return newSize - prevSize;
|
||||
}
|
||||
|
||||
void BasicLayout::adjustLayout(bool vertical) {
|
||||
std::vector<std::pair<Widget*, bool>> contributors;
|
||||
Vec2F contentSize = { 0, 0 };
|
||||
Vec2F availableSize = getArea().size;
|
||||
|
||||
for (auto child : children()) {
|
||||
child->getLayout()->pickRect(vertical);
|
||||
|
||||
if (child->getLayout()->getSizePolicy()[vertical] == SizePolicy::Expanding) {
|
||||
contributors.emplace_back( child, true );
|
||||
|
||||
auto area = child->getLayout()->getArea();
|
||||
area.size[vertical] = 0;
|
||||
child->setAreaCache(area);
|
||||
child->getLayout()->clampMinMaxSize();
|
||||
}
|
||||
|
||||
contentSize += child->getLayout()->getArea().size;
|
||||
}
|
||||
|
||||
availableSize -= mLayoutGap * ((halnf) children().size() - 1) + mLayoutMargin * 2;
|
||||
|
||||
auto diff = availableSize - contentSize;
|
||||
|
||||
// expand or contract as much as possible
|
||||
while (!contributors.empty() && (diff[vertical] != 0)) {
|
||||
auto quota = diff / (halnf) contributors.size();
|
||||
|
||||
for (auto& contributor : contributors) {
|
||||
if (!contributor.second) continue;
|
||||
|
||||
// contributor.first->endAnimations();
|
||||
auto contribution = changeChildSize(contributor.first, quota[vertical], vertical);
|
||||
|
||||
if (contribution == 0) {
|
||||
contributor.second = false;
|
||||
}
|
||||
|
||||
diff[vertical] -= contribution;
|
||||
}
|
||||
|
||||
contributors.erase(
|
||||
std::remove_if(contributors.begin(), contributors.end(), [](auto node) { return !node.second; }),
|
||||
contributors.end()
|
||||
);
|
||||
}
|
||||
|
||||
// set opposite direction size
|
||||
for (auto child : children()) {
|
||||
// if (child->getLayout()->getSizePolicy()[!vertical] != SizePolicy::Minimal) {
|
||||
auto area = child->getLayout()->getArea();
|
||||
area.size[!vertical] = getArea().size[!vertical] - mLayoutMargin * 2;
|
||||
child->setAreaCache(area);
|
||||
// }
|
||||
}
|
||||
|
||||
// set pos
|
||||
halnf iterPos = mLayoutMargin;
|
||||
for (auto child : children()) {
|
||||
auto area = child->getLayout()->getArea();
|
||||
|
||||
area.pos[vertical] = iterPos;
|
||||
area.pos[!vertical] = mLayoutMargin;
|
||||
|
||||
iterPos += area.size[vertical] + mLayoutGap;
|
||||
child->setAreaCache(area);
|
||||
|
||||
// child->updateAnimations();
|
||||
// child->triggerWidgetUpdate("layout changed");
|
||||
}
|
||||
}
|
||||
|
||||
RectF BasicLayout::getAvailableChildArea() const {
|
||||
auto out = WidgetLayout::getAvailableChildArea();
|
||||
return out.shrinkFromCenter(mLayoutMargin, true);
|
||||
}
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
#include "DockLayout.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
DockLayout::DockLayout(Widget* widget) :
|
||||
WidgetLayout(widget) {
|
||||
mSideWidgets[0].side = LEFT;
|
||||
mSideWidgets[1].side = TOP;
|
||||
mSideWidgets[2].side = RIGHT;
|
||||
mSideWidgets[3].side = BOTTOM;
|
||||
}
|
||||
|
||||
bool DockLayout::setCenterWidget(Widget* widget) {
|
||||
if (mCenterWidget) return false;
|
||||
mCenterWidget = widget;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DockLayout::dockWidget(Widget* widget, Side side) {
|
||||
if (sideExists(side) || side == Side::NONE) return false;
|
||||
|
||||
auto& sideWidget = mSideWidgets[side];
|
||||
sideWidget.widget = widget;
|
||||
|
||||
for (auto& order : mSideWidgets) {
|
||||
if (order.order == -1) {
|
||||
order.order = side;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sideWidget.hidden = false;
|
||||
sideWidget.areaBeforeDocking = widget->getArea();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DockLayout::undockWidget(Side side) {
|
||||
if (!sideExists(side) || (side == Side::NONE)) return false;
|
||||
|
||||
bool removed = false;
|
||||
for (ualni i = 0; i < 3; i++) {
|
||||
if (mSideWidgets[i].order == side) {
|
||||
removed = true;
|
||||
}
|
||||
if (removed) {
|
||||
swapV(mSideWidgets[i].order, mSideWidgets[i + 1].order);
|
||||
}
|
||||
}
|
||||
mSideWidgets[3].order = -1;
|
||||
mSideWidgets[side].widget = nullptr;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void DockLayout::toggleWidgetVisibility(Side side) {
|
||||
DEBUG_ASSERT(sideExists(side))
|
||||
mSideWidgets[side].hidden = !mSideWidgets[side].hidden;
|
||||
}
|
||||
|
||||
void DockLayout::calculateSideAreas() {
|
||||
auto startArea = getArea().relative();
|
||||
|
||||
if (startArea.size.x <= 0 || startArea.size.y <= 0) return;
|
||||
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
const auto side = sideWidget.order;
|
||||
|
||||
if (side == -1) break;
|
||||
if (!isSideVisible(Side(side))) continue;
|
||||
|
||||
bool vertical = side == TOP || side == BOTTOM;
|
||||
bool opposite = side == BOTTOM || side == RIGHT;
|
||||
|
||||
auto& sideSize = mSideWidgets[side].absoluteSize;
|
||||
|
||||
auto factor = sideSize / startArea.size[vertical];
|
||||
if (opposite) factor = factor * -1 + 1;
|
||||
auto& area = mSideWidgets[side].area;
|
||||
|
||||
if (!vertical) {
|
||||
const auto first = startArea.splitByFactorHL(factor);
|
||||
const auto second = startArea.splitByFactorHR(factor);
|
||||
area = side == LEFT ? first : second;
|
||||
startArea = side == LEFT ? second : first;
|
||||
} else {
|
||||
const auto first = startArea.splitByFactorVT(factor);
|
||||
const auto second = startArea.splitByFactorVB(factor);
|
||||
area = side == TOP ? first : second;
|
||||
startArea = side == TOP ? second : first;
|
||||
}
|
||||
|
||||
area = area.shrink(mPadding);
|
||||
}
|
||||
|
||||
mCenterArea = startArea.shrink(mPadding);
|
||||
}
|
||||
|
||||
void DockLayout::calculateResizeHandles() {
|
||||
RectF rec;
|
||||
RectF area = getArea().relative();
|
||||
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
if (!sideWidget.widget) continue;
|
||||
|
||||
auto& sideSize = sideWidget.absoluteSize;
|
||||
auto& resizeHandle = sideWidget.resizeHandle;
|
||||
|
||||
if (resizeHandle.end < mSideSizePadding * 2) {
|
||||
// sideSize = resizeHandle.end / 2.f;
|
||||
} else {
|
||||
sideSize = clamp(sideSize, resizeHandle.start + mSideSizePadding, resizeHandle.end - mSideSizePadding);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSideVisible(LEFT)) {
|
||||
auto& side = mSideWidgets[LEFT];
|
||||
rec = { side.area.p4(), { mPadding * 2, side.area.size.y } };
|
||||
side.resizeHandle.area = rec;
|
||||
side.resizeHandle.start = 0;
|
||||
side.resizeHandle.end = mCenterArea.p3().x;
|
||||
}
|
||||
if (isSideVisible(RIGHT)) {
|
||||
auto& side = mSideWidgets[RIGHT];
|
||||
rec = { side.area.p1(), { mPadding * 2, side.area.size.y } };
|
||||
rec.x -= mPadding * 2;
|
||||
side.resizeHandle.area = rec;
|
||||
side.resizeHandle.start = 0;
|
||||
side.resizeHandle.end = (area.p3() - mCenterArea.p1()).x;
|
||||
}
|
||||
if (isSideVisible(TOP)) {
|
||||
auto& side = mSideWidgets[TOP];
|
||||
rec = { side.area.p2(), { side.area.size.x, mPadding * 2 } };
|
||||
side.resizeHandle.area = rec;
|
||||
side.resizeHandle.start = 0;
|
||||
side.resizeHandle.end = mCenterArea.p2().y;
|
||||
}
|
||||
if (isSideVisible(BOTTOM)) {
|
||||
auto& side = mSideWidgets[BOTTOM];
|
||||
rec = { side.area.p1(), { side.area.size.x, mPadding * 2 } };
|
||||
rec.y -= mPadding * 2;
|
||||
side.resizeHandle.area = rec;
|
||||
side.resizeHandle.start = 0;
|
||||
side.resizeHandle.end = (area.p3() - mCenterArea.p1()).y;
|
||||
}
|
||||
}
|
||||
|
||||
void DockLayout::updateChildSideWidgets() {
|
||||
for (ualni i = 0; i < 4; i++) {
|
||||
if (!sideExists(Side(i))) continue;
|
||||
auto widget = mSideWidgets[i].widget;
|
||||
|
||||
if (!isSideVisible(Side(i))) {
|
||||
// FIXME : widget->mEnable = false;
|
||||
} else {
|
||||
widget->setAreaCache(mSideWidgets[i].area);
|
||||
// FIXME : widget->mEnable = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (mCenterWidget) mCenterWidget->setAreaCache(mCenterArea);
|
||||
}
|
||||
|
||||
void DockLayout::calculateHeaderAreas() {
|
||||
for (ualni i = 0; i < 4; i++) {
|
||||
if (!isSideVisible(Side(i))) continue;
|
||||
auto& area = mSideWidgets[i].area;
|
||||
const auto factor = mHeaderSize / area.size.y;
|
||||
mSideWidgets[i].headerArea = area.splitByFactorVT(factor);
|
||||
area = area.splitByFactorVB(factor);
|
||||
|
||||
mSideWidgets[i].headerArea.size.y -= mPadding;
|
||||
}
|
||||
}
|
||||
|
||||
ualni DockLayout::getVisibleSidesSize() const {
|
||||
ualni out = 0;
|
||||
for (ualni i = 0; i < 4; i++) {
|
||||
if (isSideVisible(Side(i))) out++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
auto DockLayout::getSideFromWidget(Widget* widget) -> Side {
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
if (sideWidget.widget == widget) return sideWidget.side;
|
||||
}
|
||||
return DockLayout::NONE;
|
||||
}
|
||||
|
||||
void DockLayout::updateLayout(bool vertical) {
|
||||
for (auto child : children()) {
|
||||
child->getLayout()->pickRect(vertical);
|
||||
}
|
||||
|
||||
if (vertical) return;
|
||||
adjustChildrenRect();
|
||||
}
|
||||
|
||||
void DockLayout::adjustChildrenRect() {
|
||||
calculateSideAreas();
|
||||
calculateResizeHandles();
|
||||
updateChildSideWidgets();
|
||||
}
|
||||
|
||||
void DockLayout::updatePreviewSide(const Vec2F& pointer) {
|
||||
const auto handleSize = min(mCenterArea.size.x, mCenterArea.size.y) * mHandleFactor;
|
||||
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
if (sideWidget.widget) continue;
|
||||
|
||||
switch (sideWidget.side) {
|
||||
case TOP: sideWidget.area = mCenterArea.splitByFactorVT(mHandleSplitFactor); break;
|
||||
case BOTTOM: sideWidget.area = mCenterArea.splitByFactorVB(1 - mHandleSplitFactor); break;
|
||||
case LEFT: sideWidget.area = mCenterArea.splitByFactorHL(mHandleSplitFactor); break;
|
||||
case RIGHT: sideWidget.area = mCenterArea.splitByFactorHR(1 - mHandleSplitFactor); break;
|
||||
default: break;
|
||||
}
|
||||
|
||||
sideWidget.area = sideWidget.area.shrink(mPadding * 2);
|
||||
sideWidget.previewHandleArea = sideWidget.area.getSizedFromCenter({ handleSize, handleSize });
|
||||
}
|
||||
|
||||
mPreviewArea = {};
|
||||
mPreviewSide = NONE;
|
||||
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
if (sideWidget.widget) continue;
|
||||
|
||||
if (sideWidget.previewHandleArea.isInside(pointer)) {
|
||||
mPreviewArea = sideWidget.area;
|
||||
mPreviewSide = sideWidget.side;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DockLayout::Side DockLayout::getPreviewSide() {
|
||||
return mPreviewSide;
|
||||
}
|
||||
|
||||
void DockLayout::updateResizeHover(const Vec2F& pointer) {
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
sideWidget.resizeHandle.hover = sideWidget.resizeHandle.area.isInside(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
void DockLayout::updateResize(const Vec2F& pointerDelta) {
|
||||
if (!mResizing) return;
|
||||
|
||||
// do the resizing
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
if (!sideWidget.resizeHandle.active) continue;
|
||||
halnf delta = pointerDelta[(sideWidget.side == TOP || sideWidget.side == BOTTOM)];
|
||||
if (sideWidget.side == BOTTOM || sideWidget.side == RIGHT) {
|
||||
delta *= -1;
|
||||
}
|
||||
sideWidget.absoluteSize += delta;
|
||||
}
|
||||
}
|
||||
|
||||
bool DockLayout::startResize(const Vec2F& pointer) {
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
if (sideWidget.resizeHandle.hover) {
|
||||
sideWidget.resizeHandle.active = true;
|
||||
mResizing = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void DockLayout::endResize() {
|
||||
for (auto& sideWidget : mSideWidgets) {
|
||||
sideWidget.resizeHandle.active = false;
|
||||
}
|
||||
mResizing = false;
|
||||
}
|
||||
|
||||
bool DockLayout::isSideVisible(DockLayout::Side side) const {
|
||||
return sideExists(side) && !mSideWidgets[side].hidden;
|
||||
}
|
||||
|
||||
bool DockLayout::sideExists(DockLayout::Side side) const {
|
||||
return mSideWidgets[side].widget;
|
||||
}
|
||||
|
||||
const std::vector<Widget*>& DockLayout::getDockedWidgets() {
|
||||
mDockedWidgets.resize(5);
|
||||
for (auto i = 0; i < 4; i++) {
|
||||
mDockedWidgets[i] = mSideWidgets[i].widget;
|
||||
}
|
||||
mDockedWidgets[4] = mCenterWidget;
|
||||
return mDockedWidgets;
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
#include "FloatingLayout.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
void FloatingLayout::pickRect(bool vertical) {
|
||||
if (mIsFloating) {
|
||||
auto area = getArea();
|
||||
|
||||
if (mIsResizing) {
|
||||
mPointerCurrent.clamp(mMinSize, mMaxSize);
|
||||
|
||||
area.size[vertical] = (mPointerCurrent + mHandleSize / 2.f)[vertical];
|
||||
|
||||
for (auto child : children()) {
|
||||
child->triggerWidgetUpdate("floating menu resized");
|
||||
}
|
||||
|
||||
} else if (mIsFloating) {
|
||||
area.pos[vertical] += (mPointerCurrent - mPointerStart)[vertical];
|
||||
}
|
||||
|
||||
setArea(area);
|
||||
}
|
||||
|
||||
clampMinMaxSize();
|
||||
}
|
||||
|
||||
RectF FloatingLayout::resizeHandleRect() {
|
||||
auto area = getAnimatedArea().relative();
|
||||
area.pos = area.p3() - mHandleSize;
|
||||
area.size = mHandleSize;
|
||||
area.shrink(mHandlePadding);
|
||||
return area;
|
||||
}
|
||||
|
||||
void FloatingLayout::startAction(const Vec2F& pointer) {
|
||||
mPointerStart = pointer;
|
||||
|
||||
mIsFloating = true;
|
||||
if (resizeHandleRect().isInside(mPointerStart)) {
|
||||
mIsResizing = true;
|
||||
}
|
||||
}
|
||||
|
||||
void FloatingLayout::updateAction(const Vec2F& pointer) {
|
||||
mPointerCurrent = pointer;
|
||||
}
|
||||
|
||||
void FloatingLayout::endAction() {
|
||||
mIsResizing = mIsFloating = false;
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
#include "ScrollableLayout.hpp"
|
||||
|
||||
// fixme : this dependency should be removed
|
||||
#include "ScrollableWidget.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
void ScrollableLayout::updateLayout(bool vertical) {
|
||||
if (vertical) return;
|
||||
|
||||
// TODO : make better interface to get scroller and content widget
|
||||
if (children().size() != 2) return;
|
||||
|
||||
auto scroller = dynamic_cast<ScrollableBarWidget*>(children().front());
|
||||
auto content = children().back();
|
||||
|
||||
if (!scroller || !content) return;
|
||||
|
||||
updateWidgetRects(getArea().relative(), content, scroller);
|
||||
}
|
||||
|
||||
void ScrollableLayout::updateWidgetRects(const RectF& area, Widget* content, ScrollableBarWidget* scroller) const {
|
||||
bool dir = scroller->getDirection();
|
||||
|
||||
// update content
|
||||
// auto contentItems = content->getLayout()->getChildrenEnclosure();
|
||||
// content->getLayout()->setArea(contentItems.shrinkFromCenter(-10, true));
|
||||
content->getLayout()->pickRect(dir);
|
||||
|
||||
auto sizeFactor = (area.size[dir] / content->getLayout()->getArea().size[dir]);
|
||||
auto splitFactor = 1 - ((sizeFactor > 1.f) ? 0 : mScrollerSize / area.size[!dir]);
|
||||
|
||||
auto holderArea = dir ? area.splitByFactorHL(splitFactor) : area.splitByFactorVT(splitFactor);
|
||||
auto contentArea = content->getLayout()->getArea();
|
||||
auto scrollerArea = dir ? area.splitByFactorHR(splitFactor) : area.splitByFactorVB(splitFactor);
|
||||
|
||||
scroller->updateSizeFactor(sizeFactor);
|
||||
|
||||
contentArea.pos[dir] = -scroller->getPosFactor() * contentArea.size[dir];
|
||||
contentArea.size[!dir] = holderArea.size[!dir];
|
||||
contentArea.pos[!dir] = 0;
|
||||
|
||||
scroller->getLayout()->setArea(scrollerArea);
|
||||
content->getLayout()->setArea(contentArea);
|
||||
}
|
||||
|
||||
RectF ScrollableLayout::getAvailableChildArea() const {
|
||||
auto out = getArea();
|
||||
// dont constrain on scroll axis
|
||||
out.pos = -FLT_MAX / 4;
|
||||
out.size = FLT_MAX / 2;
|
||||
return out;
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
|
||||
#include "SimpleLayouts.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
|
||||
using namespace tp;
|
||||
|
||||
void OverlayLayout::updateLayout(bool vertical) {
|
||||
if (vertical) return;
|
||||
|
||||
if (children().empty()) return;
|
||||
|
||||
for (auto child : children()) {
|
||||
child->getLayout()->setArea(getArea().relative());
|
||||
}
|
||||
}
|
||||
|
||||
void ToolBarLayout::updateLayout(bool vertical) {
|
||||
if (vertical) return;
|
||||
|
||||
if (children().size() != 2) return;
|
||||
|
||||
auto toolbar = children().back();
|
||||
auto content = children().front();
|
||||
|
||||
auto factor = mToolBarHeight / getArea().w;
|
||||
|
||||
auto area = getArea().relative();
|
||||
|
||||
toolbar->getLayout()->setArea(area.splitByFactorVT(factor));
|
||||
content->getLayout()->setArea(area);
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
#include "LayoutManager.hpp"
|
||||
|
||||
#include "DockLayout.hpp"
|
||||
#include "ScrollableLayout.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace tp;
|
||||
|
||||
void LayoutManager::adjust(Widget* root) {
|
||||
for (auto i : IterRange(2)) {
|
||||
mVertical = bool(i);
|
||||
|
||||
mDepGraph.clear();
|
||||
mLayOrder.clear();
|
||||
mRoots.clear();
|
||||
|
||||
findDependencies(root);
|
||||
|
||||
// TODO : implement better topological-sort
|
||||
for (auto iterRoot : mRoots) {
|
||||
topologicalSort(iterRoot);
|
||||
}
|
||||
|
||||
adjustLayouts();
|
||||
}
|
||||
}
|
||||
|
||||
void LayoutManager::findDependencies(Widget* root) {
|
||||
mDepGraph.insert({ root, {} });
|
||||
|
||||
Widget::dfs(root, [this](Widget* widget) { mDepGraph.insert({ widget, {} }); });
|
||||
|
||||
for (auto& [widget, deps] : mDepGraph) {
|
||||
if (!widget->isUpdate()) continue;
|
||||
|
||||
for (auto child : widget->mChildren) {
|
||||
// if (!child->isUpdate()) continue;
|
||||
|
||||
mDepGraph.insert({ child, {} });
|
||||
|
||||
if (auto depOrder = getLayoutOrder(widget->getLayout(), child->getLayout())) {
|
||||
if (depOrder > 0) {
|
||||
deps.depends.push_back(child);
|
||||
mDepGraph[child].references++;
|
||||
} else {
|
||||
mDepGraph[child].depends.push_back(widget);
|
||||
mDepGraph[widget].references++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// find root
|
||||
for (auto& node : mDepGraph) {
|
||||
if (node.second.references == 0) {
|
||||
mRoots.push_back(node.first);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void LayoutManager::topologicalSort(Widget* root, int depth) {
|
||||
mDepGraph.at(root).depth = max(mDepGraph.at(root).depth, depth);
|
||||
|
||||
for (auto& dep : mDepGraph.at(root).depends) {
|
||||
topologicalSort(dep, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
void LayoutManager::adjustLayouts() {
|
||||
for (auto& iter : mDepGraph) {
|
||||
mLayOrder.emplace_back(iter.first, iter.second.depth);
|
||||
}
|
||||
|
||||
std::sort(mLayOrder.begin(), mLayOrder.end(), [](auto first, auto second){
|
||||
return first.second > second.second;
|
||||
});
|
||||
|
||||
for (auto& [iter, _] : mLayOrder) {
|
||||
iter->getLayout()->updateLayout(mVertical);
|
||||
}
|
||||
}
|
||||
|
||||
static int sizePolicyDep[3][3] = {
|
||||
// f s e child
|
||||
{ 1, 1, 1 }, // parent fixed
|
||||
{ 1, 1, 1 }, // parent shrink
|
||||
{ 1, 1, 1 }, // parent expand
|
||||
};
|
||||
|
||||
int LayoutManager::getLayoutOrder(WidgetLayout* parent, WidgetLayout* child) const {
|
||||
if (!parent || !child) return 0;
|
||||
|
||||
auto policyParent = parent->getSizePolicy()[mVertical];
|
||||
auto policyChild = parent->getSizePolicy()[mVertical];
|
||||
|
||||
if (dynamic_cast<DockLayout*>(parent)) return -1;
|
||||
// if (dynamic_cast<ScrollableLayout*>(parent)) return -1;
|
||||
|
||||
return sizePolicyDep[int(policyParent)][int(policyChild)];
|
||||
}
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
#include "UpdateManager.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
void UpdateManager::processWidgets(Widget* root, const InputState& input) {
|
||||
processFocusItems(input);
|
||||
processActiveTree(root, input, { 0, 0 });
|
||||
}
|
||||
|
||||
void UpdateManager::clean() {
|
||||
erase_if(mTriggeredWidgets, [](auto iter) {
|
||||
auto widget = iter.first;
|
||||
auto flag = iter.second;
|
||||
|
||||
if (!flag) return false;
|
||||
|
||||
// if (mWidgetsToProcess.find(widget) == mWidgetsToProcess.end()) return false;
|
||||
widget->updateAnimations();
|
||||
auto end = !widget->needsNextFrame();
|
||||
if (end) {
|
||||
widget->endAnimations();
|
||||
}
|
||||
return end;
|
||||
});
|
||||
}
|
||||
|
||||
void UpdateManager::scheduleUpdate(Widget* widget, const char*) {
|
||||
mTriggeredWidgets.insert({ widget, false });
|
||||
}
|
||||
|
||||
void UpdateManager::updateTreeToProcess(Widget* root) {
|
||||
Widget::dfs(root, [](Widget* widget) {
|
||||
widget->mFlags.set(Widget::NEEDS_UPDATE, false);
|
||||
});
|
||||
|
||||
for (auto& [widget, flag] : mTriggeredWidgets) {
|
||||
flag = true;
|
||||
|
||||
for (auto iter = widget; iter && iter->mParent; iter = iter->mParent) {
|
||||
iter->mFlags.set(Widget::NEEDS_UPDATE, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateManager::getWidgetPath(Widget* widget, std::vector<Widget*>& out) {
|
||||
if (!widget) return;
|
||||
|
||||
for (auto iter = widget; iter && iter->mParent; iter = iter->mParent) {
|
||||
out.push_back(iter);
|
||||
}
|
||||
|
||||
for (auto i = 0; i < out.size() / 2; i++) {
|
||||
swapV(out[i], out[out.size() - i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateManager::handleFocusChanges(Widget* root, const InputView& input) {
|
||||
auto prevFocus = mInFocusWidget;
|
||||
|
||||
mInFocusWidget = nullptr;
|
||||
|
||||
findFocusWidget(root, &mInFocusWidget, input.getPointer());
|
||||
|
||||
if (mFocusLockWidget) {
|
||||
bool hasLockedWidget = false;
|
||||
for (auto iter = mInFocusWidget; iter; iter = iter->mParent) {
|
||||
if (iter == mFocusLockWidget) {
|
||||
hasLockedWidget = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasLockedWidget) {
|
||||
mInFocusWidget = mFocusLockWidget;
|
||||
}
|
||||
}
|
||||
|
||||
// if (mInFocusWidget == prevFocus) return;
|
||||
if (mInFocusWidget) scheduleUpdate(mInFocusWidget, "focus entered");
|
||||
|
||||
if (!mInFocusWidget && !prevFocus) return;
|
||||
|
||||
std::vector<Widget*> path2;
|
||||
getWidgetPath(mInFocusWidget, path2);
|
||||
size_t propLen2 = path2.size();
|
||||
for (auto i = 0; i < path2.size(); i++) {
|
||||
if (!path2[i]->propagateEventsToChildren()) {
|
||||
propLen2 = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Widget*> path1;
|
||||
getWidgetPath(prevFocus, path1);
|
||||
size_t propLen1 = path1.size();
|
||||
for (auto i = 0; i < path1.size(); i++) {
|
||||
if (!path1[i]->propagateEventsToChildren()) {
|
||||
propLen1 = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int mostCommonIdx = 0;
|
||||
if (!(path1.empty() || path2.empty())) {
|
||||
while (mostCommonIdx < min(path1.size(), path2.size()) && path1[mostCommonIdx] == path2[mostCommonIdx]) {
|
||||
mostCommonIdx++;
|
||||
}
|
||||
}
|
||||
mostCommonIdx--;
|
||||
|
||||
for (auto i = 0; i < path1.size(); i++) {
|
||||
path1[i]->mFlags.set(Widget::IN_FOCUS, false);
|
||||
if (i > mostCommonIdx && i < propLen1) path1[i]->mouseLeave();
|
||||
}
|
||||
|
||||
for (auto i = 0; i < path2.size(); i++) {
|
||||
path2[i]->mFlags.set(Widget::IN_FOCUS, true);
|
||||
if (i > mostCommonIdx && i < propLen2) path2[i]->mouseEnter();
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateManager::findFocusWidget(Widget* iter, Widget** focus, const Vec2F& pointer) {
|
||||
if (!iter->mArea.getTargetRect().isInside(pointer) || !iter->mFlags.get(Widget::ENABLED)) return;
|
||||
|
||||
if (iter->processesEvents()) {
|
||||
*focus = iter;
|
||||
}
|
||||
|
||||
for (auto child = iter->mDepthOrder.lastNode(); child; child = child->prev) {
|
||||
findFocusWidget(child->data, focus, pointer - iter->mArea.getTargetRect().pos);
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateManager::processActiveTree(Widget* iter, const InputState& input, Vec2F parent) {
|
||||
if (!iter || !iter->isUpdate()) return;
|
||||
|
||||
auto current = parent + iter->getAreaT().pos;
|
||||
|
||||
if (!iter->mFlags.get(Widget::IN_FOCUS)) {
|
||||
procWidget(iter, InputView(input, current, InputView::Buttons::Hidden));
|
||||
}
|
||||
|
||||
for (auto child : iter->mDepthOrder) {
|
||||
processActiveTree(child.data(), input, current);
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateManager::processFocusItems(const InputState& input) {
|
||||
if (!mInFocusWidget) return;
|
||||
|
||||
std::vector<Widget*> path;
|
||||
getWidgetPath(mInFocusWidget, path);
|
||||
|
||||
size_t len = path.size();
|
||||
for (auto i = 0; i < len; i++) {
|
||||
if (!path[i]->propagateEventsToChildren()) {
|
||||
len = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Vec2F> widgetGlobalPos(path.size());
|
||||
|
||||
widgetGlobalPos[0] = 0;
|
||||
for (auto widget = 0; widget < path.size() - 1; widget++) {
|
||||
widgetGlobalPos[widget + 1] = widgetGlobalPos[widget] + path[widget + 1]->getAreaT().pos;
|
||||
// path[widget]->mGlobalPoint = widgetGlobalPos[widget];
|
||||
}
|
||||
|
||||
bool eventsProcessed = false;
|
||||
|
||||
for (int iter = (int) len - 1; iter >= 0; iter--) {
|
||||
auto widget = path[iter];
|
||||
|
||||
const bool receivesButtons = !eventsProcessed && widget->processesEvents();
|
||||
const InputView::Buttons buttons = receivesButtons ? InputView::Buttons::Visible : InputView::Buttons::Hidden;
|
||||
procWidget(widget, InputView(input, widgetGlobalPos[iter], buttons));
|
||||
eventsProcessed = eventsProcessed || receivesButtons;
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateManager::procWidget(Widget* widget, const InputView& input) { widget->process(input); }
|
||||
|
||||
void UpdateManager::lockFocus(tp::Widget* widget) {
|
||||
mFocusLockWidget = widget;
|
||||
}
|
||||
|
||||
void UpdateManager::freeFocus(tp::Widget* widget) {
|
||||
// DEBUG_ASSERT(mFocusLockWidget == widget)
|
||||
mFocusLockWidget = nullptr;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
#include "AnimationTestWidget.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
void AnimationTestWidget::process(const InputView& events) {
|
||||
if (events.isDown(PointerButton::Left)) {
|
||||
mTestSpring.getStart().setTargetPosition(events.getPointer());
|
||||
}
|
||||
|
||||
if (events.isDown(PointerButton::Right)) {
|
||||
mTestSpring.getEnd().setTargetPosition(events.getPointer());
|
||||
}
|
||||
|
||||
mTestSpring.updateCurrentRect();
|
||||
|
||||
if (mTestSpring.shouldEndTransition()) {
|
||||
mTestSpring.endAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
void AnimationTestWidget::draw(Canvas& canvas) {
|
||||
canvas.rect(getRelativeArea(), { 0, 0, 0, 1 }, 10);
|
||||
canvas.rect(mTestSpring.getCurrentRect(), RGBA(1.f), 10);
|
||||
}
|
||||
|
||||
bool AnimationTestWidget::needsNextFrame() const {
|
||||
return Widget::needsNextFrame() || !mTestSpring.shouldEndTransition();
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
#include "ColorPickerWidget.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
|
||||
using namespace tp;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kTwoPi = 2 * std::numbers::pi_v<halnf>;
|
||||
constexpr halnf kThirdTurn = kTwoPi / 3;
|
||||
constexpr halnf kRingThickness = 20;
|
||||
constexpr halnf kWheelMargin = 5;
|
||||
constexpr halnf kTriangleInset = 6;
|
||||
constexpr int kRingSegments = 6;
|
||||
|
||||
const RGBA kEdgeShade = { 0, 0, 0, 0.25f };
|
||||
const RGBA kMarkerColor = { 1, 1, 1, 0.75f };
|
||||
const RGBA kMarkerShadow = { 0, 0, 0, 0.25f };
|
||||
const RGBA kTransparent = { 0, 0, 0, 0 };
|
||||
|
||||
RGBA colorOf(halnf hue, halnf saturation, halnf value) {
|
||||
RGBA color;
|
||||
color = HSV(hue, saturation, value);
|
||||
return color;
|
||||
}
|
||||
|
||||
Vec2F onCircle(Vec2F center, halnf radius, halnf angle) { return center + Vec2F(radius, 0.0f).rotated(angle); }
|
||||
|
||||
Vec2F saturationValueAt(Vec2F unitTrianglePoint) {
|
||||
const halnf whiteToHue = std::clamp((1 - unitTrianglePoint.x / std::cos(kThirdTurn)) / 3, 0.0f, 1.0f);
|
||||
const halnf blackToWhite = (unitTrianglePoint.y / std::sin(kThirdTurn) + 1) / 2;
|
||||
const halnf insideTriangle = std::clamp(blackToWhite, whiteToHue / 2, 1 - whiteToHue / 2);
|
||||
|
||||
const halnf value = insideTriangle + whiteToHue / 2;
|
||||
const halnf saturation = value > 0 ? whiteToHue / value : 0;
|
||||
return { std::clamp(saturation, 0.0f, 1.0f), std::clamp(value, 0.0f, 1.0f) };
|
||||
}
|
||||
|
||||
Vec2F unitTrianglePointOf(halnf saturation, halnf value) {
|
||||
const halnf whiteToHue = saturation * value;
|
||||
const halnf blackToWhite = value - whiteToHue / 2;
|
||||
return { (1 - 3 * whiteToHue) * std::cos(kThirdTurn), (2 * blackToWhite - 1) * std::sin(kThirdTurn) };
|
||||
}
|
||||
}
|
||||
|
||||
halnf RGBPickerWidget::Geometry::innerRadius() const { return outerRadius - kRingThickness; }
|
||||
|
||||
halnf RGBPickerWidget::Geometry::triangleRadius() const { return innerRadius() - kTriangleInset; }
|
||||
|
||||
RGBPickerWidget::Geometry RGBPickerWidget::geometry() const {
|
||||
const RectF area = getArea().relative();
|
||||
return { area.center(), std::min(area.z, area.w) / 2 - kWheelMargin };
|
||||
}
|
||||
|
||||
RGBPickerWidget::Part RGBPickerWidget::partAt(Vec2F point) const {
|
||||
const Geometry wheel = geometry();
|
||||
const halnf distance = (halnf) (point - wheel.center).length();
|
||||
|
||||
if (distance > wheel.outerRadius) {
|
||||
return Part::Outside;
|
||||
}
|
||||
if (distance >= wheel.innerRadius()) {
|
||||
return Part::HueRing;
|
||||
}
|
||||
return Part::SaturationValueTriangle;
|
||||
}
|
||||
|
||||
void RGBPickerWidget::pick(Vec2F point) {
|
||||
const Geometry wheel = geometry();
|
||||
const Vec2F offset = point - wheel.center;
|
||||
|
||||
if (mDragging == Part::HueRing) {
|
||||
const halnf angle = offset.angle();
|
||||
mColor.h = angle >= 0 ? angle : angle + kTwoPi;
|
||||
} else if (mDragging == Part::SaturationValueTriangle) {
|
||||
const Vec2F saturationValue = saturationValueAt(offset.rotated(-mColor.h) / wheel.triangleRadius());
|
||||
mColor.s = saturationValue.x;
|
||||
mColor.v = saturationValue.y;
|
||||
}
|
||||
}
|
||||
|
||||
void RGBPickerWidget::process(const InputView& events) {
|
||||
if (events.isPressed(PointerButton::Left)) {
|
||||
mDragging = partAt(events.getPointer());
|
||||
pick(events.getPointer());
|
||||
lockFocus();
|
||||
}
|
||||
|
||||
if (events.isReleased(PointerButton::Left)) {
|
||||
mDragging = Part::Outside;
|
||||
freeFocus();
|
||||
}
|
||||
|
||||
if (events.getPointerDelta().length() > EPSILON && events.isDown(PointerButton::Left)) {
|
||||
pick(events.getPointer());
|
||||
}
|
||||
}
|
||||
|
||||
void RGBPickerWidget::draw(Canvas& canvas) {
|
||||
const Geometry wheel = geometry();
|
||||
|
||||
drawHueRing(canvas, wheel);
|
||||
drawHueMarker(canvas, wheel);
|
||||
drawSaturationValueTriangle(canvas, wheel);
|
||||
drawSaturationValueMarker(canvas, wheel);
|
||||
}
|
||||
|
||||
void RGBPickerWidget::drawHueRing(Canvas& canvas, const Geometry& wheel) const {
|
||||
const halnf seamOverlap = 0.5f / wheel.outerRadius;
|
||||
const halnf middleRadius = (wheel.innerRadius() + wheel.outerRadius) / 2;
|
||||
|
||||
for (int segment = 0; segment < kRingSegments; segment++) {
|
||||
const halnf from = (halnf) segment / kRingSegments * kTwoPi;
|
||||
const halnf to = (halnf) (segment + 1) / kRingSegments * kTwoPi;
|
||||
const Canvas::LinearGradient hues = {
|
||||
onCircle(wheel.center, middleRadius, from),
|
||||
onCircle(wheel.center, middleRadius, to),
|
||||
colorOf(from, 1, 1),
|
||||
colorOf(to, 1, 1),
|
||||
};
|
||||
canvas.ringSegment(wheel.center, wheel.innerRadius(), wheel.outerRadius, from - seamOverlap, to + seamOverlap, hues);
|
||||
}
|
||||
|
||||
canvas.circleOutline(wheel.center, wheel.innerRadius(), kEdgeShade, 1);
|
||||
canvas.circleOutline(wheel.center, wheel.outerRadius + 1, kEdgeShade, 1);
|
||||
}
|
||||
|
||||
void RGBPickerWidget::drawHueMarker(Canvas& canvas, const Geometry& wheel) const {
|
||||
const halnf middleRadius = (wheel.innerRadius() + wheel.outerRadius) / 2;
|
||||
const Vec2F marker = onCircle(wheel.center, middleRadius, mColor.h);
|
||||
|
||||
canvas.radialGlow(marker, kRingThickness / 2, kRingThickness / 2 + 4, kMarkerShadow, kTransparent);
|
||||
canvas.circleOutline(marker, kRingThickness / 2, kMarkerColor, 2);
|
||||
}
|
||||
|
||||
void RGBPickerWidget::drawSaturationValueTriangle(Canvas& canvas, const Geometry& wheel) const {
|
||||
const halnf radius = wheel.triangleRadius();
|
||||
const Vec2F hueCorner = onCircle(wheel.center, radius, mColor.h);
|
||||
const Vec2F whiteCorner = onCircle(wheel.center, radius, mColor.h + kThirdTurn);
|
||||
const Vec2F blackCorner = onCircle(wheel.center, radius, mColor.h - kThirdTurn);
|
||||
const Vec2F triangle[] = { hueCorner, whiteCorner, blackCorner };
|
||||
|
||||
canvas.polygon(triangle, 3, Canvas::LinearGradient{ hueCorner, whiteCorner, colorOf(mColor.h, 1, 1), { 1, 1, 1, 1 } });
|
||||
canvas.polygon(triangle, 3, Canvas::LinearGradient{ (hueCorner + whiteCorner) / 2, blackCorner, kTransparent, { 0, 0, 0, 1 } });
|
||||
}
|
||||
|
||||
void RGBPickerWidget::drawSaturationValueMarker(Canvas& canvas, const Geometry& wheel) const {
|
||||
const Vec2F unitPoint = unitTrianglePointOf(mColor.s, mColor.v);
|
||||
const Vec2F marker = wheel.center + (unitPoint * wheel.triangleRadius()).rotated(mColor.h);
|
||||
|
||||
canvas.radialGlow(marker, 7, 9, kMarkerShadow, kTransparent);
|
||||
canvas.circleOutline(marker, 5, kMarkerColor, 2);
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
#include "DockWidget.hpp"
|
||||
#include "FloatingWidget.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
DockWidget::DockWidget() : Widget() {
|
||||
setLayout(new DockLayout(this));
|
||||
}
|
||||
|
||||
void DockWidget::dockWidget(Widget* widget, DockLayout::Side side) {
|
||||
if (layout()->dockWidget(widget, side)) {
|
||||
addChild(widget);
|
||||
|
||||
for (auto child : layout()->getDockedWidgets()) {
|
||||
if (child) child->bringToBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DockWidget::undockWidget(DockLayout::Side side, bool restoreArea) {
|
||||
if (layout()->sideExists(side) && side != DockLayout::NONE) {
|
||||
auto widget = layout()->getSideWidget(side);
|
||||
|
||||
widget->bringToFront();
|
||||
if (restoreArea) {
|
||||
widget->setArea(layout()->getRectBeforeDocked(side));
|
||||
} else {
|
||||
// FIXME : widget->endAnimations();
|
||||
}
|
||||
|
||||
layout()->undockWidget(side);
|
||||
}
|
||||
}
|
||||
|
||||
void DockWidget::setCenterWidget(Widget* widget) {
|
||||
if (layout()->setCenterWidget(widget)) {
|
||||
addChild(widget);
|
||||
}
|
||||
}
|
||||
|
||||
void DockWidget::toggleWidgetVisibility(DockLayout::Side side) {
|
||||
if (layout()->sideExists(side)) {
|
||||
auto widget = layout()->getSideWidget(side);
|
||||
widget->setEnabled(!layout()->isSideVisible(side));
|
||||
widget->bringToBack();
|
||||
|
||||
layout()->toggleWidgetVisibility(side);
|
||||
|
||||
widget->triggerWidgetUpdate("dock visibility changed");
|
||||
}
|
||||
}
|
||||
|
||||
void DockWidget::process(const InputView& events) {
|
||||
// calculateHeaderAreas();
|
||||
|
||||
Widget* floater = nullptr;
|
||||
for (auto child : mChildren) {
|
||||
if (auto iter = dynamic_cast<FloatingWidget*>(child)) {
|
||||
if (iter->isFloating()) {
|
||||
floater = iter;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (floater) undockWidget(layout()->getSideFromWidget(floater), false);
|
||||
|
||||
if (floater) {
|
||||
layout()->updatePreviewSide(events.getPointer());
|
||||
}
|
||||
|
||||
if (mPreviewWidget && !floater) {
|
||||
dockWidget(mPreviewWidget, layout()->getPreviewSide());
|
||||
}
|
||||
|
||||
mPreviewWidget = floater;
|
||||
|
||||
if (!mPreviewWidget) {
|
||||
layout()->updateResizeHover(events.getPointer());
|
||||
|
||||
if (events.isPressed(PointerButton::Left)) {
|
||||
if (layout()->startResize(events.getPointer())) {
|
||||
triggerWidgetUpdate("dock layout resizing");
|
||||
lockFocus();
|
||||
}
|
||||
} else if (events.isReleased(PointerButton::Left)) {
|
||||
layout()->endResize();
|
||||
freeFocus();
|
||||
}
|
||||
|
||||
if (layout()->isResizing()) {
|
||||
layout()->updateResize(events.getPointerDelta());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool DockWidget::propagateEventsToChildren() const {
|
||||
return !layout()->isResizing();
|
||||
}
|
||||
|
||||
bool DockWidget::needsNextFrame() const {
|
||||
return Widget::needsNextFrame() || layout()->isResizing();
|
||||
}
|
||||
|
||||
void DockWidget::draw(Canvas& canvas) {
|
||||
canvas.rect(getRelativeArea(), mBackgroundColor, 0);
|
||||
}
|
||||
|
||||
void DockWidget::drawSide(DockLayout::Side side, tp::Canvas& canvas) {
|
||||
auto lay = layout();
|
||||
|
||||
if (lay->isSideVisible(side)) {
|
||||
// header
|
||||
canvas.rect(lay->getHeaderArea(side), mResizeHandleColorActive, 0);
|
||||
|
||||
// resize
|
||||
if (lay->isResizing(side)) {
|
||||
canvas.rect(lay->getResizeHandleArea(side).shrink(mPadding / 1.5f), mResizeHandleColorActive, 0);
|
||||
} else if (lay->isResizeHandleHover(side)) {
|
||||
canvas.rect(lay->getResizeHandleArea(side).shrink(mPadding / 1.5f), mResizeHandleColorHovered, 0);
|
||||
}
|
||||
|
||||
} else {
|
||||
// preview handles
|
||||
if (mPreviewWidget) {
|
||||
// preview active
|
||||
if (lay->getPreviewSide() == side) {
|
||||
canvas.rect(lay->getPreviewArea().shrink(mPadding * 2), mPreviewColor, mRounding);
|
||||
} else {
|
||||
canvas.rect(lay->getPreviewHandleArea(side), mPreviewColor, mRounding);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DockWidget::drawOverlay(Canvas& canvas) {
|
||||
drawSide(DockLayout::Side::LEFT, canvas);
|
||||
drawSide(DockLayout::Side::RIGHT, canvas);
|
||||
drawSide(DockLayout::Side::TOP, canvas);
|
||||
drawSide(DockLayout::Side::BOTTOM, canvas);
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
#include "FloatingWidget.hpp"
|
||||
#include "ScrollableLayout.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
void FloatingWidget::process(const InputView& events) {
|
||||
const auto pointer = events.getPointer();
|
||||
|
||||
if (getRelativeAreaT().isInside(pointer) && events.isPressed(PointerButton::Left)) {
|
||||
layout()->startAction(pointer);
|
||||
|
||||
bringToFront();
|
||||
lockFocus();
|
||||
}
|
||||
|
||||
if (layout()->isFloating() && events.isReleased(PointerButton::Left)) {
|
||||
layout()->endAction();
|
||||
freeFocus();
|
||||
}
|
||||
|
||||
layout()->updateAction(pointer);
|
||||
}
|
||||
|
||||
void FloatingWidget::draw(Canvas& canvas) {
|
||||
canvas.rect(layout()->resizeHandleRect(), RGBA(0.7f), 2);
|
||||
canvas.rect(getRelativeArea(), RGBA(0.5f), 10);
|
||||
}
|
||||
|
||||
bool FloatingWidget::processesEvents() const {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FloatingWidget::propagateEventsToChildren() const {
|
||||
return !layout()->isFloating();
|
||||
}
|
||||
|
||||
bool FloatingWidget::isFloating() const {
|
||||
return layout()->isFloating();
|
||||
}
|
||||
|
||||
bool FloatingWidget::needsNextFrame() const {
|
||||
return Widget::needsNextFrame() || isFloating();
|
||||
}
|
||||
|
||||
FloatingLayout* FloatingWidget::layout() { return dynamic_cast<FloatingLayout*>(Widget::getLayout()); }
|
||||
|
||||
const FloatingLayout* FloatingWidget::layout() const {
|
||||
return dynamic_cast<const FloatingLayout*>(Widget::getLayout());
|
||||
}
|
||||
|
||||
FloatingMenu::FloatingMenu() : FloatingWidget() {
|
||||
// addChild(&mMenuLayout);
|
||||
|
||||
addChild(&mHeader);
|
||||
addChild(&mBodyLayout);
|
||||
|
||||
mHeader.setText("Menu");
|
||||
|
||||
mHeader.setSizePolicy(SizePolicy::Expanding, SizePolicy::Minimal);
|
||||
mBodyLayout.setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding);
|
||||
|
||||
mBodyLayout.setLayout(new ScrollableLayout(&mBodyLayout));
|
||||
mBodyLayout.addChild(&mScrollBar);
|
||||
mBodyLayout.addChild(&mContentWidget);
|
||||
|
||||
mContentWidget.setSizePolicy(SizePolicy::Minimal, SizePolicy::Minimal);
|
||||
|
||||
// getLayout()->setLayoutPolicy(LayoutPolicy::Vertical);
|
||||
// mBodyLayout.getLayout()->setLayoutPolicy(LayoutPolicy::Vertical);
|
||||
}
|
||||
|
||||
void FloatingMenu::setText(const std::string& text) {
|
||||
mHeader.setText(text);
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
#include "ScrollableWidget.hpp"
|
||||
#include "ScrollableLayout.hpp"
|
||||
#include "BasicLayout.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
void ScrollableBarWidget::process(const InputView& events) {
|
||||
// all content is visible no need to process anything
|
||||
if (mSizeFactor >= 1) {
|
||||
mScrolling = false;
|
||||
mPosFactor = mSizeFactor / 2.f;
|
||||
freeFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
updateHandleRect();
|
||||
|
||||
auto pointer = events.getPointer();
|
||||
auto size = getArea().size;
|
||||
|
||||
mHandleHovered = getHandleRect().isInside(pointer);
|
||||
|
||||
if (getRelativeAreaT().isInside(pointer) && events.isPressed(PointerButton::Left)) {
|
||||
if (!mHandleHovered) {
|
||||
jumpTo((pointer / size)[mVertical]);
|
||||
mStartPos = { 0, 0 };
|
||||
} else {
|
||||
mStartPos = pointer - getHandleRect().center();
|
||||
}
|
||||
mScrolling = true;
|
||||
lockFocus();
|
||||
}
|
||||
|
||||
if (mScrolling && events.isReleased(PointerButton::Left)) {
|
||||
mScrolling = false;
|
||||
freeFocus();
|
||||
}
|
||||
|
||||
if (mScrolling) {
|
||||
updateHandleRect();
|
||||
auto dragDelta = (pointer - getHandleRect().center()) - mStartPos;
|
||||
moveBy((dragDelta / size)[mVertical]);
|
||||
}
|
||||
}
|
||||
|
||||
void ScrollableBarWidget::jumpTo(halnf pos) {
|
||||
mPosFactor = pos;
|
||||
clamp();
|
||||
}
|
||||
|
||||
void ScrollableBarWidget::moveBy(halnf delta) {
|
||||
mPosFactor += delta;
|
||||
clamp();
|
||||
}
|
||||
|
||||
void ScrollableBarWidget::updateSizeFactor(halnf factor) {
|
||||
mSizeFactor = factor;
|
||||
clamp();
|
||||
}
|
||||
|
||||
void ScrollableBarWidget::clamp() {
|
||||
mSizeFactor = ::clamp(mSizeFactor, 0.f, 1.f);
|
||||
mPosFactor = ::clamp(mPosFactor, mSizeFactor / 2, 1.f - mSizeFactor / 2);
|
||||
}
|
||||
|
||||
void ScrollableBarWidget::draw(Canvas& canvas) {
|
||||
updateHandleRect();
|
||||
|
||||
canvas.rect(getArea().relative(), mBGColor, mRounding);
|
||||
|
||||
auto& handleColor = mScrolling ? mHandleSlideColor : (mHandleHovered ? mHandleHoverColor : mHandleColor);
|
||||
canvas.rect(getHandleRect(), handleColor, mRounding);
|
||||
}
|
||||
|
||||
void ScrollableBarWidget::updateHandleRect() {
|
||||
auto area = getArea().relative();
|
||||
|
||||
mHandleRect = area;
|
||||
mHandleRect.size[mVertical] = area.size[mVertical] * mSizeFactor;
|
||||
mHandleRect.pos[mVertical] = area.size[mVertical] * mPosFactor;
|
||||
|
||||
mHandleRect.pos[mVertical] -= mHandleRect.size[mVertical] / 2;
|
||||
|
||||
mHandleRect.shrinkFromCenter(mHandlePadding, true);
|
||||
}
|
||||
|
||||
const RectF& ScrollableBarWidget::getHandleRect() const {
|
||||
return mHandleRect;
|
||||
}
|
||||
|
||||
ScrollableWidget::ScrollableWidget() {
|
||||
addChild(&mScroller);
|
||||
addChild(&mContent);
|
||||
|
||||
setLayout(new ScrollableLayout(this));
|
||||
|
||||
mContent.setSizePolicy(SizePolicy::Minimal, SizePolicy::Minimal);
|
||||
}
|
||||
|
||||
void ScrollableWidget::setDirection(bool direction) {
|
||||
if (auto lay = dynamic_cast<BasicLayout*>(mContent.getLayout())) {
|
||||
lay->setLayoutPolicy(direction ? LayoutPolicy::Vertical : LayoutPolicy::Horizontal);
|
||||
}
|
||||
|
||||
mScroller.setDirection(direction);
|
||||
}
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
|
||||
#include "SimpleWidgets.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
void LabelWidget::setText(const std::string& text) {
|
||||
mText = text;
|
||||
}
|
||||
|
||||
const std::string& LabelWidget::getText() const {
|
||||
return mText;
|
||||
}
|
||||
|
||||
void LabelWidget::draw(Canvas& canvas) {
|
||||
canvas.text(mText.c_str(), getRelativeArea(), { .size = mSize, .color = mColor, .padding = mPadding });
|
||||
}
|
||||
|
||||
ButtonWidget::ButtonWidget() {
|
||||
mAction = []() {
|
||||
printf("Button Pressed!\n");
|
||||
};
|
||||
|
||||
mColorAnimated.setTargetColor(mColor);
|
||||
mColorAnimated.endAnimation();
|
||||
}
|
||||
|
||||
void ButtonWidget::setAction(const std::function<void()>& action) {
|
||||
mAction = action;
|
||||
}
|
||||
|
||||
void ButtonWidget::setColor(const RGBA& in) {
|
||||
mColor = in;
|
||||
mColorAnimated.setTargetColor(mColor);
|
||||
triggerWidgetUpdate("color changed");
|
||||
}
|
||||
|
||||
void ButtonWidget::process(const InputView& inputState) {
|
||||
if (getRelativeArea().isInside(inputState.getPointer())) {
|
||||
if (inputState.isPressed(PointerButton::Left)) {
|
||||
mAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ButtonWidget::draw(Canvas& canvas) {
|
||||
canvas.rect(getRelativeArea(), mColorAnimated.getCurrentColor(), mRounding);
|
||||
LabelWidget::draw(canvas);
|
||||
}
|
||||
|
||||
bool ButtonWidget::needsNextFrame() const {
|
||||
return LabelWidget::needsNextFrame() || !mColorAnimated.shouldEndTransition();
|
||||
}
|
||||
|
||||
void ButtonWidget::endAnimations() {
|
||||
mColorAnimated.endAnimation();
|
||||
LabelWidget::endAnimations();
|
||||
}
|
||||
|
||||
void ButtonWidget::updateAnimations() {
|
||||
mColorAnimated.updateCurrentRect();
|
||||
LabelWidget::updateAnimations();
|
||||
}
|
||||
|
||||
void ButtonWidget::mouseEnter() {
|
||||
mColorAnimated.setTargetColor(mColor);
|
||||
mColorAnimated.endAnimation();
|
||||
|
||||
mColorAnimated.setTargetColor(mColorHovered);
|
||||
|
||||
// mColorAnimated.updateCurrentRect();
|
||||
triggerWidgetUpdate("button hovered");
|
||||
}
|
||||
|
||||
void ButtonWidget::mouseLeave() {
|
||||
mColorAnimated.setTargetColor(mColor);
|
||||
//mColorAnimated.updateCurrentRect();
|
||||
|
||||
triggerWidgetUpdate("button out of focus");
|
||||
}
|
||||
|
||||
|
||||
void SliderWidget::process(const InputView& events) {
|
||||
const auto pointer = events.getPointer();
|
||||
|
||||
switch (mState) {
|
||||
case SLIDING:
|
||||
if (events.isReleased(PointerButton::Left)) {
|
||||
mState = IDLE;
|
||||
freeFocus();
|
||||
}
|
||||
|
||||
mFactor = ((pointer - mHandleSize / 2) / (getArea().relative().size - mHandleSize)).x;
|
||||
mFactor = clamp(mFactor, 0.f, 1.f);
|
||||
break;
|
||||
|
||||
case IDLE:
|
||||
case HOVER:
|
||||
mState = getHandleArea().isInside(pointer) ? HOVER : IDLE;
|
||||
if (getRelativeAreaT().isInside(pointer) && events.isPressed(PointerButton::Left)) {
|
||||
mState = SLIDING;
|
||||
lockFocus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void SliderWidget::draw(Canvas& canvas) {
|
||||
canvas.rect(getArea().relative(), mColorBG, mRounding);
|
||||
|
||||
switch (mState) {
|
||||
case IDLE: canvas.rect(getHandleArea(), mColorIdle, mRounding); break;
|
||||
case HOVER: canvas.rect(getHandleArea(), mColorHovered, mRounding); break;
|
||||
case SLIDING: canvas.rect(getHandleArea(), mColorActive, mRounding); break;
|
||||
}
|
||||
}
|
||||
|
||||
RectF SliderWidget::getHandleArea() const {
|
||||
auto area = getArea().relative();
|
||||
|
||||
const auto& size = area.size;
|
||||
auto center = area.center();
|
||||
auto tilt = -(mFactor - 0.5f) * 2;
|
||||
|
||||
area.pos.x = center.x - (tilt * ((size.x - mHandleSize) / 2)) - mHandleSize / 2;
|
||||
area.size.x = mHandleSize;
|
||||
|
||||
return area;
|
||||
}
|
||||
|
||||
void PopupWidget::open(Widget* parent, const RectF& at) {
|
||||
mParentArea = parent->getArea().relative();
|
||||
mParentArea.pos = at.pos * -1;
|
||||
mParentArea.shrinkFromCenter(-10, true);
|
||||
|
||||
setArea(at);
|
||||
parent->openPopup(this);
|
||||
}
|
||||
|
||||
void PopupWidget::process(const InputView& events) {
|
||||
bool insideParent = mParentArea.isInside(events.getPointer());
|
||||
bool insideMenu = getArea().relative().isInside(events.getPointer());
|
||||
|
||||
if (!(insideMenu || insideParent)) {
|
||||
closePopup(this);
|
||||
}
|
||||
}
|
||||
|
||||
void PopupWidget::draw(Canvas& canvas) {
|
||||
canvas.rect(getArea().relative(), col, rounding);
|
||||
}
|
||||
|
||||
void HoverPopupTriggerWidget::mouseEnter() {
|
||||
auto area = getArea().relative();
|
||||
auto size = Vec2F(300, 400);
|
||||
|
||||
if (mDirection == Right) {
|
||||
auto popupArea = RectF{ area.p4() + Vec2F{ gap, 0.f }, size };
|
||||
mPopup.open(this, popupArea);
|
||||
} else {
|
||||
auto popupArea = RectF{ area.p2() + Vec2F{ 0.f, gap }, size };
|
||||
mPopup.open(this, popupArea);
|
||||
}
|
||||
}
|
||||
|
||||
PopupWidget* HoverPopupTriggerWidget::getPopup() {
|
||||
return &mPopup;
|
||||
}
|
||||
|
||||
void HoverPopupTriggerWidget::draw(Canvas& canvas) {
|
||||
canvas.rect(getArea().relative(), col, rounding);
|
||||
LabelWidget::draw(canvas);
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "Rect.hpp"
|
||||
#include <vector>
|
||||
|
||||
namespace tp {
|
||||
class Widget;
|
||||
|
||||
enum class SizePolicy {
|
||||
Fixed,
|
||||
Expanding,
|
||||
Minimal,
|
||||
};
|
||||
|
||||
enum class LayoutPolicy {
|
||||
Passive,
|
||||
Vertical,
|
||||
Horizontal,
|
||||
};
|
||||
|
||||
class WidgetLayout {
|
||||
friend class LayoutManager;
|
||||
|
||||
public:
|
||||
explicit WidgetLayout(Widget* widget) { mWidget = widget; }
|
||||
virtual ~WidgetLayout() = default;
|
||||
|
||||
virtual void updateLayout(bool vertical) {}
|
||||
|
||||
virtual void pickRect(bool vertical) {}
|
||||
virtual void clampRect() {}
|
||||
[[nodiscard]] virtual RectF getAvailableChildArea() const;
|
||||
|
||||
public:
|
||||
const Vec2F& getMinSize();
|
||||
void setMinSize(const Vec2F& size);
|
||||
|
||||
[[nodiscard]] const Vec2<SizePolicy>& getSizePolicy() const;
|
||||
void setSizePolicy(SizePolicy x, SizePolicy y);
|
||||
|
||||
public:
|
||||
[[nodiscard]] const RectF& getArea() const;
|
||||
[[nodiscard]] RectF getAnimatedArea() const;
|
||||
|
||||
void setArea(const RectF& area);
|
||||
[[nodiscard]] Widget* parent() const;
|
||||
[[nodiscard]] const std::vector<Widget*>& children() const;
|
||||
|
||||
public:
|
||||
void clampMinMaxSize();
|
||||
|
||||
[[nodiscard]] RangeF pickRange(const RangeF& current, const RangeF& child, const RangeF& parent, bool v) const;
|
||||
[[nodiscard]] RangeF clampRange(const RangeF& current, const RangeF& child, const RangeF& parent, bool v) const;
|
||||
|
||||
[[nodiscard]] RectF getChildrenEnclosure() const;
|
||||
[[nodiscard]] RectF getParentEnclosure() const;
|
||||
|
||||
private:
|
||||
Widget* mWidget = nullptr;
|
||||
|
||||
protected:
|
||||
Vec2<SizePolicy> mSizePolicy = { SizePolicy::Fixed, SizePolicy::Fixed };
|
||||
Vec2F mMinSize = { 30, 30 };
|
||||
Vec2F mMaxSize = { FLT_MAX / 2, FLT_MAX / 2 };
|
||||
|
||||
private:
|
||||
// RectF mPrevArea{};
|
||||
};
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include "UpdateManager.hpp"
|
||||
#include "LayoutManager.hpp"
|
||||
|
||||
namespace tp {
|
||||
class RootWidget : public WidgetManagerInterface {
|
||||
public:
|
||||
RootWidget();
|
||||
|
||||
// User Interface
|
||||
public:
|
||||
void setRootWidget(Widget* widget);
|
||||
static void setWidgetArea(Widget& widget, const RectF& rect);
|
||||
|
||||
// Graphic Application Interface
|
||||
public:
|
||||
void processFrame(const InputState& input, const RectF& screenArea);
|
||||
void drawFrame(Canvas& canvas);
|
||||
[[nodiscard]] bool needsUpdate() const;
|
||||
|
||||
// Internals
|
||||
private:
|
||||
void drawRecursion(Canvas& canvas, Widget* active, const Vec2F& pos);
|
||||
|
||||
void updateWidget(Widget*, const char* reason = nullptr) override;
|
||||
|
||||
void openPopup(Widget*) override;
|
||||
void closePopup(Widget*) override;
|
||||
|
||||
void lockFocus(Widget*) override;
|
||||
void freeFocus(Widget*) override;
|
||||
|
||||
void updateAnimations();
|
||||
void updateAreaCache(Widget* iter, bool read);
|
||||
|
||||
private:
|
||||
Widget mRoot;
|
||||
Widget mPopups;
|
||||
|
||||
Widget* mUserRoot = nullptr;
|
||||
|
||||
LayoutManager mLayoutManager;
|
||||
UpdateManager mUpdateManager;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "SpringAnimations.hpp"
|
||||
|
||||
#include "Layout.hpp"
|
||||
|
||||
#include "InputState.hpp"
|
||||
#include "Canvas.hpp"
|
||||
#include "List.hpp"
|
||||
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
|
||||
namespace tp {
|
||||
class WidgetLayout;
|
||||
|
||||
class LayoutManager;
|
||||
class UpdateManager;
|
||||
|
||||
class WidgetManagerInterface;
|
||||
class RootWidget;
|
||||
|
||||
class Widget {
|
||||
friend RootWidget;
|
||||
|
||||
friend LayoutManager;
|
||||
friend UpdateManager;
|
||||
|
||||
using BitField = Bits<ualni>;
|
||||
|
||||
using DFSAction = std::function<void(Widget*)>;
|
||||
|
||||
enum Flags : int1 {
|
||||
ENABLED = 0,
|
||||
NEEDS_UPDATE,
|
||||
IN_FOCUS,
|
||||
TRIGGERED,
|
||||
};
|
||||
|
||||
public:
|
||||
Widget(const Widget&&) = delete;
|
||||
Widget(const Widget&) = delete;
|
||||
void operator=(const Widget&) = delete;
|
||||
|
||||
Widget();
|
||||
virtual ~Widget();
|
||||
|
||||
void addChild(Widget* child, bool front = false);
|
||||
void removeChild(Widget* child);
|
||||
void clear();
|
||||
|
||||
const List<Widget*>& getChildren() { return mDepthOrder; }
|
||||
|
||||
void bringToFront();
|
||||
void bringToBack();
|
||||
|
||||
void setLayout(WidgetLayout* layout);
|
||||
void setSizePolicy(SizePolicy x, SizePolicy y);
|
||||
|
||||
void setEnabled(bool val) { mFlags.set(ENABLED, val); }
|
||||
[[nodiscard]] bool getEnabled() const { return mFlags.get(ENABLED); }
|
||||
|
||||
WidgetLayout* getLayout();
|
||||
[[nodiscard]] const WidgetLayout* getLayout() const;
|
||||
|
||||
void triggerWidgetUpdate(const char* reason = nullptr);
|
||||
|
||||
void openPopup(Widget*);
|
||||
void closePopup(Widget*);
|
||||
|
||||
void lockFocus();
|
||||
void freeFocus();
|
||||
|
||||
protected:
|
||||
virtual void process(const InputView& events) {}
|
||||
virtual void draw(Canvas& canvas) {}
|
||||
virtual void drawOverlay(Canvas& canvas) {}
|
||||
virtual void endAnimations();
|
||||
virtual void updateAnimations();
|
||||
|
||||
[[nodiscard]] virtual bool processesEvents() const;
|
||||
[[nodiscard]] virtual bool propagateEventsToChildren() const;
|
||||
[[nodiscard]] virtual bool needsNextFrame() const;
|
||||
|
||||
virtual void mouseEnter();
|
||||
virtual void mouseLeave();
|
||||
|
||||
protected:
|
||||
WidgetManagerInterface* getRoot();
|
||||
|
||||
public:
|
||||
[[nodiscard]] RectF getArea() const;
|
||||
[[nodiscard]] RectF getAreaT() const;
|
||||
|
||||
[[nodiscard]] RectF getRelativeArea() const;
|
||||
[[nodiscard]] RectF getRelativeAreaT() const;
|
||||
|
||||
void setArea(const RectF& area);
|
||||
void setAreaCache(const RectF& area);
|
||||
|
||||
private:
|
||||
[[nodiscard]] bool isUpdate() const { return mFlags.get(ENABLED) && mFlags.get(NEEDS_UPDATE); }
|
||||
[[nodiscard]] bool isDraw() const { return mFlags.get(ENABLED); }
|
||||
|
||||
static void dfs(Widget* iter, const DFSAction& before, const DFSAction& after = [](auto){}) {
|
||||
if (!iter->isUpdate()) return;
|
||||
|
||||
before(iter);
|
||||
|
||||
for (auto child : iter->mDepthOrder) {
|
||||
dfs(child.data(), before, after);
|
||||
}
|
||||
|
||||
after(iter);
|
||||
}
|
||||
|
||||
protected:
|
||||
friend WidgetLayout;
|
||||
|
||||
Widget* mParent = nullptr;
|
||||
|
||||
std::vector<Widget*> mChildren;
|
||||
List<Widget*> mDepthOrder;
|
||||
|
||||
// relative to the parent
|
||||
SpringRect mArea;
|
||||
RectF mAreaCache;
|
||||
|
||||
WidgetLayout* mLayout = nullptr;
|
||||
|
||||
BitField mFlags;
|
||||
};
|
||||
|
||||
struct WidgetManagerInterface : public Widget {
|
||||
virtual void updateWidget(Widget*, const char* reason) = 0;
|
||||
|
||||
virtual void openPopup(Widget*) = 0;
|
||||
virtual void closePopup(Widget*) = 0;
|
||||
|
||||
virtual void lockFocus(Widget*) = 0;
|
||||
virtual void freeFocus(Widget*) = 0;
|
||||
|
||||
static WidgetLayout* defaultLayout(Widget* widget);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "GraphicApplication.hpp"
|
||||
#include "RootWidget.hpp"
|
||||
|
||||
namespace tp {
|
||||
class WidgetApplication : public ApplicationDelegate {
|
||||
public:
|
||||
explicit WidgetApplication(const ApplicationOptions& options = {});
|
||||
|
||||
void run() { mApplication.run(); }
|
||||
[[nodiscard]] Application& application() { return mApplication; }
|
||||
[[nodiscard]] Window& window() { return mApplication.window(); }
|
||||
[[nodiscard]] Canvas& canvas() { return mApplication.canvas(); }
|
||||
|
||||
void setRoot(Widget* widget);
|
||||
|
||||
private:
|
||||
void processFrame(const InputState& input, halnf deltaMs) override;
|
||||
void drawFrame(Canvas& canvas) override;
|
||||
bool wantsFrame() override;
|
||||
|
||||
private:
|
||||
RootWidget mRootWidget;
|
||||
Application mApplication;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "Layout.hpp"
|
||||
|
||||
namespace tp {
|
||||
|
||||
class BasicLayout : public WidgetLayout {
|
||||
|
||||
public:
|
||||
explicit BasicLayout(Widget* widget) : WidgetLayout(widget) {}
|
||||
|
||||
void updateLayout(bool vertical) override;
|
||||
|
||||
void pickRect(bool vertical) override;
|
||||
void clampRect() override;
|
||||
[[nodiscard]] RectF getAvailableChildArea() const override;
|
||||
|
||||
void setLayoutPolicy(LayoutPolicy layout) { mLayoutPolicy = layout; }
|
||||
|
||||
private:
|
||||
void adjustLayout(bool vertical);
|
||||
static halnf changeChildSize(Widget*, halnf diff, bool vertical);
|
||||
|
||||
private:
|
||||
LayoutPolicy mLayoutPolicy = LayoutPolicy::Vertical;
|
||||
halnf mLayoutGap = 5;
|
||||
halnf mLayoutMargin = 9;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
#include "Widget.hpp"
|
||||
|
||||
namespace tp {
|
||||
class DockLayout : public WidgetLayout {
|
||||
public:
|
||||
enum Side { LEFT, TOP, RIGHT, BOTTOM, NONE };
|
||||
|
||||
private:
|
||||
struct ResizeHandle {
|
||||
RectF area{ 0, 0, 0, 0 };
|
||||
halnf start{ 0 };
|
||||
halnf end{ 0 };
|
||||
bool active = false;
|
||||
bool hover = false;
|
||||
};
|
||||
|
||||
struct SideWidgetData {
|
||||
Widget* widget = nullptr;
|
||||
bool hidden = false;
|
||||
halnf absoluteSize = 300;
|
||||
alni order = -1;
|
||||
|
||||
Side side = { TOP };
|
||||
|
||||
RectF area = {};
|
||||
RectF headerArea = {};
|
||||
RectF previewHandleArea = {};
|
||||
|
||||
ResizeHandle resizeHandle;
|
||||
|
||||
RectF areaBeforeDocking = {};
|
||||
};
|
||||
|
||||
public:
|
||||
explicit DockLayout(Widget* widget);
|
||||
|
||||
void updateResizeHover(const Vec2F& pointer);
|
||||
bool startResize(const Vec2F& pointer);
|
||||
void updateResize(const Vec2F& pointerDelta);
|
||||
void endResize();
|
||||
|
||||
void updatePreviewSide(const Vec2F& pointer);
|
||||
|
||||
public:
|
||||
void pickRect(bool vertical) override {}
|
||||
void clampRect() override {};
|
||||
void updateLayout(bool vertical) override;
|
||||
void adjustChildrenRect();
|
||||
|
||||
public:
|
||||
bool setCenterWidget(Widget* widget);
|
||||
|
||||
bool dockWidget(Widget* widget, Side side);
|
||||
bool undockWidget(Side side);
|
||||
void toggleWidgetVisibility(Side side);
|
||||
|
||||
public:
|
||||
[[nodiscard]] bool isSideVisible(Side side) const;
|
||||
[[nodiscard]] bool sideExists(Side side) const;
|
||||
|
||||
[[nodiscard]] bool isResizing() const { return mResizing; }
|
||||
[[nodiscard]] bool isResizing(Side side) const { return mSideWidgets[side].resizeHandle.active; }
|
||||
[[nodiscard]] bool isResizeHandleHover(Side side) const { return mSideWidgets[side].resizeHandle.hover; }
|
||||
|
||||
Side getSideFromWidget(Widget*);
|
||||
Side getPreviewSide();
|
||||
|
||||
[[nodiscard]] RectF getRectBeforeDocked(Side side) const { return mSideWidgets[side].areaBeforeDocking; }
|
||||
[[nodiscard]] RectF getResizeHandleArea(Side side) const { return mSideWidgets[side].resizeHandle.area; }
|
||||
[[nodiscard]] RectF getHeaderArea(Side side) const { return mSideWidgets[side].headerArea; }
|
||||
[[nodiscard]] RectF getPreviewHandleArea(Side side) const { return mSideWidgets[side].previewHandleArea; }
|
||||
[[nodiscard]] RectF getPreviewArea() const { return mPreviewArea; }
|
||||
|
||||
Widget* getSideWidget(Side side) { return mSideWidgets[side].widget; }
|
||||
|
||||
const std::vector<Widget*>& getDockedWidgets();
|
||||
|
||||
private:
|
||||
ualni getVisibleSidesSize() const;
|
||||
|
||||
void calculateSideAreas();
|
||||
void calculateResizeHandles();
|
||||
void updateChildSideWidgets();
|
||||
void calculateHeaderAreas();
|
||||
|
||||
private:
|
||||
RectF mPreviewArea = {};
|
||||
Side mPreviewSide = NONE;
|
||||
int resizeType[2] = { 0, 0 };
|
||||
|
||||
SideWidgetData mSideWidgets[4];
|
||||
|
||||
std::vector<Widget*> mDockedWidgets;
|
||||
|
||||
private:
|
||||
bool mResizing = false;
|
||||
|
||||
Widget* mCenterWidget = nullptr;
|
||||
RectF mCenterArea { 0, 0, 10, 10 };
|
||||
|
||||
// Parameters
|
||||
const halnf mHandleSplitFactor = 0.3;
|
||||
const halnf mHandleFactor = 0.1;
|
||||
|
||||
halnf mSideSizePadding = 150.f;
|
||||
halnf mPadding = 4;
|
||||
halnf mHeaderSize = 27;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "BasicLayout.hpp"
|
||||
|
||||
namespace tp {
|
||||
class FloatingLayout : public BasicLayout {
|
||||
public:
|
||||
explicit FloatingLayout(Widget* widget) :
|
||||
BasicLayout(widget) {}
|
||||
|
||||
public:
|
||||
void startAction(const Vec2F& pointer);
|
||||
void updateAction(const Vec2F& pointer);
|
||||
void endAction();
|
||||
|
||||
bool isFloating() const { return mIsFloating; }
|
||||
|
||||
RectF resizeHandleRect();
|
||||
|
||||
public:
|
||||
void pickRect(bool vertical) override;
|
||||
|
||||
private:
|
||||
bool mIsFloating = false;
|
||||
bool mIsResizing = false;
|
||||
|
||||
Vec2F mPointerStart;
|
||||
Vec2F mPointerCurrent;
|
||||
|
||||
// TODO : remove?
|
||||
halnf mHandleSize = 10;
|
||||
halnf mHandlePadding = 2;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "Layout.hpp"
|
||||
|
||||
namespace tp {
|
||||
class ScrollableBarWidget;
|
||||
|
||||
class ScrollableLayout : public WidgetLayout {
|
||||
public:
|
||||
explicit ScrollableLayout(Widget* widget) :
|
||||
WidgetLayout(widget) {
|
||||
setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding);
|
||||
}
|
||||
|
||||
void updateLayout(bool vertical) override;
|
||||
[[nodiscard]] RectF getAvailableChildArea() const override;
|
||||
|
||||
private:
|
||||
void updateWidgetRects(const RectF& area, Widget* content, ScrollableBarWidget* scroller) const;
|
||||
|
||||
private:
|
||||
halnf mScrollerSize = 15;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "Layout.hpp"
|
||||
|
||||
namespace tp {
|
||||
|
||||
class OverlayLayout : public WidgetLayout {
|
||||
|
||||
public:
|
||||
explicit OverlayLayout(Widget* widget) : WidgetLayout(widget) {}
|
||||
|
||||
void updateLayout(bool vertical) override;
|
||||
|
||||
void pickRect(bool vertical) override {}
|
||||
void clampRect() override {}
|
||||
};
|
||||
|
||||
class ToolBarLayout : public WidgetLayout {
|
||||
|
||||
public:
|
||||
explicit ToolBarLayout(Widget* widget) : WidgetLayout(widget) {}
|
||||
|
||||
void updateLayout(bool vertical) override;
|
||||
|
||||
void pickRect(bool vertical) override {}
|
||||
void clampRect() override {}
|
||||
|
||||
private:
|
||||
halnf mToolBarHeight = 55;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "Layout.hpp"
|
||||
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
namespace tp {
|
||||
class LayoutManager {
|
||||
struct DepNode {
|
||||
std::vector<Widget*> depends;
|
||||
int references = 0;
|
||||
int depth = 0;
|
||||
};
|
||||
|
||||
public:
|
||||
LayoutManager() = default;
|
||||
|
||||
void adjust(Widget* root);
|
||||
|
||||
private:
|
||||
void findDependencies(Widget* root);
|
||||
void topologicalSort(Widget* root, int depth = 0);
|
||||
void adjustLayouts();
|
||||
|
||||
private:
|
||||
int getLayoutOrder(WidgetLayout* parent, WidgetLayout* child) const;
|
||||
|
||||
private:
|
||||
std::map<Widget*, DepNode> mDepGraph;
|
||||
std::vector<Widget*> mRoots;
|
||||
|
||||
std::vector<std::pair<Widget*, int>> mLayOrder;
|
||||
bool mVertical = false;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include <map>
|
||||
|
||||
namespace tp {
|
||||
// FIXME : desperately needs refactor
|
||||
class UpdateManager {
|
||||
public:
|
||||
UpdateManager() = default;
|
||||
|
||||
void scheduleUpdate(Widget* widget, const char* reason);
|
||||
|
||||
void lockFocus(Widget* widget);
|
||||
void freeFocus(Widget* widget);
|
||||
|
||||
[[nodiscard]] bool isPendingUpdates() const {
|
||||
return !mTriggeredWidgets.empty();
|
||||
}
|
||||
|
||||
void processWidgets(Widget* root, const InputState& input);
|
||||
void clean();
|
||||
|
||||
void updateTreeToProcess(Widget* root);
|
||||
void handleFocusChanges(Widget* root, const InputView& input);
|
||||
|
||||
void findFocusWidget(Widget* iter, Widget** focus, const Vec2F& pointer);
|
||||
static void getWidgetPath(Widget* widget, std::vector<Widget*>& out);
|
||||
void processActiveTree(Widget* iter, const InputState& input, Vec2F pos);
|
||||
void processFocusItems(const InputState& input);
|
||||
|
||||
private:
|
||||
static void procWidget(Widget* widget, const InputView& input);
|
||||
|
||||
private:
|
||||
std::map<Widget*, bool> mTriggeredWidgets;
|
||||
Widget* mInFocusWidget = nullptr;
|
||||
Widget* mFocusLockWidget = nullptr;
|
||||
|
||||
private:
|
||||
};
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
namespace tp {
|
||||
class AnimationTestWidget : public Widget {
|
||||
|
||||
public:
|
||||
AnimationTestWidget() = default;
|
||||
~AnimationTestWidget() override = default;
|
||||
|
||||
void draw(Canvas& canvas) override;
|
||||
void process(const InputView& events) override;
|
||||
|
||||
[[nodiscard]] bool needsNextFrame() const override;
|
||||
|
||||
private:
|
||||
mutable SpringRect mTestSpring;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
namespace tp {
|
||||
class RGBPickerWidget : public Widget {
|
||||
public:
|
||||
RGBPickerWidget() = default;
|
||||
|
||||
void process(const InputView& events) override;
|
||||
void draw(Canvas& canvas) override;
|
||||
|
||||
[[nodiscard]] bool processesEvents() const override { return true; }
|
||||
|
||||
[[nodiscard]] const HSV& color() const { return mColor; }
|
||||
|
||||
private:
|
||||
enum class Part { Outside, HueRing, SaturationValueTriangle };
|
||||
|
||||
struct Geometry {
|
||||
Vec2F center;
|
||||
halnf outerRadius = 0;
|
||||
|
||||
[[nodiscard]] halnf innerRadius() const;
|
||||
[[nodiscard]] halnf triangleRadius() const;
|
||||
};
|
||||
|
||||
[[nodiscard]] Geometry geometry() const;
|
||||
[[nodiscard]] Part partAt(Vec2F point) const;
|
||||
void pick(Vec2F point);
|
||||
|
||||
void drawHueRing(Canvas& canvas, const Geometry& wheel) const;
|
||||
void drawHueMarker(Canvas& canvas, const Geometry& wheel) const;
|
||||
void drawSaturationValueTriangle(Canvas& canvas, const Geometry& wheel) const;
|
||||
void drawSaturationValueMarker(Canvas& canvas, const Geometry& wheel) const;
|
||||
|
||||
HSV mColor = { 0.13f, 0.5f, 1 };
|
||||
Part mDragging = Part::Outside;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
#include "DockLayout.hpp"
|
||||
|
||||
namespace tp {
|
||||
class DockWidget : public Widget {
|
||||
public:
|
||||
DockWidget();
|
||||
|
||||
public:
|
||||
void process(const InputView& events) override;
|
||||
void draw(Canvas& canvas) override;
|
||||
void drawOverlay(Canvas& canvas) override;
|
||||
|
||||
[[nodiscard]] bool propagateEventsToChildren() const override;
|
||||
[[nodiscard]] bool needsNextFrame() const override;
|
||||
[[nodiscard]]bool processesEvents() const override { return true; }
|
||||
|
||||
void drawSide(DockLayout::Side side, Canvas& canvas);
|
||||
|
||||
public:
|
||||
DockLayout::Side getSide(Widget* widget) { return layout()->getSideFromWidget(widget); }
|
||||
|
||||
void setCenterWidget(Widget* widget);
|
||||
|
||||
void dockWidget(Widget* widget, DockLayout::Side side);
|
||||
void undockWidget(DockLayout::Side side, bool restoreArea = true);
|
||||
void toggleWidgetVisibility(DockLayout::Side side);
|
||||
|
||||
private:
|
||||
DockLayout* layout() { return dynamic_cast<DockLayout*>(mLayout); }
|
||||
const DockLayout* layout() const { return dynamic_cast<const DockLayout*>(mLayout); }
|
||||
|
||||
private:
|
||||
Widget* mPreviewWidget = nullptr;
|
||||
|
||||
private:
|
||||
halnf mRounding = 10;
|
||||
halnf mPadding = 4;
|
||||
|
||||
RGBA mResizeHandleColorHovered = RGBA(0.3, 0.3, 0.3, 1);
|
||||
RGBA mResizeHandleColorActive = RGBA(0.6, 0.6, 0.6, 1);
|
||||
RGBA mBackgroundColor = RGBA(0, 0, 0, 1);
|
||||
RGBA mPreviewColor = RGBA(0.6, 0.6, 0.6, 0.2);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "SimpleWidgets.hpp"
|
||||
#include "FloatingLayout.hpp"
|
||||
#include "ScrollableWidget.hpp"
|
||||
|
||||
namespace tp {
|
||||
class FloatingWidget : public Widget {
|
||||
public:
|
||||
FloatingWidget() : Widget() {
|
||||
setLayout(new FloatingLayout(this));
|
||||
}
|
||||
|
||||
void process(const InputView& events) override;
|
||||
|
||||
void draw(Canvas& canvas) override;
|
||||
|
||||
|
||||
[[nodiscard]] bool needsNextFrame() const override;
|
||||
|
||||
[[nodiscard]] bool propagateEventsToChildren() const override;
|
||||
[[nodiscard]] bool processesEvents() const override;
|
||||
|
||||
[[nodiscard]] bool isFloating() const;
|
||||
|
||||
private:
|
||||
FloatingLayout* layout();
|
||||
[[nodiscard]] const FloatingLayout* layout() const;
|
||||
};
|
||||
|
||||
class FloatingMenu : public FloatingWidget {
|
||||
public:
|
||||
FloatingMenu();
|
||||
|
||||
public:
|
||||
void addToMenu(Widget* widget) {
|
||||
widget->setSizePolicy(SizePolicy::Expanding, SizePolicy::Minimal);
|
||||
mContentWidget.addChild(widget);
|
||||
}
|
||||
|
||||
const List<Widget*>& getContent() {
|
||||
return mContentWidget.getChildren();
|
||||
}
|
||||
|
||||
void clearChildren() {
|
||||
mContentWidget.clear();
|
||||
}
|
||||
|
||||
void setText(const std::string& text);
|
||||
|
||||
private:
|
||||
// VerticalLayout mMenuLayout;
|
||||
Widget mBodyLayout;
|
||||
Widget mContentWidget;
|
||||
ScrollableBarWidget mScrollBar;
|
||||
|
||||
LabelWidget mHeader;
|
||||
|
||||
// ButtonWidget mTestButton;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
namespace tp {
|
||||
// just the scrolling bar
|
||||
class ScrollableBarWidget : public Widget {
|
||||
public:
|
||||
ScrollableBarWidget() = default;
|
||||
|
||||
[[nodiscard]] bool processesEvents() const override { return true; }
|
||||
[[nodiscard]] bool propagateEventsToChildren() const override { return false; }
|
||||
[[nodiscard]] bool needsNextFrame() const override { return mScrolling; }
|
||||
|
||||
void process(const InputView& events) override;
|
||||
void draw(Canvas& canvas) override;
|
||||
|
||||
void updateSizeFactor(halnf factor); // holder-size divided by content-size
|
||||
|
||||
[[nodiscard]] bool getDirection() const { return mVertical; }
|
||||
[[nodiscard]] halnf getPosFactor() const { return mPosFactor - mSizeFactor / 2; }
|
||||
|
||||
void setDirection(bool direction) { mVertical = direction; }
|
||||
|
||||
private:
|
||||
[[nodiscard]] const RectF& getHandleRect() const;
|
||||
void updateHandleRect();
|
||||
void jumpTo(halnf);
|
||||
void moveBy(halnf);
|
||||
void clamp();
|
||||
|
||||
private: // state
|
||||
bool mVertical = true;
|
||||
bool mScrolling = false;
|
||||
bool mHandleHovered = false;
|
||||
|
||||
Vec2F mStartPos = {};
|
||||
|
||||
halnf mPosFactor = 0.2f; // (center-of-holder - content-start) / content-size
|
||||
halnf mSizeFactor = 0.3f;
|
||||
|
||||
private: // TODO : make params static
|
||||
halnf mRounding = 5;
|
||||
halnf mHandlePadding = 0;
|
||||
|
||||
RGBA mBGColor = { 0.1, 0.1, 0.1, 0.0 };
|
||||
RGBA mHandleColor = { 0.2, 0.2, 0.2, 1 };
|
||||
RGBA mHandleHoverColor = { 0.3, 0.3, 0.3, 1 };
|
||||
RGBA mHandleSlideColor = { 0.5, 0.5, 0.5, 1 };
|
||||
|
||||
private: // cache
|
||||
RectF mHandleRect;
|
||||
};
|
||||
|
||||
class ScrollableWidget : public Widget {
|
||||
public:
|
||||
ScrollableWidget();
|
||||
|
||||
void setDirection(bool direction);
|
||||
|
||||
Widget* getContainer() { return &mContent; }
|
||||
|
||||
private:
|
||||
Widget mContent;
|
||||
ScrollableBarWidget mScroller;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include <functional>
|
||||
|
||||
namespace tp {
|
||||
class LabelWidget : public Widget {
|
||||
public:
|
||||
LabelWidget() : Widget() {
|
||||
}
|
||||
|
||||
void setText(const std::string& text);
|
||||
|
||||
[[nodiscard]] const std::string& getText() const;
|
||||
|
||||
void draw(Canvas& canvas) override;
|
||||
|
||||
[[nodiscard]] bool processesEvents() const override { return false; }
|
||||
|
||||
private:
|
||||
std::string mText = "Text";
|
||||
|
||||
halnf mPadding = 5;
|
||||
RGBA mColor = 1.f;
|
||||
halnf mSize = 17.f;
|
||||
};
|
||||
|
||||
class ButtonWidget : public LabelWidget {
|
||||
public:
|
||||
ButtonWidget();
|
||||
|
||||
void setAction(const std::function<void()>& action);
|
||||
|
||||
void process(const InputView& inputState) override;
|
||||
void draw(Canvas& canvas) override;
|
||||
|
||||
void mouseEnter() override;
|
||||
void mouseLeave() override;
|
||||
|
||||
[[nodiscard]] bool processesEvents() const override { return true; }
|
||||
[[nodiscard]] bool needsNextFrame() const override;
|
||||
|
||||
void endAnimations() override;
|
||||
void updateAnimations() override;
|
||||
|
||||
void setColor(const RGBA& in);
|
||||
|
||||
private:
|
||||
std::function<void()> mAction;
|
||||
|
||||
SpringRect mColorAnimated;
|
||||
|
||||
halnf mRounding = 5;
|
||||
RGBA mColorHovered = { 0.0f, 0.4f, 0.4f, 1.f };
|
||||
RGBA mColor = { 0.13f, 0.13f, 0.13f, 1.f };
|
||||
};
|
||||
|
||||
class SliderWidget : public Widget {
|
||||
enum State {
|
||||
IDLE,
|
||||
HOVER,
|
||||
SLIDING,
|
||||
};
|
||||
|
||||
public:
|
||||
SliderWidget() = default;
|
||||
|
||||
void process(const InputView& inputState) override;
|
||||
void draw(Canvas& canvas) override;
|
||||
|
||||
[[nodiscard]] bool processesEvents() const override { return true; }
|
||||
[[nodiscard]] bool needsNextFrame() const override { return mState != IDLE; }
|
||||
|
||||
[[nodiscard]] halnf val() const { return mFactor; }
|
||||
|
||||
private:
|
||||
[[nodiscard]] RectF getHandleArea() const;
|
||||
|
||||
private:
|
||||
halnf mFactor = 0;
|
||||
State mState = IDLE;
|
||||
|
||||
private:
|
||||
halnf mHandleSize = 20;
|
||||
halnf mRounding = 5;
|
||||
|
||||
RGBA mColorActive = { 0.99f, 0.99f, 0.99f, 1.f };
|
||||
RGBA mColorHovered = { 0.9f, 0.9f, 0.9f, 1.f };
|
||||
RGBA mColorIdle = { 0.8f, 0.8f, 0.8f, 1.f };
|
||||
RGBA mColorBG = { 0.3f, 0.3f, 0.3f, 1.0f };
|
||||
};
|
||||
|
||||
class PopupWidget : public Widget {
|
||||
public:
|
||||
PopupWidget() = default;
|
||||
|
||||
public:
|
||||
void process(const InputView& events) override;
|
||||
void draw(Canvas& canvas) override;
|
||||
|
||||
void open(Widget* parent, const RectF& at);
|
||||
[[nodiscard]] bool processesEvents() const override { return true; }
|
||||
|
||||
private:
|
||||
RectF mParentArea {};
|
||||
RGBA col = RGBA(0.03f, 0.03f, 0.03f, 0.9f);
|
||||
halnf rounding = 5;
|
||||
halnf borders = 2;
|
||||
};
|
||||
|
||||
class HoverPopupTriggerWidget : public LabelWidget {
|
||||
public:
|
||||
enum ExpandDirection {
|
||||
Right,
|
||||
Bottom,
|
||||
};
|
||||
|
||||
public:
|
||||
HoverPopupTriggerWidget() = default;
|
||||
|
||||
[[nodiscard]] bool processesEvents() const override { return true; }
|
||||
void mouseEnter() override;
|
||||
|
||||
void draw(Canvas& canvas) override;
|
||||
|
||||
PopupWidget* getPopup();
|
||||
void setDirection(ExpandDirection dir) { mDirection = dir; }
|
||||
|
||||
private:
|
||||
PopupWidget mPopup;
|
||||
|
||||
private:
|
||||
ExpandDirection mDirection = Bottom;
|
||||
RGBA col = RGBA(0.03f, 0.03f, 0.03f, 0.07f);
|
||||
halnf rounding = 5;
|
||||
halnf gap = 7;
|
||||
};
|
||||
}
|
||||
138
Widgets/src/app/private/AppDriver.cpp
Normal file
138
Widgets/src/app/private/AppDriver.cpp
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
#include "AppDriver.hpp"
|
||||
|
||||
#include "Widget.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
AppDriver::AppDriver(WidgetApplication& application) :
|
||||
mApplication(application),
|
||||
mInput(application.window().input().injectInput()) {
|
||||
mApplication.window().input().setPlatformInputEnabled(false);
|
||||
mApplication.start();
|
||||
mInput.post(KeyboardFocusChanged{ true });
|
||||
frames(2);
|
||||
}
|
||||
|
||||
AppDriver::~AppDriver() {
|
||||
mApplication.finish();
|
||||
mApplication.window().input().setPlatformInputEnabled(true);
|
||||
}
|
||||
|
||||
void AppDriver::frame(halnf deltaMs) { mApplication.step(deltaMs); }
|
||||
|
||||
void AppDriver::frames(int count) {
|
||||
for (int index = 0; index < count; index++)
|
||||
frame();
|
||||
}
|
||||
|
||||
void AppDriver::advance(halnf milliseconds) {
|
||||
while (milliseconds > 0) {
|
||||
const halnf delta = milliseconds < kFrameMs ? milliseconds : kFrameMs;
|
||||
frame(delta);
|
||||
milliseconds -= delta;
|
||||
}
|
||||
}
|
||||
|
||||
void AppDriver::moveTo(Vec2F windowPoint) {
|
||||
mInput.post(PointerMoved{ windowPoint });
|
||||
frame();
|
||||
}
|
||||
|
||||
void AppDriver::press(tp::PointerButton button) {
|
||||
mInput.post(PointerButtonChanged{ button, true });
|
||||
frame();
|
||||
}
|
||||
|
||||
void AppDriver::release(tp::PointerButton button) {
|
||||
mInput.post(PointerButtonChanged{ button, false });
|
||||
frame();
|
||||
}
|
||||
|
||||
void AppDriver::click(Vec2F windowPoint, tp::PointerButton button) {
|
||||
moveTo(windowPoint);
|
||||
press(button);
|
||||
release(button);
|
||||
advance(kClickGapMs);
|
||||
}
|
||||
|
||||
void AppDriver::click(const Widget& widget) {
|
||||
settle(widget);
|
||||
click(centerOf(widget));
|
||||
}
|
||||
|
||||
void AppDriver::settle(const Widget& widget) {
|
||||
for (int index = 0; index < kSettleFrameLimit; index++) {
|
||||
frame();
|
||||
if (!isMoving(widget)) return;
|
||||
}
|
||||
}
|
||||
|
||||
bool AppDriver::isMoving(const Widget& widget) {
|
||||
for (const Widget* current = &widget; current; current = current->parent()) {
|
||||
if (current->isGeometryMoving() || current->isTransitioning()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void AppDriver::penStroke(const std::vector<Vec2F>& points, const std::vector<PointerSample>& samples) {
|
||||
if (points.empty() || points.size() != samples.size()) return;
|
||||
|
||||
mInput.post(PointerSampled{ samples.front() });
|
||||
mInput.post(PointerMoved{ points.front() });
|
||||
mInput.post(PointerButtonChanged{ tp::PointerButton::Left, true });
|
||||
frame();
|
||||
for (size_t index = 1; index < points.size(); index++) {
|
||||
mInput.post(PointerSampled{ samples[index] });
|
||||
mInput.post(PointerMoved{ points[index] });
|
||||
frame();
|
||||
}
|
||||
mInput.post(PointerButtonChanged{ tp::PointerButton::Left, false });
|
||||
mInput.post(PointerSampled{ PointerSample{} });
|
||||
frame();
|
||||
}
|
||||
|
||||
void AppDriver::drag(Vec2F from, Vec2F to, int steps) {
|
||||
moveTo(from);
|
||||
press();
|
||||
for (int step = 1; step <= steps; step++) {
|
||||
const halnf amount = (halnf) step / (halnf) steps;
|
||||
moveTo(from + (to - from) * amount);
|
||||
}
|
||||
release();
|
||||
advance(kClickGapMs);
|
||||
}
|
||||
|
||||
void AppDriver::scroll(Vec2F windowPoint, halnf amount) {
|
||||
moveTo(windowPoint);
|
||||
mInput.post(Scrolled{ Vec2F(0.0f, amount) });
|
||||
frame();
|
||||
}
|
||||
|
||||
void AppDriver::keyDown(Key key) {
|
||||
mInput.post(KeyChanged{ key, KeyAction::Press });
|
||||
frame();
|
||||
}
|
||||
|
||||
void AppDriver::keyUp(Key key) {
|
||||
mInput.post(KeyChanged{ key, KeyAction::Release });
|
||||
frame();
|
||||
}
|
||||
|
||||
void AppDriver::key(Key key) {
|
||||
keyDown(key);
|
||||
keyUp(key);
|
||||
}
|
||||
|
||||
void AppDriver::shortcut(Key modifier, Key key) {
|
||||
keyDown(modifier);
|
||||
this->key(key);
|
||||
keyUp(modifier);
|
||||
}
|
||||
|
||||
void AppDriver::type(const std::string& utf8) {
|
||||
mInput.post(TextEntered{ utf8 });
|
||||
frame();
|
||||
}
|
||||
|
||||
Vec2F AppDriver::centerOf(const Widget& widget) { return widget.windowRect().center(); }
|
||||
34
Widgets/src/app/private/CanvasImageLoader.cpp
Normal file
34
Widgets/src/app/private/CanvasImageLoader.cpp
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#include "CanvasImageLoader.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
CanvasImageLoader::CanvasImageLoader(Canvas& canvas) :
|
||||
mCanvas(canvas) {}
|
||||
|
||||
CanvasImageLoader::~CanvasImageLoader() {
|
||||
for (const auto& [id, handle] : mHandles) {
|
||||
mCanvas.deleteImage(handle);
|
||||
}
|
||||
}
|
||||
|
||||
LoadedImage CanvasImageLoader::load(const std::string& path) {
|
||||
const auto found = mLoaded.find(path);
|
||||
if (found != mLoaded.end()) return found->second;
|
||||
|
||||
const ImageHandle handle = mCanvas.createImageFromFile(path.c_str());
|
||||
LoadedImage image;
|
||||
if (handle.isValid()) {
|
||||
const ualni id = mNextId++;
|
||||
mHandles.emplace(id, handle);
|
||||
image.id = { id };
|
||||
image.size = mCanvas.imageSize(handle);
|
||||
}
|
||||
mLoaded.emplace(path, image);
|
||||
return image;
|
||||
}
|
||||
|
||||
ImageHandle CanvasImageLoader::handle(ImageId image) const {
|
||||
const auto found = mHandles.find(image.value);
|
||||
return found != mHandles.end() ? found->second : ImageHandle();
|
||||
}
|
||||
129
Widgets/src/app/private/CanvasPainter.cpp
Normal file
129
Widgets/src/app/private/CanvasPainter.cpp
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#include "CanvasPainter.hpp"
|
||||
|
||||
#include "CanvasImageLoader.hpp"
|
||||
|
||||
using namespace tp;
|
||||
|
||||
namespace {
|
||||
|
||||
tp::TextAlign canvasAlign(tp::ui::TextAlign align) {
|
||||
switch (align) {
|
||||
case tp::ui::TextAlign::Start: return tp::TextAlign::leftCenter();
|
||||
case tp::ui::TextAlign::Center: return tp::TextAlign::centered();
|
||||
case tp::ui::TextAlign::End: return { tp::HorizontalAlign::Right, tp::VerticalAlign::Center };
|
||||
}
|
||||
return tp::TextAlign::leftCenter();
|
||||
}
|
||||
|
||||
tp::FontFace canvasFace(tp::ui::FontFace face) {
|
||||
switch (face) {
|
||||
case tp::ui::FontFace::Regular: return tp::FontFace::Regular;
|
||||
case tp::ui::FontFace::Bold: return tp::FontFace::Bold;
|
||||
case tp::ui::FontFace::Italic: return tp::FontFace::Italic;
|
||||
case tp::ui::FontFace::BoldItalic: return tp::FontFace::BoldItalic;
|
||||
case tp::ui::FontFace::Monospace: return tp::FontFace::Monospace;
|
||||
}
|
||||
return tp::FontFace::Regular;
|
||||
}
|
||||
|
||||
constexpr int kHueSegments = 6;
|
||||
}
|
||||
|
||||
tp::ui::CanvasPainter::CanvasPainter(Canvas& canvas, const CanvasImageLoader& loader) :
|
||||
mCanvas(canvas),
|
||||
mLoader(loader) {}
|
||||
|
||||
void tp::ui::CanvasPainter::pushTranslation(Vec2F offset) { mStates.push_back(std::make_unique<Canvas::Translation>(mCanvas, offset)); }
|
||||
|
||||
void tp::ui::CanvasPainter::popTranslation() {
|
||||
if (!mStates.empty()) mStates.pop_back();
|
||||
}
|
||||
|
||||
// Note: the Canvas exposes no clip-only scope (clipTo is private), so clipping is only save/restored here.
|
||||
void tp::ui::CanvasPainter::pushClip(const RectF& rect) { mStates.push_back(std::make_unique<Canvas::SavedState>(mCanvas)); }
|
||||
|
||||
void tp::ui::CanvasPainter::popClip() {
|
||||
if (!mStates.empty()) mStates.pop_back();
|
||||
}
|
||||
|
||||
void tp::ui::CanvasPainter::drawFilledRect(const RectF& rect, const RGBA& color, halnf cornerRadius) {
|
||||
mCanvas.rect(rect, color, cornerRadius);
|
||||
}
|
||||
|
||||
void tp::ui::CanvasPainter::drawStrokedRect(const RectF& rect, const RGBA& color, halnf cornerRadius, halnf width) {
|
||||
mCanvas.outline(rect, color, cornerRadius, width);
|
||||
}
|
||||
|
||||
void tp::ui::CanvasPainter::drawStrokedCircle(Vec2F center, halnf radius, const RGBA& color, halnf width) {
|
||||
mCanvas.circleOutline(center, radius, color, width);
|
||||
}
|
||||
|
||||
void tp::ui::CanvasPainter::polyline(const std::vector<Vec2F>& points, const RGBA& color, halnf thickness) {
|
||||
mCanvas.polyline(points.data(), (int) points.size(), color, thickness);
|
||||
}
|
||||
|
||||
void tp::ui::CanvasPainter::fillPolygon(const std::vector<Vec2F>& points, const RGBA& color) {
|
||||
mCanvas.polygon(points.data(), (int) points.size(), color);
|
||||
}
|
||||
|
||||
void tp::ui::CanvasPainter::fillCircle(Vec2F center, halnf radius, const RGBA& color) { mCanvas.circle(center, radius, color); }
|
||||
|
||||
void tp::ui::CanvasPainter::line(Vec2F from, Vec2F to, const RGBA& color, halnf thickness) {
|
||||
mCanvas.line(from, to, color, thickness);
|
||||
}
|
||||
|
||||
void tp::ui::CanvasPainter::styledText(
|
||||
const std::string& text, const RectF& box, const tp::ui::TextStyle& style, tp::ui::TextAlign align, const RGBA& color
|
||||
) {
|
||||
const tp::TextStyle canvasStyle{ style.fontSize, color, canvasAlign(align), 0, canvasFace(style.face) };
|
||||
mCanvas.text(text.c_str(), box, canvasStyle);
|
||||
}
|
||||
|
||||
void tp::ui::CanvasPainter::verticalText(
|
||||
const std::string& text, const RectF& box, halnf fontSize, tp::ui::TextDirection direction, const RGBA& color
|
||||
) {
|
||||
const halnf quarterTurn = 1.5707964f;
|
||||
const halnf angle = direction == TextDirection::BottomToTop ? -quarterTurn : quarterTurn;
|
||||
mCanvas.rotatedText(text.c_str(), box.center(), fontSize, angle, color);
|
||||
}
|
||||
|
||||
void tp::ui::CanvasPainter::drawImage(const RectF& rect, tp::ui::ImageId image, halnf cornerRadius) {
|
||||
mCanvas.drawImage(rect, mLoader.handle(image), 0, 1, cornerRadius);
|
||||
}
|
||||
|
||||
void tp::ui::CanvasPainter::colorWheel(const RectF& rect, const HSV& selected, halnf ringThickness) {
|
||||
const Vec2F center = rect.center();
|
||||
const halnf radius = (std::min(rect.z, rect.w) * 0.5f) - 1;
|
||||
const halnf inner = radius - ringThickness;
|
||||
|
||||
for (int i = 0; i < kHueSegments; i++) {
|
||||
const halnf a0 = (halnf) i / kHueSegments * 2 * (halnf) PI;
|
||||
const halnf a1 = (halnf) (i + 1) / kHueSegments * 2 * (halnf) PI;
|
||||
const Canvas::LinearGradient gradient{
|
||||
center, center, RGBA(HSV(a0, 1, 0.55f), 1), RGBA(HSV(a1, 1, 0.55f), 1)
|
||||
};
|
||||
mCanvas.ringSegment(center, inner, radius, a0, a1, gradient);
|
||||
}
|
||||
|
||||
const halnf hue = selected.h;
|
||||
const Vec2F marker = center + Vec2F(std::cos(hue), std::sin(hue)) * (inner + radius) * 0.5f;
|
||||
mCanvas.circle(marker, std::max(ringThickness * 0.4f, 2.f), RGBA(1, 1, 1, 0.75f));
|
||||
}
|
||||
|
||||
void tp::ui::CanvasPainter::fillGradientRect(
|
||||
const RectF& rect, const RGBA& from, const RGBA& to, bool horizontal, halnf cornerRadius
|
||||
) {
|
||||
mCanvas.gradientRect(rect, from, to, horizontal, cornerRadius);
|
||||
}
|
||||
|
||||
halnf tp::ui::CanvasPainter::styledTextWidth(const std::string& text, const tp::ui::TextStyle& style) const {
|
||||
return mCanvas.textWidth(text.c_str(), style.fontSize, canvasFace(style.face));
|
||||
}
|
||||
|
||||
std::vector<halnf> tp::ui::CanvasPainter::prefixWidths(const std::string& text, const tp::ui::TextStyle& style) const {
|
||||
std::vector<halnf> widths(text.size() + 1, 0);
|
||||
mCanvas.prefixWidths(text.c_str(), text.size(), style.fontSize, canvasFace(style.face), widths.data());
|
||||
return widths;
|
||||
}
|
||||
|
||||
bool tp::ui::CanvasPainter::isClippedOut(const RectF& rect) const { return mCanvas.isOutsideClip(rect); }
|
||||
130
Widgets/src/app/private/InputTranslator.cpp
Normal file
130
Widgets/src/app/private/InputTranslator.cpp
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
#include "InputTranslator.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
|
||||
struct ButtonBinding {
|
||||
tp::PointerButton input;
|
||||
tp::ui::PointerButton button;
|
||||
};
|
||||
|
||||
const ButtonBinding kPointerButtons[] = {
|
||||
{ tp::PointerButton::Left, tp::ui::PointerButton::Primary },
|
||||
{ tp::PointerButton::Right, tp::ui::PointerButton::Secondary },
|
||||
{ tp::PointerButton::Middle, tp::ui::PointerButton::Middle },
|
||||
};
|
||||
|
||||
PointerSample sampleOf(const InputState& input) {
|
||||
PointerSample sample = input.pointerSample();
|
||||
sample.pressure = input.pointerPressure();
|
||||
return sample;
|
||||
}
|
||||
|
||||
bool isShift(Key key) { return key == Key::LeftShift || key == Key::RightShift; }
|
||||
bool isControl(Key key) { return key == Key::LeftControl || key == Key::RightControl; }
|
||||
bool isAlt(Key key) { return key == Key::LeftAlt || key == Key::RightAlt; }
|
||||
}
|
||||
|
||||
std::vector<Event> InputTranslator::translate(const InputState& input, const InputRouting& routing) {
|
||||
std::vector<Event> events;
|
||||
translateLog(input, events, routing);
|
||||
translatePointer(input, events);
|
||||
translateButtons(input, events, routing.pointerBlocked);
|
||||
if (!routing.pointerBlocked) translateWheel(input, events);
|
||||
return events;
|
||||
}
|
||||
|
||||
void InputTranslator::translatePointer(const InputState& input, std::vector<Event>& events) {
|
||||
const Vec2F pointer = input.pointer();
|
||||
const PointerSample sample = sampleOf(input);
|
||||
if (mPointerKnown && pointer == mLastPointer && (!sample.isStylus() || sample == mLastSample)) return;
|
||||
mLastSample = sample;
|
||||
|
||||
const Vec2F delta = mPointerKnown ? pointer - mLastPointer : Vec2F{ 0, 0 };
|
||||
events.push_back(
|
||||
Event::pointer(Event::Type::PointerMove, pointer, delta, tp::ui::PointerButton::Primary)
|
||||
.withModifiers(mModifiers)
|
||||
.withSample(sample)
|
||||
);
|
||||
|
||||
mLastPointer = pointer;
|
||||
mPointerKnown = true;
|
||||
}
|
||||
|
||||
void InputTranslator::translateButtons(const InputState& input, std::vector<Event>& events, bool pressesBlocked) {
|
||||
for (const ButtonBinding& binding : kPointerButtons) {
|
||||
const size_t index = (size_t) binding.button;
|
||||
const ButtonState state = input.button(binding.input);
|
||||
const bool pressed = state.pressed && !pressesBlocked;
|
||||
const bool released = state.released && mButtonDelivered[index];
|
||||
if (!pressed && !released) continue;
|
||||
|
||||
mButtonDelivered[index] = pressed;
|
||||
|
||||
const Event::Type type = pressed ? Event::Type::PointerDown : Event::Type::PointerUp;
|
||||
events.push_back(
|
||||
Event::pointer(type, input.pointer(), { 0, 0 }, binding.button)
|
||||
.withModifiers(mModifiers)
|
||||
.withSample(sampleOf(input))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void InputTranslator::translateWheel(const InputState& input, std::vector<Event>& events) {
|
||||
const Vec2F scroll = input.scroll();
|
||||
if (scroll.x == 0 && scroll.y == 0) return;
|
||||
|
||||
events.push_back(Event::wheel(input.pointer(), scroll.y, scroll.x).withModifiers(mModifiers));
|
||||
}
|
||||
|
||||
void InputTranslator::translateLog(const InputState& input, std::vector<Event>& events, const InputRouting& routing) {
|
||||
for (const InputEvent& event : input.events()) {
|
||||
if (const auto* key = std::get_if<KeyChanged>(&event)) {
|
||||
updateModifiers(key->key, key->action != KeyAction::Release);
|
||||
if (key->action == KeyAction::Release) {
|
||||
if (mDeliveredKeys.erase(key->key) == 0) continue;
|
||||
events.push_back(Event::key(Event::Type::KeyUp, key->key).withModifiers(mModifiers));
|
||||
} else if (routing.keyboardBlocked) {
|
||||
continue;
|
||||
} else if (key->action == KeyAction::Press) {
|
||||
mDeliveredKeys.insert(key->key);
|
||||
events.push_back(Event::key(Event::Type::KeyDown, key->key).withModifiers(mModifiers));
|
||||
} else if (mDeliveredKeys.count(key->key)) {
|
||||
events.push_back(Event::keyRepeat(key->key).withModifiers(mModifiers));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (const auto* text = std::get_if<TextEntered>(&event)) {
|
||||
if (mModifiers.control || mModifiers.alt) continue;
|
||||
events.push_back(Event::text(text->utf8).withModifiers(mModifiers));
|
||||
continue;
|
||||
}
|
||||
if (const auto* gesture = std::get_if<GestureChanged>(&event)) {
|
||||
if (routing.pointerBlocked && gesture->gesture.phase != GesturePhase::End) continue;
|
||||
events.push_back(Event::gesture(gesture->gesture).withModifiers(mModifiers));
|
||||
continue;
|
||||
}
|
||||
if (const auto* focus = std::get_if<KeyboardFocusChanged>(&event)) {
|
||||
if (!focus->focused) {
|
||||
mModifiersDown.clear();
|
||||
mModifiers = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void InputTranslator::updateModifiers(Key key, bool pressed) {
|
||||
if (!isShift(key) && !isControl(key) && !isAlt(key)) return;
|
||||
|
||||
if (pressed) mModifiersDown.insert(key);
|
||||
else mModifiersDown.erase(key);
|
||||
|
||||
mModifiers = {};
|
||||
for (Key down : mModifiersDown) {
|
||||
mModifiers.shift |= isShift(down);
|
||||
mModifiers.control |= isControl(down);
|
||||
mModifiers.alt |= isAlt(down);
|
||||
}
|
||||
}
|
||||
100
Widgets/src/app/private/WidgetApplication.cpp
Normal file
100
Widgets/src/app/private/WidgetApplication.cpp
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#include "WidgetApplication.hpp"
|
||||
#include "Profiler.hpp"
|
||||
|
||||
#include "CanvasImageLoader.hpp"
|
||||
#include "CanvasPainter.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
WidgetApplication::WidgetApplication(const WindowIdentity& identity) :
|
||||
mApplication(*this, ApplicationOptions{ .window = { .identity = identity } }) {
|
||||
mImageLoader = std::make_unique<CanvasImageLoader>(mApplication.canvas());
|
||||
mTextMeasurer = std::make_unique<CanvasPainter>(mApplication.canvas(), *mImageLoader);
|
||||
mUi.setTextMetrics(mTextMeasurer.get());
|
||||
mUi.setImageLoader(mImageLoader.get());
|
||||
mUi.setWindowHost(this);
|
||||
}
|
||||
|
||||
WidgetApplication::~WidgetApplication() {
|
||||
clearRoot();
|
||||
mUi.setImageLoader(nullptr);
|
||||
mUi.setWindowHost(nullptr);
|
||||
}
|
||||
|
||||
void WidgetApplication::setRoot(Widget& root) {
|
||||
mFrame.setContent(&root);
|
||||
mUi.setRoot(&mFrame);
|
||||
mUi.setViewport(makeRect(Vec2F{ 0, 0 }, mApplication.window().contentSize()));
|
||||
}
|
||||
|
||||
void WidgetApplication::clearRoot() {
|
||||
mUi.setRoot(nullptr);
|
||||
mFrame.setContent(nullptr);
|
||||
}
|
||||
|
||||
void WidgetApplication::processFrame(const InputState& input, halnf deltaTime) {
|
||||
mUi.resizeViewport(makeRect(Vec2F{ 0, 0 }, mApplication.window().contentSize()));
|
||||
|
||||
const InputRouting routing;
|
||||
{
|
||||
ProfileScope scope(mApplication.profiler(), "ui.dispatch");
|
||||
for (const Event& event : mInputTranslator.translate(input, routing))
|
||||
mUi.dispatch(event);
|
||||
}
|
||||
{
|
||||
ProfileScope scope(mApplication.profiler(), "ui.advance");
|
||||
mUi.advance(deltaTime);
|
||||
}
|
||||
}
|
||||
|
||||
void WidgetApplication::drawFrame(Canvas& canvas) {
|
||||
CanvasPainter painter(canvas, *mImageLoader);
|
||||
{
|
||||
ProfileScope scope(mApplication.profiler(), "ui.paint");
|
||||
mUi.paint(painter);
|
||||
}
|
||||
drawOverlayInterface();
|
||||
}
|
||||
|
||||
bool WidgetApplication::wantsFrame() { return mUi.needsFrame(); }
|
||||
|
||||
PlatformInfo WidgetApplication::platformInfo() const { return mApplication.window().platformInfo(); }
|
||||
|
||||
void WidgetApplication::minimizeWindow() { mApplication.window().controls().minimize(); }
|
||||
|
||||
void WidgetApplication::toggleMaximizeWindow() {
|
||||
if (auto* desktop = mApplication.window().controls().desktop()) desktop->toggleMaximize();
|
||||
}
|
||||
|
||||
void WidgetApplication::closeWindow() { mApplication.window().controls().requestClose(); }
|
||||
|
||||
bool WidgetApplication::isWindowMaximized() const { return mApplication.window().controls().isMaximized(); }
|
||||
|
||||
void WidgetApplication::beginWindowMove() {
|
||||
if (auto* desktop = mApplication.window().controls().desktop()) desktop->beginMove();
|
||||
}
|
||||
|
||||
void WidgetApplication::showWindowMenu(Vec2F windowPoint) {
|
||||
if (auto* desktop = mApplication.window().controls().desktop()) desktop->showWindowMenu(windowPoint);
|
||||
}
|
||||
|
||||
bool WidgetApplication::canMoveWindow() const { return mApplication.window().controls().desktop() != nullptr; }
|
||||
|
||||
bool WidgetApplication::hasWindowControls() const { return mApplication.window().controls().desktop() != nullptr; }
|
||||
|
||||
void WidgetApplication::setCursor(CursorShape shape) { mApplication.window().services().setCursor(shape); }
|
||||
|
||||
void WidgetApplication::setClipboardText(const std::string& text) { mApplication.window().services().setClipboardText(text); }
|
||||
|
||||
std::string WidgetApplication::clipboardText() { return mApplication.window().services().clipboardText(); }
|
||||
|
||||
ClipboardImage WidgetApplication::clipboardImage() { return mApplication.window().services().clipboardImage(); }
|
||||
|
||||
bool WidgetApplication::openUrl(const std::string& url) { return mApplication.window().services().openUrl(url); }
|
||||
|
||||
void WidgetApplication::setTextInputArea(const RectF* windowRect) {
|
||||
mApplication.window().services().setTextInputEnabled(windowRect != nullptr);
|
||||
if (windowRect) mApplication.window().services().setTextInputRect(*windowRect);
|
||||
}
|
||||
50
Widgets/src/app/public/AppDriver.hpp
Normal file
50
Widgets/src/app/public/AppDriver.hpp
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#pragma once
|
||||
|
||||
#include "WidgetApplication.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class AppDriver {
|
||||
public:
|
||||
static constexpr halnf kFrameMs = 16;
|
||||
static constexpr halnf kClickGapMs = 600;
|
||||
static constexpr int kSettleFrameLimit = 120;
|
||||
|
||||
explicit AppDriver(WidgetApplication& application);
|
||||
~AppDriver();
|
||||
|
||||
AppDriver(const AppDriver&) = delete;
|
||||
AppDriver& operator=(const AppDriver&) = delete;
|
||||
|
||||
void frame(halnf deltaMs = kFrameMs);
|
||||
void frames(int count);
|
||||
void advance(halnf milliseconds);
|
||||
|
||||
void moveTo(Vec2F windowPoint);
|
||||
void press(tp::PointerButton button = tp::PointerButton::Left);
|
||||
void release(tp::PointerButton button = tp::PointerButton::Left);
|
||||
void click(Vec2F windowPoint, tp::PointerButton button = tp::PointerButton::Left);
|
||||
void click(const Widget& widget);
|
||||
void drag(Vec2F from, Vec2F to, int steps = 8);
|
||||
void scroll(Vec2F windowPoint, halnf amount);
|
||||
void penStroke(const std::vector<Vec2F>& points, const std::vector<PointerSample>& samples);
|
||||
|
||||
void key(Key key);
|
||||
void keyDown(Key key);
|
||||
void keyUp(Key key);
|
||||
void shortcut(Key modifier, Key key);
|
||||
void type(const std::string& utf8);
|
||||
|
||||
void settle(const Widget& widget);
|
||||
|
||||
[[nodiscard]] static Vec2F centerOf(const Widget& widget);
|
||||
[[nodiscard]] static bool isMoving(const Widget& widget);
|
||||
|
||||
private:
|
||||
WidgetApplication& mApplication;
|
||||
InputSink& mInput;
|
||||
};
|
||||
}
|
||||
30
Widgets/src/app/public/CanvasImageLoader.hpp
Normal file
30
Widgets/src/app/public/CanvasImageLoader.hpp
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
#pragma once
|
||||
|
||||
#include "Canvas.hpp"
|
||||
#include "Painter.hpp"
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class CanvasImageLoader : public ImageLoader {
|
||||
public:
|
||||
explicit CanvasImageLoader(Canvas& canvas);
|
||||
~CanvasImageLoader() override;
|
||||
|
||||
CanvasImageLoader(const CanvasImageLoader&) = delete;
|
||||
CanvasImageLoader& operator=(const CanvasImageLoader&) = delete;
|
||||
|
||||
[[nodiscard]] LoadedImage load(const std::string& path) override;
|
||||
|
||||
// Resolves an ImageId to its Canvas handle; an empty handle means the id is unknown.
|
||||
[[nodiscard]] ImageHandle handle(ImageId image) const;
|
||||
|
||||
private:
|
||||
Canvas& mCanvas;
|
||||
std::map<std::string, LoadedImage> mLoaded;
|
||||
std::map<ualni, ImageHandle> mHandles;
|
||||
ualni mNextId = 1;
|
||||
};
|
||||
}
|
||||
53
Widgets/src/app/public/CanvasPainter.hpp
Normal file
53
Widgets/src/app/public/CanvasPainter.hpp
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#pragma once
|
||||
|
||||
#include "Canvas.hpp"
|
||||
#include "Painter.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class CanvasImageLoader;
|
||||
|
||||
class CanvasPainter : public Painter {
|
||||
public:
|
||||
explicit CanvasPainter(Canvas& canvas, const CanvasImageLoader& loader);
|
||||
|
||||
void pushTranslation(Vec2F offset) override;
|
||||
void popTranslation() override;
|
||||
void pushClip(const RectF& rect) override;
|
||||
void popClip() override;
|
||||
|
||||
void fillCircle(Vec2F center, halnf radius, const RGBA& color) override;
|
||||
void line(Vec2F from, Vec2F to, const RGBA& color, halnf thickness) override;
|
||||
void polyline(const std::vector<Vec2F>& points, const RGBA& color, halnf thickness) override;
|
||||
void fillPolygon(const std::vector<Vec2F>& points, const RGBA& color) override;
|
||||
void styledText(
|
||||
const std::string& text, const RectF& box, const TextStyle& style, TextAlign align, const RGBA& color
|
||||
) override;
|
||||
void verticalText(
|
||||
const std::string& text, const RectF& box, halnf fontSize, TextDirection direction, const RGBA& color
|
||||
) override;
|
||||
void colorWheel(const RectF& rect, const HSV& selected, halnf ringThickness) override;
|
||||
void fillGradientRect(
|
||||
const RectF& rect, const RGBA& from, const RGBA& to, bool horizontal, halnf cornerRadius = 0
|
||||
) override;
|
||||
|
||||
[[nodiscard]] halnf styledTextWidth(const std::string& text, const TextStyle& style) const override;
|
||||
[[nodiscard]] std::vector<halnf> prefixWidths(const std::string& text, const TextStyle& style) const override;
|
||||
[[nodiscard]] bool isClippedOut(const RectF& rect) const override;
|
||||
|
||||
protected:
|
||||
void drawFilledRect(const RectF& rect, const RGBA& color, halnf cornerRadius) override;
|
||||
void drawStrokedRect(const RectF& rect, const RGBA& color, halnf cornerRadius, halnf width) override;
|
||||
void drawStrokedCircle(Vec2F center, halnf radius, const RGBA& color, halnf width) override;
|
||||
void drawImage(const RectF& rect, ImageId image, halnf cornerRadius) override;
|
||||
|
||||
private:
|
||||
Canvas& mCanvas;
|
||||
const CanvasImageLoader& mLoader;
|
||||
// Scoped save/restore (and translation) pairs pushed by the Painter interface; popped in reverse order.
|
||||
std::vector<std::unique_ptr<Canvas::SavedState>> mStates;
|
||||
};
|
||||
}
|
||||
34
Widgets/src/app/public/InputTranslator.hpp
Normal file
34
Widgets/src/app/public/InputTranslator.hpp
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#pragma once
|
||||
|
||||
#include "Event.hpp"
|
||||
#include "InputRouting.hpp"
|
||||
#include "InputState.hpp"
|
||||
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
// Turns one frame's InputState snapshot into the Events the widgets consume.
|
||||
class InputTranslator {
|
||||
public:
|
||||
[[nodiscard]] std::vector<Event> translate(const InputState& input, const InputRouting& routing);
|
||||
|
||||
[[nodiscard]] const Modifiers& modifiers() const { return mModifiers; }
|
||||
|
||||
private:
|
||||
void translatePointer(const InputState& input, std::vector<Event>& events);
|
||||
void translateButtons(const InputState& input, std::vector<Event>& events, bool pressesBlocked);
|
||||
void translateWheel(const InputState& input, std::vector<Event>& events);
|
||||
void translateLog(const InputState& input, std::vector<Event>& events, const InputRouting& routing);
|
||||
void updateModifiers(Key key, bool pressed);
|
||||
|
||||
Vec2F mLastPointer = { -1.0e6f, -1.0e6f };
|
||||
bool mPointerKnown = false;
|
||||
PointerSample mLastSample;
|
||||
bool mButtonDelivered[3] = {};
|
||||
std::set<Key> mDeliveredKeys;
|
||||
std::set<Key> mModifiersDown;
|
||||
Modifiers mModifiers;
|
||||
};
|
||||
}
|
||||
76
Widgets/src/app/public/WidgetApplication.hpp
Normal file
76
Widgets/src/app/public/WidgetApplication.hpp
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#pragma once
|
||||
|
||||
#include "ApplicationFrame.hpp"
|
||||
#include "GraphicApplication.hpp"
|
||||
#include "InputTranslator.hpp"
|
||||
#include "Ui.hpp"
|
||||
#include "WindowHost.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class CanvasPainter;
|
||||
class CanvasImageLoader;
|
||||
|
||||
class WidgetApplication : public ApplicationDelegate, private WindowHost {
|
||||
public:
|
||||
explicit WidgetApplication(const WindowIdentity& identity = WindowIdentity::generic());
|
||||
~WidgetApplication() override;
|
||||
|
||||
void run() { mApplication.run(); }
|
||||
void start() { mApplication.start(); }
|
||||
void step(halnf deltaMs) { mApplication.step(deltaMs); }
|
||||
void finish() { mApplication.finish(); }
|
||||
[[nodiscard]] bool isRunning() const { return mApplication.isRunning(); }
|
||||
|
||||
[[nodiscard]] Application& application() { return mApplication; }
|
||||
[[nodiscard]] Window& window() { return mApplication.window(); }
|
||||
[[nodiscard]] Canvas& canvas() { return mApplication.canvas(); }
|
||||
[[nodiscard]] GpuDevice& gpu() { return mApplication.gpu(); }
|
||||
|
||||
void setRoot(Widget& root);
|
||||
void clearRoot();
|
||||
|
||||
[[nodiscard]] MenuBar& menuBar() { return mFrame.menuBar; }
|
||||
void setMenuBarVisible(bool visible) { mFrame.setMenuBarVisible(visible); }
|
||||
[[nodiscard]] bool isMenuBarVisible() const { return mFrame.isMenuBarVisible(); }
|
||||
[[nodiscard]] ApplicationFrame& frame() { return mFrame; }
|
||||
|
||||
[[nodiscard]] Ui& ui() { return mUi; }
|
||||
[[nodiscard]] AppStorage persistentAppStorage() const override = 0;
|
||||
|
||||
protected:
|
||||
virtual void drawOverlayInterface() {}
|
||||
|
||||
// ApplicationDelegate
|
||||
[[nodiscard]] bool wantsFrame() override;
|
||||
void processFrame(const InputState& input, halnf deltaTime) override;
|
||||
void drawFrame(Canvas& canvas) override;
|
||||
|
||||
private:
|
||||
// WindowHost
|
||||
[[nodiscard]] PlatformInfo platformInfo() const override;
|
||||
void minimizeWindow() override;
|
||||
void toggleMaximizeWindow() override;
|
||||
void closeWindow() override;
|
||||
[[nodiscard]] bool isWindowMaximized() const override;
|
||||
void beginWindowMove() override;
|
||||
void showWindowMenu(Vec2F windowPoint) override;
|
||||
[[nodiscard]] bool canMoveWindow() const override;
|
||||
[[nodiscard]] bool hasWindowControls() const override;
|
||||
void setCursor(CursorShape shape) override;
|
||||
void setClipboardText(const std::string& text) override;
|
||||
[[nodiscard]] std::string clipboardText() override;
|
||||
[[nodiscard]] ClipboardImage clipboardImage() override;
|
||||
bool openUrl(const std::string& url) override;
|
||||
void setTextInputArea(const RectF* windowRect) override;
|
||||
|
||||
mutable Application mApplication;
|
||||
std::unique_ptr<CanvasPainter> mTextMeasurer;
|
||||
std::unique_ptr<CanvasImageLoader> mImageLoader;
|
||||
Ui mUi;
|
||||
ApplicationFrame mFrame;
|
||||
InputTranslator mInputTranslator;
|
||||
};
|
||||
}
|
||||
28
Widgets/src/components/chrome/private/ApplicationFrame.cpp
Normal file
28
Widgets/src/components/chrome/private/ApplicationFrame.cpp
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#include "ApplicationFrame.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
ApplicationFrame::ApplicationFrame() :
|
||||
Panel(Surface::Window) {
|
||||
setDebugName("application frame");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
setCornerRadius(0);
|
||||
emplaceLayout<ColumnLayout>();
|
||||
addChild(menuBar);
|
||||
}
|
||||
|
||||
void ApplicationFrame::setContent(Widget* content) {
|
||||
if (content == mContent) return;
|
||||
if (mContent) removeChild(*mContent);
|
||||
|
||||
mContent = content;
|
||||
if (!mContent) return;
|
||||
|
||||
mContent->setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
addChild(*mContent);
|
||||
}
|
||||
|
||||
void ApplicationFrame::setMenuBarVisible(bool visible) { menuBar.setVisible(visible); }
|
||||
141
Widgets/src/components/chrome/private/Dialog.cpp
Normal file
141
Widgets/src/components/chrome/private/Dialog.cpp
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
#include "Dialog.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kDialogWidth = 360;
|
||||
}
|
||||
|
||||
Dialog::Dialog(std::string title) :
|
||||
Panel(Surface::Raised),
|
||||
mTitle(std::move(title)) {
|
||||
setDebugName("dialog");
|
||||
setBorder(true);
|
||||
setSizing(AxisSizing::fixed(kDialogWidth), AxisSizing::fit());
|
||||
emplaceLayout<ColumnLayout>(10).setPadding(Insets::uniform(16));
|
||||
|
||||
mTitle.setFontSize(Theme::standard().metrics.fontSize + 2);
|
||||
mTitle.setWrap(true);
|
||||
mTitle.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mMessage.setWrap(true);
|
||||
mMessage.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mMessage.setTone(Label::Tone::Muted);
|
||||
mMessage.setVisible(false);
|
||||
|
||||
mBody.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mBody.emplaceLayout<ColumnLayout>(8);
|
||||
|
||||
mButtonRow.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mButtonRow.emplaceLayout<RowLayout>(6);
|
||||
mButtonSpacer.setSizing(AxisSizing::fill(), AxisSizing::fixed(1));
|
||||
mButtonRow.addChild(mButtonSpacer);
|
||||
|
||||
addChild(mTitle);
|
||||
addChild(mMessage);
|
||||
addChild(mBody);
|
||||
addChild(mButtonRow);
|
||||
}
|
||||
|
||||
Dialog::~Dialog() { close(); }
|
||||
|
||||
void Dialog::setTitle(std::string title) { mTitle.setText(std::move(title)); }
|
||||
|
||||
void Dialog::setMessage(std::string message) {
|
||||
mMessage.setVisible(!message.empty());
|
||||
mMessage.setText(std::move(message));
|
||||
}
|
||||
|
||||
Button& Dialog::addButton(std::string text, std::function<void()> action, bool isDefault) {
|
||||
auto button = std::make_unique<Button>(std::move(text));
|
||||
button->setOnActivate([this, action]() {
|
||||
close();
|
||||
if (action) action();
|
||||
});
|
||||
if (isDefault) {
|
||||
button->setStyle(Button::Style::Accent);
|
||||
mDefault = button.get();
|
||||
}
|
||||
|
||||
mButtonRow.addChild(*button);
|
||||
mButtons.push_back(std::move(button));
|
||||
return *mButtons.back();
|
||||
}
|
||||
|
||||
Button& Dialog::addCancelButton(std::string text) {
|
||||
Button& button = addButton(std::move(text), {});
|
||||
button.setOnActivate([this]() { cancel(); });
|
||||
return button;
|
||||
}
|
||||
|
||||
void Dialog::open(Ui& ui, Widget* owner) {
|
||||
const RectF viewport = ui.viewport();
|
||||
|
||||
PopupRequest request;
|
||||
request.content = this;
|
||||
request.owner = owner;
|
||||
request.anchor = makeRect(viewport.pos.x, viewport.pos.y + viewport.size.y / 4, viewport.size.x, 0);
|
||||
request.options.placement = Placement::Over;
|
||||
request.options.align = PlacementAlign::Center;
|
||||
request.options.modal = true;
|
||||
request.options.closeOnOutsidePress = false;
|
||||
request.options.onClosed = [this]() {
|
||||
if (mOnCancel) mOnCancel();
|
||||
};
|
||||
ui.openPopup(request);
|
||||
if (mDefault) ui.setKeyboardFocus(mDefault);
|
||||
}
|
||||
|
||||
void Dialog::close() {
|
||||
if (!ui() || !ui()->isPopupOpen(*this)) return;
|
||||
|
||||
const std::function<void()> onCancel = std::move(mOnCancel);
|
||||
ui()->closePopup(*this);
|
||||
mOnCancel = onCancel;
|
||||
}
|
||||
|
||||
bool Dialog::isOpen() const { return ui() && ui()->isPopupOpen(*this); }
|
||||
|
||||
void Dialog::accept() {
|
||||
if (mDefault) mDefault->activate();
|
||||
}
|
||||
|
||||
void Dialog::cancel() {
|
||||
if (!isOpen()) return;
|
||||
ui()->closePopup(*this);
|
||||
}
|
||||
|
||||
void Dialog::onEvent(Event& event) {
|
||||
if (event.type() != Event::Type::KeyDown) return;
|
||||
|
||||
if (event.keyCode() == Key::Enter || event.keyCode() == Key::KeypadEnter) {
|
||||
accept();
|
||||
event.accept();
|
||||
}
|
||||
}
|
||||
|
||||
ConfirmDialog::ConfirmDialog() {
|
||||
setDebugName("confirm dialog");
|
||||
mConfirm = &addButton(
|
||||
{},
|
||||
[this]() {
|
||||
if (mOnConfirm) mOnConfirm();
|
||||
},
|
||||
true
|
||||
);
|
||||
addCancelButton();
|
||||
}
|
||||
|
||||
void ConfirmDialog::ask(
|
||||
Ui& ui, std::string title, std::string message, std::string confirmText, std::function<void()> onConfirm
|
||||
) {
|
||||
setTitle(std::move(title));
|
||||
setMessage(std::move(message));
|
||||
mConfirm->setText(std::move(confirmText));
|
||||
mOnConfirm = std::move(onConfirm);
|
||||
open(ui);
|
||||
}
|
||||
686
Widgets/src/components/chrome/private/Menus.cpp
Normal file
686
Widgets/src/components/chrome/private/Menus.cpp
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
#include "Menus.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
#include "WindowHost.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kSubmenuArrowWidth = 16;
|
||||
constexpr halnf kWindowDragThreshold = 4;
|
||||
|
||||
PopupOptions menuPopupOptions(Placement placement, halnf gap) {
|
||||
PopupOptions options;
|
||||
options.placement = placement;
|
||||
options.align = PlacementAlign::Start;
|
||||
options.gap = gap;
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
PopupButton::PopupButton(std::string text) :
|
||||
Button(std::move(text)) {
|
||||
setDebugName("popup button");
|
||||
}
|
||||
|
||||
void PopupButton::setPopupContent(Widget* content) {
|
||||
if (mPopupContent && isPopupOpen()) closePopup();
|
||||
mPopupContent = content;
|
||||
}
|
||||
|
||||
void PopupButton::openPopup() {
|
||||
if (!mPopupContent || !ui()) return;
|
||||
if (mOnOpening) mOnOpening();
|
||||
|
||||
PopupRequest request;
|
||||
request.content = mPopupContent;
|
||||
request.owner = this;
|
||||
request.anchor = windowRect();
|
||||
request.options = mOptions;
|
||||
|
||||
ui()->openPopup(request);
|
||||
refreshVisualState();
|
||||
}
|
||||
|
||||
void PopupButton::openPopupFromHover() {
|
||||
openPopup();
|
||||
mOpenedByHover = isPopupOpen();
|
||||
if (mOpenedByHover && ui()) mHoverOpenPoint = ui()->pointerPosition();
|
||||
}
|
||||
|
||||
void PopupButton::closePopup() {
|
||||
mOpenedByHover = false;
|
||||
mKeepOpenOnActivation = false;
|
||||
if (mPopupContent && ui()) ui()->closePopup(*mPopupContent);
|
||||
refreshVisualState();
|
||||
}
|
||||
|
||||
bool PopupButton::isPopupOpen() const { return mPopupContent && ui() && ui()->isPopupOpen(*mPopupContent); }
|
||||
|
||||
void PopupButton::onEvent(Event& event) {
|
||||
if (event.type() == Event::Type::PointerDown && event.button() == PointerButton::Primary) {
|
||||
mKeepOpenOnActivation = mOpenedByHover && isPopupOpen() && event.windowPosition() == mHoverOpenPoint;
|
||||
mOpenedByHover = false;
|
||||
}
|
||||
Button::onEvent(event);
|
||||
}
|
||||
|
||||
void PopupButton::onActivated() {
|
||||
if (mKeepOpenOnActivation) {
|
||||
mKeepOpenOnActivation = false;
|
||||
if (isPopupOpen()) return;
|
||||
}
|
||||
if (isPopupOpen()) closePopup();
|
||||
else openPopup();
|
||||
}
|
||||
|
||||
void PopupButton::onHoverChanged() {
|
||||
Button::onHoverChanged();
|
||||
if (!isHovered()) {
|
||||
mOpenedByHover = false;
|
||||
mKeepOpenOnActivation = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mOpenOnHover && !isPopupOpen()) openPopupFromHover();
|
||||
if (mOnHovered) mOnHovered();
|
||||
}
|
||||
|
||||
RGBA PopupButton::backgroundTarget() const {
|
||||
if (isPopupOpen()) return theme().palette.controlPressed;
|
||||
return Button::backgroundTarget();
|
||||
}
|
||||
|
||||
MenuEntry MenuEntry::item(std::string text, std::function<void()> action, std::string shortcut) {
|
||||
MenuEntry entry;
|
||||
entry.kind = Kind::Action;
|
||||
entry.text = std::move(text);
|
||||
entry.action = std::move(action);
|
||||
entry.shortcut = std::move(shortcut);
|
||||
return entry;
|
||||
}
|
||||
|
||||
MenuEntry MenuEntry::toggle(std::string text, std::function<bool()> isChecked, std::function<void()> action) {
|
||||
MenuEntry entry;
|
||||
entry.kind = Kind::Toggle;
|
||||
entry.text = std::move(text);
|
||||
entry.isChecked = std::move(isChecked);
|
||||
entry.action = std::move(action);
|
||||
return entry;
|
||||
}
|
||||
|
||||
MenuEntry MenuEntry::separator() {
|
||||
MenuEntry entry;
|
||||
entry.kind = Kind::Separator;
|
||||
return entry;
|
||||
}
|
||||
|
||||
MenuEntry MenuEntry::nested(std::string text, Menu& menu) {
|
||||
MenuEntry entry;
|
||||
entry.kind = Kind::Submenu;
|
||||
entry.text = std::move(text);
|
||||
entry.submenu = &menu;
|
||||
return entry;
|
||||
}
|
||||
|
||||
MenuItem::MenuItem(Menu& menu, MenuEntry entry) :
|
||||
mMenu(menu),
|
||||
mEntry(std::move(entry)) {
|
||||
setDebugName("menu item: " + mEntry.text);
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
refresh();
|
||||
}
|
||||
|
||||
void MenuItem::refresh() {
|
||||
if (mEntry.isEnabled) setEnabled(mEntry.isEnabled());
|
||||
|
||||
std::string text = mEntry.dynamicText ? mEntry.dynamicText() : mEntry.text;
|
||||
if (text != mText) {
|
||||
mText = std::move(text);
|
||||
requestLayout();
|
||||
}
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
Extent MenuItem::measureContent(Axis axis, halnf) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
|
||||
if (axis == Axis::Vertical) return Extent::rigid(metrics.menuItemHeight);
|
||||
|
||||
halnf width = metrics.controlPadding.total(Axis::Horizontal) + metrics.indicatorSize + metrics.spacing;
|
||||
width += textWidth(mText, metrics.fontSize);
|
||||
if (!mEntry.shortcut.empty()) width += metrics.spacing * 4 + textWidth(mEntry.shortcut, metrics.smallFontSize);
|
||||
if (mEntry.kind == MenuEntry::Kind::Submenu) width += kSubmenuArrowWidth;
|
||||
return Extent::natural(width);
|
||||
}
|
||||
|
||||
void MenuItem::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const RectF content = style.metrics.controlPadding.shrink(localRect());
|
||||
RGBA textColor = isEffectivelyEnabled() ? style.palette.text : style.palette.disabledText;
|
||||
if (mEntry.muted && isEffectivelyEnabled()) textColor = style.palette.mutedText;
|
||||
|
||||
painter.fillRect(localRect(), background(), style.metrics.cornerRadius);
|
||||
|
||||
const bool checked = mEntry.isChecked && mEntry.isChecked();
|
||||
if (mEntry.icon.isValid() && !checked) {
|
||||
const halnf side = style.metrics.indicatorSize * 1.25f;
|
||||
const RectF slot = makeRect(content.pos.x - side * 0.1f, (size().y - side) / 2, side, side);
|
||||
painter.image(slot, mEntry.icon);
|
||||
}
|
||||
if (checked) {
|
||||
const halnf unit = style.metrics.indicatorSize;
|
||||
const Vec2F origin = { content.pos.x, (size().y - unit) / 2 };
|
||||
painter.polyline(
|
||||
{ origin + Vec2F(unit * 0.2f, unit * 0.52f),
|
||||
origin + Vec2F(unit * 0.42f, unit * 0.74f),
|
||||
origin + Vec2F(unit * 0.82f, unit * 0.28f) },
|
||||
textColor,
|
||||
1.5f
|
||||
);
|
||||
}
|
||||
|
||||
const halnf textLeft = content.pos.x + style.metrics.indicatorSize + style.metrics.spacing;
|
||||
const RectF textBox = makeRect(textLeft, 0, size().x - textLeft, size().y);
|
||||
painter.text(mText, textBox, style.metrics.fontSize, TextAlign::Start, textColor);
|
||||
|
||||
if (!mEntry.shortcut.empty()) {
|
||||
const halnf right = mEntry.kind == MenuEntry::Kind::Submenu ? kSubmenuArrowWidth : 0;
|
||||
const RectF shortcutBox = makeRect(content.pos.x, 0, content.size.x - right, size().y);
|
||||
painter.text(mEntry.shortcut, shortcutBox, style.metrics.smallFontSize, TextAlign::End, style.palette.mutedText);
|
||||
}
|
||||
|
||||
if (mEntry.kind == MenuEntry::Kind::Submenu) {
|
||||
const Vec2F tip = { size().x - 10, size().y / 2 };
|
||||
painter.polyline({ tip + Vec2F(-3.0f, -4.0f), tip, tip + Vec2F(-3.0f, 4.0f) }, style.palette.mutedText, 1.5f);
|
||||
}
|
||||
}
|
||||
|
||||
void MenuItem::onActivated() {
|
||||
if (mEntry.kind == MenuEntry::Kind::Submenu) {
|
||||
openSubmenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mEntry.action) mEntry.action();
|
||||
mMenu.closeChain();
|
||||
if (mMenu.mOnItemActivated) mMenu.mOnItemActivated();
|
||||
}
|
||||
|
||||
void MenuItem::onHoverChanged() {
|
||||
Control::onHoverChanged();
|
||||
if (!isHovered()) return;
|
||||
|
||||
if (mEntry.kind == MenuEntry::Kind::Submenu) openSubmenu();
|
||||
else if (ui()) {
|
||||
for (const auto& sibling : mMenu.items()) {
|
||||
const MenuEntry& siblingEntry = sibling->entry();
|
||||
if (siblingEntry.kind == MenuEntry::Kind::Submenu && siblingEntry.submenu->isOpen()) {
|
||||
siblingEntry.submenu->closeMenu();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RGBA MenuItem::backgroundTarget() const {
|
||||
const Palette& palette = theme().palette;
|
||||
const bool submenuOpen = mEntry.kind == MenuEntry::Kind::Submenu && mEntry.submenu->isOpen();
|
||||
|
||||
if (isEffectivelyEnabled() && (isHovered() || submenuOpen)) return palette.menuHighlight;
|
||||
return { palette.menuHighlight.r, palette.menuHighlight.g, palette.menuHighlight.b, 0 };
|
||||
}
|
||||
|
||||
Placement MenuItem::submenuSide(const RectF& menuRect) const {
|
||||
const RectF* owner = mMenu.ui() ? mMenu.ui()->popupAnchorOf(mMenu) : nullptr;
|
||||
if (!owner) return Placement::Right;
|
||||
const bool openedLeftOfOwner = menuRect.pos.x + menuRect.size.x <= owner->pos.x + 1;
|
||||
return openedLeftOfOwner ? Placement::Left : Placement::Right;
|
||||
}
|
||||
|
||||
void MenuItem::openSubmenu() {
|
||||
if (!mEntry.submenu || mEntry.submenu->isOpen()) return;
|
||||
|
||||
const RectF menuRect = mMenu.windowRect();
|
||||
const RectF itemRect = windowRect();
|
||||
const RectF anchor = makeRect(menuRect.pos.x, itemRect.pos.y, menuRect.size.x, itemRect.size.y);
|
||||
|
||||
mEntry.submenu->openAt(*this, anchor, submenuSide(menuRect));
|
||||
refreshVisualState();
|
||||
}
|
||||
|
||||
Menu::Menu() :
|
||||
Panel(Surface::Raised) {
|
||||
setDebugName("menu");
|
||||
setBorder(true);
|
||||
setCornerRadius(Theme::standard().metrics.popupCornerRadius);
|
||||
emplaceLayout<ColumnLayout>(0);
|
||||
|
||||
mColumn.setDebugName("menu items");
|
||||
mColumn.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mColumn.emplaceLayout<ColumnLayout>(0).setPadding(Theme::standard().metrics.menuPadding);
|
||||
mScroll.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mScroll.setContent(&mColumn);
|
||||
addChild(mScroll);
|
||||
}
|
||||
|
||||
Menu::~Menu() {
|
||||
if (isOpen()) closeMenu();
|
||||
mColumn.removeAllChildren();
|
||||
mScroll.setContent(nullptr);
|
||||
removeAllChildren();
|
||||
}
|
||||
|
||||
MenuItem& Menu::add(MenuEntry entry) {
|
||||
mItems.push_back(std::make_unique<MenuItem>(*this, std::move(entry)));
|
||||
mColumn.addChild(*mItems.back());
|
||||
return *mItems.back();
|
||||
}
|
||||
|
||||
MenuItem& Menu::addItem(std::string text, std::function<void()> action, std::string shortcut) {
|
||||
return add(MenuEntry::item(std::move(text), std::move(action), std::move(shortcut)));
|
||||
}
|
||||
|
||||
MenuItem& Menu::addToggle(std::string text, std::function<bool()> isChecked, std::function<void()> action) {
|
||||
return add(MenuEntry::toggle(std::move(text), std::move(isChecked), std::move(action)));
|
||||
}
|
||||
|
||||
void Menu::addSeparator() {
|
||||
mSeparators.push_back(std::make_unique<Separator>());
|
||||
mColumn.addChild(*mSeparators.back());
|
||||
}
|
||||
|
||||
MenuItem& Menu::addSubmenu(std::string text, Menu& submenu) { return add(MenuEntry::nested(std::move(text), submenu)); }
|
||||
|
||||
void Menu::clear() {
|
||||
mColumn.removeAllChildren();
|
||||
mItems.clear();
|
||||
mSeparators.clear();
|
||||
}
|
||||
|
||||
std::vector<const MenuItem*> Menu::itemsWithSeparators() const {
|
||||
std::vector<const MenuItem*> order;
|
||||
for (Widget* child : mColumn.children()) {
|
||||
const auto* item = dynamic_cast<const MenuItem*>(child);
|
||||
if (item) order.push_back(item);
|
||||
else if (!order.empty() && order.back()) order.push_back(nullptr);
|
||||
}
|
||||
while (!order.empty() && !order.back())
|
||||
order.pop_back();
|
||||
return order;
|
||||
}
|
||||
|
||||
void Menu::refresh() {
|
||||
if (mOnOpen) mOnOpen(*this);
|
||||
for (const auto& item : mItems)
|
||||
item->refresh();
|
||||
}
|
||||
|
||||
void Menu::openAt(Widget& owner, const RectF& anchor, Placement placement) {
|
||||
if (!owner.ui()) return;
|
||||
refresh();
|
||||
|
||||
PopupRequest request;
|
||||
request.content = this;
|
||||
request.owner = &owner;
|
||||
request.anchor = anchor;
|
||||
request.options = menuPopupOptions(placement, owner.theme().metrics.popupGap);
|
||||
owner.ui()->openPopup(request);
|
||||
}
|
||||
|
||||
void Menu::closeMenu() {
|
||||
if (ui()) ui()->closePopup(*this);
|
||||
}
|
||||
|
||||
bool Menu::isOpen() const { return ui() && ui()->isPopupOpen(*this); }
|
||||
|
||||
void Menu::closeChain() {
|
||||
if (ui()) ui()->closePopupChain(*this);
|
||||
}
|
||||
|
||||
namespace {
|
||||
constexpr halnf kCenterMargin = 3;
|
||||
}
|
||||
|
||||
class MenuBar::MenuBarLayout : public RowLayout {
|
||||
public:
|
||||
explicit MenuBarLayout(MenuBar& bar) :
|
||||
RowLayout(2),
|
||||
mBar(bar) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
if (!mBar.mCenter) return RowLayout::measureChildren(context, axis, crossLength);
|
||||
|
||||
const Extent row = RowLayout::measureChildren(context.without(*mBar.mCenter), axis, crossLength);
|
||||
Extent center = context.measure(*mBar.mCenter, axis, crossLength);
|
||||
if (axis == Axis::Vertical) {
|
||||
center.minimum += kCenterMargin * 2;
|
||||
center.preferred += kCenterMargin * 2;
|
||||
}
|
||||
if (axis == Axis::Vertical)
|
||||
return {
|
||||
std::max(row.minimum, center.minimum), std::max(row.preferred, center.preferred), row.maximum, row.grow
|
||||
};
|
||||
return { row.minimum + center.minimum, row.preferred + center.preferred, row.maximum, row.grow };
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& contentArea) override {
|
||||
mBar.showTitles(fittingTitles(context, contentArea));
|
||||
if (!mBar.mCenter) {
|
||||
RowLayout::arrangeChildren(context, contentArea);
|
||||
return;
|
||||
}
|
||||
LayoutContext row = context.without(*mBar.mCenter);
|
||||
RowLayout::arrangeChildren(row, contentArea);
|
||||
placeCenter(context, contentArea);
|
||||
}
|
||||
|
||||
void placeCenter(LayoutContext& context, const RectF& area) const {
|
||||
Widget& center = *mBar.mCenter;
|
||||
const RectF space = mBar.mDragSpace.rect();
|
||||
const halnf height =
|
||||
std::min(area.size.y - kCenterMargin * 2, context.measure(center, Axis::Vertical, space.size.x).preferred);
|
||||
const halnf width = std::min(space.size.x, context.measure(center, Axis::Horizontal, height).preferred);
|
||||
const halnf barCenter = area.pos.x + area.size.x / 2;
|
||||
const halnf left = std::clamp(barCenter - width / 2, space.pos.x, space.pos.x + space.size.x - width);
|
||||
context.place(center, makeRect(left, area.pos.y + (area.size.y - height) / 2, width, height));
|
||||
}
|
||||
|
||||
private:
|
||||
[[nodiscard]] halnf widthOf(const LayoutContext& context, const Widget& widget, halnf height) const {
|
||||
return context.measure(widget, Axis::Horizontal, height).preferred;
|
||||
}
|
||||
|
||||
[[nodiscard]] size_t fittingTitles(const LayoutContext& context, const RectF& area) const {
|
||||
const halnf height = area.size.y;
|
||||
halnf controls = mBar.mWindowControls.isVisible() ? widthOf(context, mBar.mWindowControls, height) : 0;
|
||||
if (mBar.mTrailing && mBar.mTrailing->isVisible()) controls += widthOf(context, *mBar.mTrailing, height) + gap();
|
||||
halnf total = controls + centerWidth(context, height);
|
||||
for (const auto& title : mBar.mTitles)
|
||||
total += widthOf(context, *title, height) + gap();
|
||||
if (total <= area.size.x) return mBar.mTitles.size();
|
||||
|
||||
halnf used = controls + centerWidth(context, height) + widthOf(context, mBar.mOverflow, height) + gap();
|
||||
size_t count = 0;
|
||||
for (const auto& title : mBar.mTitles) {
|
||||
used += widthOf(context, *title, height) + gap();
|
||||
if (used > area.size.x) break;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
[[nodiscard]] halnf centerWidth(const LayoutContext& context, halnf height) const {
|
||||
if (!mBar.mCenter) return 0;
|
||||
return context.measure(*mBar.mCenter, Axis::Horizontal, height).minimum + gap();
|
||||
}
|
||||
|
||||
MenuBar& mBar;
|
||||
};
|
||||
|
||||
MenuBar::MenuBar() :
|
||||
Panel(Surface::None) {
|
||||
setDebugName("menu bar");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
emplaceLayout<MenuBarLayout>(*this).setPadding(Insets::symmetric(4, 2));
|
||||
|
||||
mDragSpace.setDebugName("menu bar drag space");
|
||||
mDragSpace.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mOverflow.setDebugName("menu bar overflow");
|
||||
mOverflow.setStyle(Button::Style::Flat);
|
||||
mOverflow.setPopupContent(&mOverflowMenu);
|
||||
mOverflow.setPopupOptions(menuPopupOptions(Placement::Below, Theme::standard().metrics.popupGap));
|
||||
mOverflow.setOnOpening([this]() { mOverflowMenu.refresh(); });
|
||||
mOverflow.setVisible(false);
|
||||
mOverflowMenu.setOnItemActivated([this]() { mOverflow.closePopup(); });
|
||||
addChild(mOverflow);
|
||||
addChild(mDragSpace);
|
||||
addChild(mWindowControls);
|
||||
}
|
||||
|
||||
void MenuBar::showTitles(size_t count) {
|
||||
if (count == mShownTitles && mOverflow.isVisible() == (count < mTitles.size())) return;
|
||||
mShownTitles = count;
|
||||
for (size_t index = 0; index < mTitles.size(); index++)
|
||||
mTitles[index]->setVisible(index < count);
|
||||
mOverflow.setVisible(count < mTitles.size());
|
||||
mOverflowMenu.clear();
|
||||
for (size_t index = count; index < mTitles.size(); index++)
|
||||
mOverflowMenu.addSubmenu(mTitles[index]->text(), *mMenus[index]);
|
||||
}
|
||||
|
||||
void MenuBar::onEvent(Event& event) {
|
||||
WindowHost* host = ui() ? ui()->windowHost() : nullptr;
|
||||
if (!host || !host->canMoveWindow()) return;
|
||||
|
||||
if (mMovePending) {
|
||||
handlePendingMove(event, *host);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type() == Event::Type::PointerDown && isDragSurface(event.target())) handleDragSurfacePress(event, *host);
|
||||
}
|
||||
|
||||
void MenuBar::handleDragSurfacePress(Event& event, WindowHost& host) {
|
||||
if (event.button() == PointerButton::Secondary) {
|
||||
host.showWindowMenu(event.windowPosition());
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button() != PointerButton::Primary) return;
|
||||
event.accept();
|
||||
|
||||
if (event.clickCount() == 2) {
|
||||
host.toggleMaximizeWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
mMovePending = true;
|
||||
mMovePressPoint = event.windowPosition();
|
||||
ui()->capturePointer(*this);
|
||||
}
|
||||
|
||||
void MenuBar::handlePendingMove(Event& event, WindowHost& host) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerMove:
|
||||
{
|
||||
const Vec2F offset = event.windowPosition() - mMovePressPoint;
|
||||
if (offset.x * offset.x + offset.y * offset.y < kWindowDragThreshold * kWindowDragThreshold) return;
|
||||
|
||||
cancelPendingMove();
|
||||
host.beginWindowMove();
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
cancelPendingMove();
|
||||
event.accept();
|
||||
return;
|
||||
|
||||
default: return;
|
||||
}
|
||||
}
|
||||
|
||||
void MenuBar::cancelPendingMove() {
|
||||
mMovePending = false;
|
||||
if (ui()) ui()->releasePointer(*this);
|
||||
}
|
||||
|
||||
MenuIconButton::MenuIconButton() {
|
||||
setDebugName("menu icon button");
|
||||
setStyle(Style::Flat);
|
||||
setIconSize(16);
|
||||
}
|
||||
|
||||
void MenuIconButton::paintIcon(Painter& painter, const RectF& box) {
|
||||
const RGBA color = isHovered() ? theme().palette.text : theme().palette.mutedText;
|
||||
const halnf thickness = 1.6f;
|
||||
const halnf width = box.size.x * 0.8f;
|
||||
const halnf left = box.pos.x + (box.size.x - width) / 2;
|
||||
const halnf spacing = box.size.y * 0.3f;
|
||||
const halnf middle = box.pos.y + box.size.y / 2;
|
||||
for (int row = -1; row <= 1; row++) {
|
||||
const halnf y = middle + halnf(row) * spacing;
|
||||
painter.line(Vec2F(left, y), Vec2F(left + width, y), color, thickness);
|
||||
}
|
||||
}
|
||||
|
||||
bool MenuBar::isDragSurface(const Widget* target) const { return target == this || target == &mDragSpace; }
|
||||
|
||||
void MenuBar::setCenterWidget(Widget* widget) {
|
||||
if (mCenter) removeChild(*mCenter);
|
||||
mCenter = widget;
|
||||
if (widget) addChild(*widget);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
PopupButton& MenuBar::addMenu(std::string title, Menu& menu) {
|
||||
return insertMenu(mTitles.size() - mTrailingMenus, std::move(title), menu);
|
||||
}
|
||||
|
||||
PopupButton& MenuBar::addTrailingMenu(std::string title, Menu& menu) {
|
||||
PopupButton& button = insertMenu(mTitles.size(), std::move(title), menu);
|
||||
mTrailingMenus++;
|
||||
return button;
|
||||
}
|
||||
|
||||
void MenuBar::removeMenu(Menu& menu) {
|
||||
const auto found = std::find(mMenus.begin(), mMenus.end(), &menu);
|
||||
if (found == mMenus.end()) return;
|
||||
|
||||
const size_t index = (size_t) (found - mMenus.begin());
|
||||
if (index >= mTitles.size() - mTrailingMenus) mTrailingMenus--;
|
||||
menu.setOnItemActivated({});
|
||||
mTitles[index]->closePopup();
|
||||
removeChild(*mTitles[index]);
|
||||
mTitles.erase(mTitles.begin() + (std::ptrdiff_t) index);
|
||||
mMenus.erase(found);
|
||||
mShownTitles = mTitles.size();
|
||||
mOverflowMenu.clear();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void MenuBar::setTrailingWidget(Widget* widget) {
|
||||
if (widget == mTrailing) return;
|
||||
if (mTrailing) removeChild(*mTrailing);
|
||||
mTrailing = widget;
|
||||
if (widget) insertChild(children().size() - 1, *widget);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
PopupButton& MenuBar::insertMenu(size_t index, std::string title, Menu& menu) {
|
||||
mTitles.insert(mTitles.begin() + (std::ptrdiff_t) index, std::make_unique<PopupButton>(std::move(title)));
|
||||
PopupButton& button = *mTitles[index];
|
||||
|
||||
button.setStyle(Button::Style::Flat);
|
||||
button.setPopupContent(&menu);
|
||||
button.setPopupOptions(menuPopupOptions(Placement::Below, Theme::standard().metrics.popupGap));
|
||||
button.setOnHovered([this, &button]() { onTitleHovered(button); });
|
||||
button.setOnOpening([&menu]() { menu.refresh(); });
|
||||
menu.setOnItemActivated([this, &button]() {
|
||||
button.closePopup();
|
||||
mOverflow.closePopup();
|
||||
});
|
||||
|
||||
mMenus.insert(mMenus.begin() + (std::ptrdiff_t) index, &menu);
|
||||
insertChild(index, button);
|
||||
mShownTitles = mTitles.size();
|
||||
return button;
|
||||
}
|
||||
|
||||
void MenuBar::onTitleHovered(PopupButton& title) {
|
||||
for (const auto& other : mTitles) {
|
||||
if (other.get() == &title || !other->isPopupOpen()) continue;
|
||||
|
||||
other->closePopup();
|
||||
title.openPopupFromHover();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Dropdown::Dropdown(std::vector<std::string> options) {
|
||||
setDebugName("dropdown");
|
||||
setPopupContent(&mMenu);
|
||||
setPopupOptions(menuPopupOptions(Placement::Below, Theme::standard().metrics.popupGap));
|
||||
setOptions(std::move(options));
|
||||
}
|
||||
|
||||
void Dropdown::setOptions(std::vector<std::string> options) {
|
||||
mOptions = std::move(options);
|
||||
if (mSelected >= (long) mOptions.size()) mSelected = -1;
|
||||
if (mSelected < 0 && !mOptions.empty()) mSelected = 0;
|
||||
|
||||
rebuildMenu();
|
||||
setText(selectedText());
|
||||
}
|
||||
|
||||
void Dropdown::setSelectedIndex(long index) {
|
||||
if (index < -1 || index >= (long) mOptions.size() || index == mSelected) return;
|
||||
|
||||
mSelected = index;
|
||||
setText(selectedText());
|
||||
if (mOnSelected) mOnSelected(mSelected);
|
||||
}
|
||||
|
||||
std::string Dropdown::selectedText() const { return mSelected >= 0 ? mOptions[(size_t) mSelected] : std::string(); }
|
||||
|
||||
Extent Dropdown::measureContent(Axis axis, halnf crossLength) const {
|
||||
Extent extent = Button::measureContent(axis, crossLength);
|
||||
if (axis != Axis::Horizontal) return extent;
|
||||
|
||||
halnf widest = 0;
|
||||
for (const std::string& option : mOptions) {
|
||||
const halnf width = textWidth(option, theme().metrics.fontSize);
|
||||
if (width > widest) widest = width;
|
||||
}
|
||||
|
||||
extent.preferred = widest + theme().metrics.controlPadding.total(Axis::Horizontal) + kSubmenuArrowWidth;
|
||||
return extent;
|
||||
}
|
||||
|
||||
void Dropdown::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const RectF content = style.metrics.controlPadding.shrink(localRect());
|
||||
|
||||
const halnf radius = style.metrics.cornerRadius;
|
||||
const RGBA border = isPopupOpen() || showsFocusRing() ? style.palette.mutedText : style.palette.controlBorder;
|
||||
painter.fillRect(localRect(), background(), radius);
|
||||
painter.strokeRect(localRect(), border, radius);
|
||||
|
||||
const RectF textBox = makeRect(content.pos, { content.size.x - kSubmenuArrowWidth, content.size.y });
|
||||
painter.text(selectedText(), textBox, style.metrics.fontSize, TextAlign::Start, style.palette.text);
|
||||
|
||||
const Vec2F chevron = { content.pos.x + content.size.x - kSubmenuArrowWidth / 2, size().y / 2 + 1.5f };
|
||||
painter.polyline(
|
||||
{ chevron + Vec2F(-4.0f, -2.5f), chevron + Vec2F(0.0f, 1.5f), chevron + Vec2F(4.0f, -2.5f) },
|
||||
style.palette.mutedText,
|
||||
1.5f
|
||||
);
|
||||
}
|
||||
|
||||
void Dropdown::onActivated() {
|
||||
mMenu.setSizing(AxisSizing::fit().atLeast(size().x), AxisSizing::fit());
|
||||
PopupButton::onActivated();
|
||||
}
|
||||
|
||||
void Dropdown::rebuildMenu() {
|
||||
mMenu.clear();
|
||||
|
||||
for (size_t index = 0; index < mOptions.size(); index++) {
|
||||
const long optionIndex = (long) index;
|
||||
mMenu.addToggle(
|
||||
mOptions[index],
|
||||
[this, optionIndex]() { return mSelected == optionIndex; },
|
||||
[this, optionIndex]() { setSelectedIndex(optionIndex); }
|
||||
);
|
||||
}
|
||||
|
||||
mMenu.setOnItemActivated([this]() { closePopup(); });
|
||||
}
|
||||
131
Widgets/src/components/chrome/private/WindowControls.cpp
Normal file
131
Widgets/src/components/chrome/private/WindowControls.cpp
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
#include "WindowControls.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
#include "WindowHost.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kGlyphHalfSize = 4.5f;
|
||||
constexpr halnf kGlyphThickness = 1.2f;
|
||||
constexpr halnf kRestoreOffset = 2;
|
||||
constexpr halnf kControlsGap = 2;
|
||||
|
||||
RGBA withAlpha(const RGBA& color, halnf alpha) { return { color.r, color.g, color.b, alpha }; }
|
||||
|
||||
const char* debugNameOf(WindowButton::Kind kind) {
|
||||
switch (kind) {
|
||||
case WindowButton::Kind::Minimize: return "minimize button";
|
||||
case WindowButton::Kind::Maximize: return "maximize button";
|
||||
case WindowButton::Kind::Close: return "close button";
|
||||
}
|
||||
return "window button";
|
||||
}
|
||||
}
|
||||
|
||||
WindowButton::WindowButton(Kind kind) :
|
||||
mKind(kind) {
|
||||
setStyle(Style::Flat);
|
||||
setDebugName(debugNameOf(kind));
|
||||
}
|
||||
|
||||
Extent WindowButton::measureContent(Axis axis, halnf) const {
|
||||
const Theme& style = theme();
|
||||
return Extent::rigid(axis == Axis::Horizontal ? style.metrics.windowButtonWidth : style.metrics.iconButtonSize);
|
||||
}
|
||||
|
||||
RGBA WindowButton::backgroundTarget() const {
|
||||
const Palette& palette = theme().palette;
|
||||
|
||||
if (mKind == Kind::Close) {
|
||||
if (isPressed()) return palette.windowClosePressed;
|
||||
if (isHovered()) return palette.windowCloseHovered;
|
||||
return withAlpha(palette.windowCloseHovered, 0);
|
||||
}
|
||||
|
||||
if (isPressed()) return palette.controlPressed;
|
||||
if (isHovered()) return palette.controlHovered;
|
||||
return withAlpha(palette.controlHovered, 0);
|
||||
}
|
||||
|
||||
void WindowButton::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const bool closeHighlighted = mKind == Kind::Close && (isHovered() || isPressed());
|
||||
const RGBA glyph = closeHighlighted ? style.palette.onWindowClose
|
||||
: isHovered() ? style.palette.text
|
||||
: style.palette.mutedText;
|
||||
|
||||
painter.fillRect(localRect(), background(), style.metrics.cornerRadius);
|
||||
paintGlyph(painter, localRect().center(), glyph);
|
||||
}
|
||||
|
||||
void WindowButton::paintGlyph(Painter& painter, Vec2F center, const RGBA& color) const {
|
||||
const Vec2F half = { kGlyphHalfSize, kGlyphHalfSize };
|
||||
|
||||
switch (mKind) {
|
||||
case Kind::Minimize:
|
||||
painter.line(center + Vec2F(-kGlyphHalfSize, 0.0f), center + Vec2F(kGlyphHalfSize, 0.0f), color, kGlyphThickness);
|
||||
return;
|
||||
|
||||
case Kind::Maximize:
|
||||
{
|
||||
const WindowHost* host = ui() ? ui()->windowHost() : nullptr;
|
||||
const bool maximized = host && host->isWindowMaximized();
|
||||
const RectF box = makeRect(center - half, half * 2.0f);
|
||||
|
||||
if (!maximized) {
|
||||
painter.strokeRect(box, color, 2, kGlyphThickness);
|
||||
return;
|
||||
}
|
||||
|
||||
const Vec2F shift = { kRestoreOffset, -kRestoreOffset };
|
||||
painter.strokeRect(translated(insetRect(box, 1), shift), color, 2, kGlyphThickness);
|
||||
painter.fillRect(insetRect(box, 1), background(), 2);
|
||||
painter.strokeRect(insetRect(box, 1), color, 2, kGlyphThickness);
|
||||
return;
|
||||
}
|
||||
|
||||
case Kind::Close:
|
||||
painter.line(center - half, center + half, color, kGlyphThickness);
|
||||
painter.line(
|
||||
center + Vec2F(-kGlyphHalfSize, kGlyphHalfSize),
|
||||
center + Vec2F(kGlyphHalfSize, -kGlyphHalfSize),
|
||||
color,
|
||||
kGlyphThickness
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void WindowButton::onActivated() {
|
||||
WindowHost* host = ui() ? ui()->windowHost() : nullptr;
|
||||
if (!host) return;
|
||||
|
||||
switch (mKind) {
|
||||
case Kind::Minimize: host->minimizeWindow(); break;
|
||||
case Kind::Maximize: host->toggleMaximizeWindow(); break;
|
||||
case Kind::Close: host->closeWindow(); break;
|
||||
}
|
||||
}
|
||||
|
||||
WindowControls::WindowControls() {
|
||||
setDebugName("window controls");
|
||||
setSizing(AxisSizing::fit(), AxisSizing::fit());
|
||||
emplaceLayout<RowLayout>(kControlsGap).setCrossAlign(CrossAlign::Center);
|
||||
|
||||
addChild(mMinimize);
|
||||
addChild(mMaximize);
|
||||
addChild(mClose);
|
||||
}
|
||||
|
||||
void WindowControls::onAttached() { followWindowHost(); }
|
||||
|
||||
void WindowControls::onWindowHostChanged() { followWindowHost(); }
|
||||
|
||||
void WindowControls::followWindowHost() {
|
||||
const WindowHost* host = ui() ? ui()->windowHost() : nullptr;
|
||||
setVisible(host && host->hasWindowControls());
|
||||
}
|
||||
26
Widgets/src/components/chrome/public/ApplicationFrame.hpp
Normal file
26
Widgets/src/components/chrome/public/ApplicationFrame.hpp
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#pragma once
|
||||
|
||||
#include "Containers.hpp"
|
||||
#include "Menus.hpp"
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class ApplicationFrame : public Panel {
|
||||
public:
|
||||
ApplicationFrame();
|
||||
|
||||
void setContent(Widget* content);
|
||||
[[nodiscard]] Widget* content() const { return mContent; }
|
||||
|
||||
void setMenuBarVisible(bool visible);
|
||||
[[nodiscard]] bool isMenuBarVisible() const { return menuBar.isVisible(); }
|
||||
|
||||
MenuBar menuBar;
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Widget* defaultKeyTarget() override { return mContent ? mContent : this; }
|
||||
|
||||
private:
|
||||
Widget* mContent = nullptr;
|
||||
};
|
||||
}
|
||||
62
Widgets/src/components/chrome/public/Dialog.hpp
Normal file
62
Widgets/src/components/chrome/public/Dialog.hpp
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
#pragma once
|
||||
|
||||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class Dialog : public Panel {
|
||||
public:
|
||||
explicit Dialog(std::string title = {});
|
||||
~Dialog() override;
|
||||
|
||||
void setTitle(std::string title);
|
||||
void setMessage(std::string message);
|
||||
[[nodiscard]] const std::string& title() const { return mTitle.text(); }
|
||||
[[nodiscard]] const std::string& message() const { return mMessage.text(); }
|
||||
|
||||
[[nodiscard]] Widget& body() { return mBody; }
|
||||
|
||||
Button& addButton(std::string text, std::function<void()> action, bool isDefault = false);
|
||||
Button& addCancelButton(std::string text = "Cancel");
|
||||
[[nodiscard]] const std::vector<std::unique_ptr<Button>>& buttons() const { return mButtons; }
|
||||
void setOnCancel(std::function<void()> onCancel) { mOnCancel = std::move(onCancel); }
|
||||
|
||||
void open(Ui& ui, Widget* owner = nullptr);
|
||||
void close();
|
||||
[[nodiscard]] bool isOpen() const;
|
||||
|
||||
void accept();
|
||||
void cancel();
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
|
||||
private:
|
||||
Label mTitle;
|
||||
Label mMessage;
|
||||
Widget mBody;
|
||||
Widget mButtonRow;
|
||||
Spacer mButtonSpacer;
|
||||
std::vector<std::unique_ptr<Button>> mButtons;
|
||||
Button* mDefault = nullptr;
|
||||
std::function<void()> mOnCancel;
|
||||
};
|
||||
|
||||
class ConfirmDialog : public Dialog {
|
||||
public:
|
||||
ConfirmDialog();
|
||||
|
||||
void ask(Ui& ui, std::string title, std::string message, std::string confirmText, std::function<void()> onConfirm);
|
||||
|
||||
private:
|
||||
std::function<void()> mOnConfirm;
|
||||
Button* mConfirm = nullptr;
|
||||
};
|
||||
}
|
||||
220
Widgets/src/components/chrome/public/Menus.hpp
Normal file
220
Widgets/src/components/chrome/public/Menus.hpp
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
#pragma once
|
||||
|
||||
#include "Button.hpp"
|
||||
#include "WindowControls.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Popup.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class WindowHost;
|
||||
|
||||
class PopupButton : public Button {
|
||||
public:
|
||||
explicit PopupButton(std::string text = {});
|
||||
|
||||
void setPopupContent(Widget* content);
|
||||
void setPopupOptions(const PopupOptions& options) { mOptions = options; }
|
||||
[[nodiscard]] PopupOptions& popupOptions() { return mOptions; }
|
||||
|
||||
void openPopup();
|
||||
void openPopupFromHover();
|
||||
void closePopup();
|
||||
[[nodiscard]] bool isPopupOpen() const;
|
||||
|
||||
void setOpenOnHover(bool openOnHover) { mOpenOnHover = openOnHover; }
|
||||
void setOnHovered(std::function<void()> onHovered) { mOnHovered = std::move(onHovered); }
|
||||
void setOnOpening(std::function<void()> onOpening) { mOnOpening = std::move(onOpening); }
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
void onActivated() override;
|
||||
void onHoverChanged() override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
|
||||
private:
|
||||
Widget* mPopupContent = nullptr;
|
||||
PopupOptions mOptions;
|
||||
bool mOpenOnHover = false;
|
||||
bool mOpenedByHover = false;
|
||||
bool mKeepOpenOnActivation = false;
|
||||
Vec2F mHoverOpenPoint = { 0, 0 };
|
||||
std::function<void()> mOnHovered;
|
||||
std::function<void()> mOnOpening;
|
||||
};
|
||||
|
||||
class Menu;
|
||||
|
||||
struct MenuEntry {
|
||||
enum class Kind { Action, Toggle, Separator, Submenu };
|
||||
|
||||
Kind kind = Kind::Action;
|
||||
std::string text;
|
||||
std::string shortcut;
|
||||
std::function<void()> action;
|
||||
std::function<bool()> isChecked;
|
||||
std::function<bool()> isEnabled;
|
||||
std::function<std::string()> dynamicText;
|
||||
bool muted = false;
|
||||
ImageId icon;
|
||||
Menu* submenu = nullptr;
|
||||
|
||||
static MenuEntry item(std::string text, std::function<void()> action, std::string shortcut = {});
|
||||
static MenuEntry toggle(std::string text, std::function<bool()> isChecked, std::function<void()> action);
|
||||
static MenuEntry separator();
|
||||
static MenuEntry nested(std::string text, Menu& menu);
|
||||
};
|
||||
|
||||
class MenuItem : public Control {
|
||||
public:
|
||||
MenuItem(Menu& menu, MenuEntry entry);
|
||||
|
||||
[[nodiscard]] const MenuEntry& entry() const { return mEntry; }
|
||||
[[nodiscard]] const std::string& text() const { return mText; }
|
||||
|
||||
void refresh();
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Painter& painter) override;
|
||||
void onActivated() override;
|
||||
void onHoverChanged() override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
|
||||
private:
|
||||
void openSubmenu();
|
||||
[[nodiscard]] Placement submenuSide(const RectF& menuRect) const;
|
||||
|
||||
Menu& mMenu;
|
||||
MenuEntry mEntry;
|
||||
std::string mText;
|
||||
};
|
||||
|
||||
class Menu : public Panel {
|
||||
public:
|
||||
Menu();
|
||||
~Menu() override;
|
||||
|
||||
MenuItem& add(MenuEntry entry);
|
||||
MenuItem& addItem(std::string text, std::function<void()> action, std::string shortcut = {});
|
||||
MenuItem& addToggle(std::string text, std::function<bool()> isChecked, std::function<void()> action);
|
||||
void addSeparator();
|
||||
MenuItem& addSubmenu(std::string text, Menu& submenu);
|
||||
void clear();
|
||||
|
||||
[[nodiscard]] const std::vector<std::unique_ptr<MenuItem>>& items() const { return mItems; }
|
||||
[[nodiscard]] std::vector<const MenuItem*> itemsWithSeparators() const;
|
||||
void notifyItemActivated() const {
|
||||
if (mOnItemActivated) mOnItemActivated();
|
||||
}
|
||||
|
||||
void setOnItemActivated(std::function<void()> onItemActivated) { mOnItemActivated = std::move(onItemActivated); }
|
||||
void setOnOpen(std::function<void(Menu&)> onOpen) { mOnOpen = std::move(onOpen); }
|
||||
void refresh();
|
||||
|
||||
void openAt(Widget& owner, const RectF& anchor, Placement placement);
|
||||
void closeMenu();
|
||||
[[nodiscard]] bool isOpen() const;
|
||||
|
||||
private:
|
||||
friend class MenuItem;
|
||||
|
||||
void closeChain();
|
||||
|
||||
ScrollView mScroll;
|
||||
Widget mColumn;
|
||||
std::vector<std::unique_ptr<MenuItem>> mItems;
|
||||
std::vector<std::unique_ptr<Separator>> mSeparators;
|
||||
std::function<void()> mOnItemActivated;
|
||||
std::function<void(Menu&)> mOnOpen;
|
||||
};
|
||||
|
||||
class MenuIconButton : public PopupButton {
|
||||
public:
|
||||
MenuIconButton();
|
||||
|
||||
protected:
|
||||
[[nodiscard]] bool hasIcon() const override { return true; }
|
||||
void paintIcon(Painter& painter, const RectF& box) override;
|
||||
};
|
||||
|
||||
class MenuBar : public Panel {
|
||||
public:
|
||||
MenuBar();
|
||||
|
||||
PopupButton& addMenu(std::string title, Menu& menu);
|
||||
PopupButton& addTrailingMenu(std::string title, Menu& menu);
|
||||
void removeMenu(Menu& menu);
|
||||
void setCenterWidget(Widget* widget);
|
||||
void setTrailingWidget(Widget* widget);
|
||||
[[nodiscard]] Widget* trailingWidget() const { return mTrailing; }
|
||||
[[nodiscard]] Widget* centerWidget() const { return mCenter; }
|
||||
[[nodiscard]] const std::vector<std::unique_ptr<PopupButton>>& titles() const { return mTitles; }
|
||||
[[nodiscard]] WindowControls& windowControls() { return mWindowControls; }
|
||||
[[nodiscard]] PopupButton& overflowButton() { return mOverflow; }
|
||||
[[nodiscard]] Menu& overflowMenu() { return mOverflowMenu; }
|
||||
[[nodiscard]] size_t shownTitleCount() const { return mShownTitles; }
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
|
||||
private:
|
||||
class MenuBarLayout;
|
||||
friend class MenuBarLayout;
|
||||
|
||||
PopupButton& insertMenu(size_t index, std::string title, Menu& menu);
|
||||
void showTitles(size_t count);
|
||||
void onTitleHovered(PopupButton& title);
|
||||
[[nodiscard]] bool isDragSurface(const Widget* target) const;
|
||||
void handleDragSurfacePress(Event& event, WindowHost& host);
|
||||
void handlePendingMove(Event& event, WindowHost& host);
|
||||
void cancelPendingMove();
|
||||
|
||||
std::vector<std::unique_ptr<PopupButton>> mTitles;
|
||||
bool mMovePending = false;
|
||||
Vec2F mMovePressPoint = { 0, 0 };
|
||||
Spacer mDragSpace;
|
||||
Widget* mCenter = nullptr;
|
||||
Widget* mTrailing = nullptr;
|
||||
size_t mTrailingMenus = 0;
|
||||
WindowControls mWindowControls;
|
||||
PopupButton mOverflow{ "\u00bb" };
|
||||
Menu mOverflowMenu;
|
||||
std::vector<Menu*> mMenus;
|
||||
size_t mShownTitles = 0;
|
||||
};
|
||||
|
||||
class Dropdown : public PopupButton {
|
||||
public:
|
||||
explicit Dropdown(std::vector<std::string> options = {});
|
||||
|
||||
void setOptions(std::vector<std::string> options);
|
||||
[[nodiscard]] const std::vector<std::string>& options() const { return mOptions; }
|
||||
|
||||
void setSelectedIndex(long index);
|
||||
[[nodiscard]] long selectedIndex() const { return mSelected; }
|
||||
[[nodiscard]] std::string selectedText() const;
|
||||
|
||||
void setOnSelected(std::function<void(long)> onSelected) { mOnSelected = std::move(onSelected); }
|
||||
|
||||
[[nodiscard]] Menu& menu() { return mMenu; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Painter& painter) override;
|
||||
void onActivated() override;
|
||||
|
||||
private:
|
||||
void rebuildMenu();
|
||||
|
||||
std::vector<std::string> mOptions;
|
||||
long mSelected = -1;
|
||||
Menu mMenu;
|
||||
std::function<void(long)> mOnSelected;
|
||||
};
|
||||
}
|
||||
47
Widgets/src/components/chrome/public/WindowControls.hpp
Normal file
47
Widgets/src/components/chrome/public/WindowControls.hpp
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#pragma once
|
||||
|
||||
#include "Button.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class WindowButton : public Button {
|
||||
public:
|
||||
enum class Kind { Minimize, Maximize, Close };
|
||||
|
||||
explicit WindowButton(Kind kind);
|
||||
|
||||
[[nodiscard]] Kind kind() const { return mKind; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Painter& painter) override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
void onActivated() override;
|
||||
|
||||
private:
|
||||
void paintGlyph(Painter& painter, Vec2F center, const RGBA& color) const;
|
||||
|
||||
Kind mKind;
|
||||
};
|
||||
|
||||
class WindowControls : public Widget {
|
||||
public:
|
||||
WindowControls();
|
||||
|
||||
[[nodiscard]] WindowButton& minimizeButton() { return mMinimize; }
|
||||
[[nodiscard]] WindowButton& maximizeButton() { return mMaximize; }
|
||||
[[nodiscard]] WindowButton& closeButton() { return mClose; }
|
||||
|
||||
protected:
|
||||
void onAttached() override;
|
||||
void onWindowHostChanged() override;
|
||||
|
||||
private:
|
||||
void followWindowHost();
|
||||
|
||||
WindowButton mMinimize{ WindowButton::Kind::Minimize };
|
||||
WindowButton mMaximize{ WindowButton::Kind::Maximize };
|
||||
WindowButton mClose{ WindowButton::Kind::Close };
|
||||
};
|
||||
}
|
||||
60
Widgets/src/components/controls/private/Breadcrumb.cpp
Normal file
60
Widgets/src/components/controls/private/Breadcrumb.cpp
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#include "Breadcrumb.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "StackLayout.hpp"
|
||||
#include "Theme.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kPartGap = 2;
|
||||
constexpr const char* kSeparator = "/";
|
||||
}
|
||||
|
||||
BreadcrumbPart::BreadcrumbPart(std::string text, bool current) :
|
||||
Button(std::move(text)),
|
||||
mCurrent(current) {
|
||||
setDebugName("breadcrumb part");
|
||||
setStyle(Style::Flat);
|
||||
setEnabled(!current);
|
||||
if (current) setFontFace(FontFace::Bold);
|
||||
}
|
||||
|
||||
RGBA BreadcrumbPart::labelColor() const { return mCurrent ? theme().palette.text : Button::labelColor(); }
|
||||
|
||||
Breadcrumb::Breadcrumb() {
|
||||
setDebugName("breadcrumb");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
emplaceLayout<StackLayout>();
|
||||
|
||||
mRow.setSizing(AxisSizing::fit(), AxisSizing::fit());
|
||||
mRow.emplaceLayout<RowLayout>(kPartGap).setCrossAlign(CrossAlign::Center);
|
||||
mScroll.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mScroll.setShowsScrollBar(false);
|
||||
mScroll.setContent(&mRow);
|
||||
addChild(mScroll);
|
||||
}
|
||||
|
||||
void Breadcrumb::setParts(const std::vector<std::string>& parts) {
|
||||
mRow.removeAllChildren();
|
||||
mParts.clear();
|
||||
mSeparators.clear();
|
||||
|
||||
for (size_t index = 0; index < parts.size(); index++) {
|
||||
if (index > 0) {
|
||||
mSeparators.push_back(std::make_unique<Label>(kSeparator));
|
||||
mSeparators.back()->setTone(Label::Tone::Muted);
|
||||
mRow.addChild(*mSeparators.back());
|
||||
}
|
||||
|
||||
const bool current = index + 1 == parts.size();
|
||||
mParts.push_back(std::make_unique<BreadcrumbPart>(parts[index], current));
|
||||
BreadcrumbPart& part = *mParts.back();
|
||||
part.setOnActivate([this, index]() {
|
||||
if (mOnPartChosen) mOnPartChosen(index);
|
||||
});
|
||||
mRow.addChild(part);
|
||||
}
|
||||
requestLayout();
|
||||
}
|
||||
231
Widgets/src/components/controls/private/Button.cpp
Normal file
231
Widgets/src/components/controls/private/Button.cpp
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
#include "Button.hpp"
|
||||
|
||||
#include "Theme.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
Button::Button(std::string text, std::function<void()> onClick) :
|
||||
mText(std::move(text)) {
|
||||
setOnActivate(std::move(onClick));
|
||||
setDebugName("button");
|
||||
}
|
||||
|
||||
void Button::setText(std::string text) {
|
||||
if (text == mText) return;
|
||||
|
||||
mText = std::move(text);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void Button::setFontFace(FontFace face) {
|
||||
if (face == mFontFace) return;
|
||||
mFontFace = face;
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void Button::setIcon(ImageId icon) {
|
||||
mIcon = icon;
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void Button::setIconPlacement(IconPlacement placement) {
|
||||
mIconPlacement = placement;
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void Button::setStyle(Style style) {
|
||||
mStyle = style;
|
||||
refreshVisualState();
|
||||
}
|
||||
|
||||
void Button::setSelected(bool selected) {
|
||||
if (selected == mSelected) return;
|
||||
|
||||
mSelected = selected;
|
||||
refreshVisualState();
|
||||
}
|
||||
|
||||
Extent Button::measureContent(Axis axis, halnf) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
const halnf padding = metrics.controlPadding.total(axis);
|
||||
const halnf textWidthValue = mText.empty() ? 0 : textWidth(mText, labelStyle());
|
||||
const halnf icon = hasIcon() ? iconSize() : 0;
|
||||
const bool stacked = hasIcon() && !mText.empty() && mIconPlacement == IconPlacement::Above;
|
||||
|
||||
if (axis == Axis::Horizontal) {
|
||||
if (stacked) return Extent::natural((textWidthValue > icon ? textWidthValue : icon) + padding);
|
||||
|
||||
const halnf gap = hasIcon() && !mText.empty() ? metrics.spacing : 0;
|
||||
return Extent::natural(icon + gap + textWidthValue + padding);
|
||||
}
|
||||
|
||||
const halnf content = stacked ? icon + metrics.lineHeight : (icon > metrics.lineHeight ? icon : metrics.lineHeight);
|
||||
const halnf height = content + padding;
|
||||
return Extent::natural(height > metrics.controlHeight ? height : metrics.controlHeight);
|
||||
}
|
||||
|
||||
void Button::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const ContentBoxes boxes = contentBoxes();
|
||||
|
||||
const halnf radius = cornerRadius();
|
||||
const bool primary = (mSelected && mStyle == Style::Normal) || mStyle == Style::Accent;
|
||||
|
||||
painter.fillRect(localRect(), background(), radius);
|
||||
if (mStyle == Style::Normal && !primary) painter.strokeRect(localRect(), style.palette.controlBorder, radius);
|
||||
if (showsFocusRing()) painter.strokeRect(insetRect(localRect(), -2), style.palette.focusRing, radius + 2, 2);
|
||||
|
||||
if (hasIcon()) paintIcon(painter, boxes.icon);
|
||||
if (mText.empty()) return;
|
||||
|
||||
const bool leadingIcon = hasIcon() && mIconPlacement == IconPlacement::Leading;
|
||||
painter.styledText(mText, boxes.text, labelStyle(), leadingIcon ? TextAlign::Start : TextAlign::Center, labelColor());
|
||||
}
|
||||
|
||||
RGBA Button::labelColor() const {
|
||||
const Palette& palette = theme().palette;
|
||||
if (!isEffectivelyEnabled()) return palette.disabledText;
|
||||
|
||||
const bool primary = (mSelected && mStyle == Style::Normal) || mStyle == Style::Accent;
|
||||
if (primary) return palette.onAccent;
|
||||
const bool quietUnselected = (mStyle == Style::Tab || mStyle == Style::Flat) && !mSelected && !isHovered();
|
||||
return quietUnselected ? palette.mutedText : palette.text;
|
||||
}
|
||||
|
||||
void Button::setFill(std::optional<RGBA> fill) {
|
||||
mFill = fill;
|
||||
refreshVisualState();
|
||||
}
|
||||
|
||||
RGBA Button::backgroundTarget() const {
|
||||
const Palette& palette = theme().palette;
|
||||
|
||||
if (mFill && isEffectivelyEnabled()) {
|
||||
if (isPressed()) return palette.controlPressed;
|
||||
if (isHovered()) return palette.controlHovered;
|
||||
return *mFill;
|
||||
}
|
||||
|
||||
if (mStyle == Style::Tab || mStyle == Style::Flat) {
|
||||
const RGBA transparent = { palette.controlHovered.r, palette.controlHovered.g, palette.controlHovered.b, 0 };
|
||||
if (!isEffectivelyEnabled()) return transparent;
|
||||
if (isPressed()) return palette.controlPressed;
|
||||
if (mSelected) return isHovered() ? palette.controlHovered : palette.neutralSelection;
|
||||
if (isHovered()) return palette.controlHovered;
|
||||
return transparent;
|
||||
}
|
||||
|
||||
if (mSelected || mStyle == Style::Accent) {
|
||||
if (!isEffectivelyEnabled()) return palette.control;
|
||||
if (isPressed()) return palette.accentPressed;
|
||||
return isHovered() ? palette.accentHovered : palette.accent;
|
||||
}
|
||||
|
||||
return Control::backgroundTarget();
|
||||
}
|
||||
|
||||
void Button::paintIcon(Painter& painter, const RectF& box) { painter.image(box, mIcon); }
|
||||
|
||||
Button::ContentBoxes Button::contentBoxes() const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
const RectF content = metrics.controlPadding.shrink(localRect());
|
||||
const halnf icon = iconSize();
|
||||
|
||||
if (!hasIcon()) return { makeRect(0, 0, 0, 0), content };
|
||||
|
||||
if (mText.empty()) {
|
||||
const Vec2F center = content.center();
|
||||
return { makeRect(center.x - icon / 2, center.y - icon / 2, icon, icon), content };
|
||||
}
|
||||
|
||||
if (mIconPlacement == IconPlacement::Above) {
|
||||
const RectF iconBox = makeRect(content.pos.x + (content.size.x - icon) / 2, content.pos.y, icon, icon);
|
||||
const RectF textBox = makeRect(content.pos.x, content.pos.y + icon, content.size.x, content.size.y - icon);
|
||||
return { iconBox, textBox };
|
||||
}
|
||||
|
||||
const RectF iconBox = makeRect(content.pos.x, content.pos.y + (content.size.y - icon) / 2, icon, icon);
|
||||
const halnf textLeft = content.pos.x + icon + metrics.spacing;
|
||||
return { iconBox, makeRect(textLeft, content.pos.y, content.pos.x + content.size.x - textLeft, content.size.y) };
|
||||
}
|
||||
|
||||
halnf Button::cornerRadius() const { return mCornerRadius >= 0 ? mCornerRadius : theme().metrics.cornerRadius; }
|
||||
|
||||
halnf Button::iconSize() const { return mIconSize > 0 ? mIconSize : theme().metrics.lineHeight + 2; }
|
||||
|
||||
halnf Button::labelFontSize() const {
|
||||
const bool stacked = hasIcon() && mIconPlacement == IconPlacement::Above;
|
||||
return stacked ? theme().metrics.smallFontSize : theme().metrics.fontSize;
|
||||
}
|
||||
|
||||
namespace {
|
||||
constexpr halnf kChevronHalfHeight = 5;
|
||||
constexpr halnf kChevronDepth = 3;
|
||||
constexpr halnf kGlyphStroke = 1.5f;
|
||||
|
||||
const char* glyphDebugName(GlyphButton::Glyph glyph) {
|
||||
switch (glyph) {
|
||||
case GlyphButton::Glyph::Options: return "options button";
|
||||
case GlyphButton::Glyph::Hide: return "hide button";
|
||||
case GlyphButton::Glyph::Previous: return "previous button";
|
||||
case GlyphButton::Glyph::Next: return "next button";
|
||||
case GlyphButton::Glyph::Up: return "up button";
|
||||
}
|
||||
return "glyph button";
|
||||
}
|
||||
}
|
||||
|
||||
GlyphButton::GlyphButton(Glyph glyph) :
|
||||
mGlyph(glyph) {
|
||||
setStyle(Style::Flat);
|
||||
setDebugName(glyphDebugName(glyph));
|
||||
}
|
||||
|
||||
Extent GlyphButton::measureContent(Axis, halnf) const { return Extent::rigid(theme().metrics.iconButtonSize); }
|
||||
|
||||
void GlyphButton::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
RGBA color = isHovered() && isEffectivelyEnabled() ? style.palette.text : style.palette.mutedText;
|
||||
if (!isEffectivelyEnabled()) color = style.palette.disabledText;
|
||||
const Vec2F center = localRect().center();
|
||||
|
||||
painter.fillRect(localRect(), background(), style.metrics.cornerRadius);
|
||||
|
||||
switch (mGlyph) {
|
||||
case Glyph::Hide: painter.line(center - Vec2F(5.0f, 0.0f), center + Vec2F(5.0f, 0.0f), color, kGlyphStroke); return;
|
||||
case Glyph::Previous: paintChevron(painter, -1, color); return;
|
||||
case Glyph::Next: paintChevron(painter, 1, color); return;
|
||||
case Glyph::Up: paintUpChevron(painter, color); return;
|
||||
case Glyph::Options:
|
||||
for (halnf offset : { -5.0f, 0.0f, 5.0f })
|
||||
painter.fillCircle(center + Vec2F(0.0f, offset), 1.5f, color);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void GlyphButton::paintChevron(Painter& painter, halnf direction, const RGBA& color) const {
|
||||
const Vec2F center = localRect().center();
|
||||
const Vec2F tip = center + Vec2F(direction * kChevronDepth, 0.0f);
|
||||
const Vec2F back = center - Vec2F(direction * kChevronDepth, 0.0f);
|
||||
painter.polyline(
|
||||
{ back - Vec2F(0.0f, kChevronHalfHeight), tip, back + Vec2F(0.0f, kChevronHalfHeight) }, color, kGlyphStroke
|
||||
);
|
||||
}
|
||||
|
||||
void GlyphButton::paintUpChevron(Painter& painter, const RGBA& color) const {
|
||||
const Vec2F center = localRect().center();
|
||||
const Vec2F tip = center - Vec2F(0.0f, kChevronDepth);
|
||||
const Vec2F back = center + Vec2F(0.0f, kChevronDepth);
|
||||
painter.polyline(
|
||||
{ back - Vec2F(kChevronHalfHeight, 0.0f), tip, back + Vec2F(kChevronHalfHeight, 0.0f) }, color, kGlyphStroke
|
||||
);
|
||||
}
|
||||
|
||||
RGBA GlyphButton::backgroundTarget() const {
|
||||
const Palette& palette = theme().palette;
|
||||
const RGBA hovered = palette.controlHovered;
|
||||
if (isPressed()) return palette.controlPressed;
|
||||
if (isHovered()) return hovered;
|
||||
return { hovered.r, hovered.g, hovered.b, 0 };
|
||||
}
|
||||
249
Widgets/src/components/controls/private/Collapsible.cpp
Normal file
249
Widgets/src/components/controls/private/Collapsible.cpp
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
#include "Collapsible.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Layout.hpp"
|
||||
#include "Painter.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kChevronWidth = 18;
|
||||
constexpr halnf kChevronHalf = 3.5f;
|
||||
constexpr halnf kPi = 3.14159265f;
|
||||
}
|
||||
|
||||
CollapsibleHeader::CollapsibleHeader(std::string title) :
|
||||
mTitle(std::move(title)) {
|
||||
setDebugName("collapsible header");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
}
|
||||
|
||||
void CollapsibleHeader::setTitle(std::string title) {
|
||||
if (title == mTitle) return;
|
||||
mTitle = std::move(title);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void CollapsibleHeader::setSummary(std::string summary) {
|
||||
if (summary == mSummary) return;
|
||||
mSummary = std::move(summary);
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void CollapsibleHeader::setExpanded(bool expanded, bool animated) {
|
||||
const halnf target = expanded ? 1.0f : 0.0f;
|
||||
if (animated) animate(mTurn, target);
|
||||
else {
|
||||
mTurn.jumpTo(target);
|
||||
requestPaint();
|
||||
}
|
||||
}
|
||||
|
||||
class CollapsibleHeader::HeaderLayout : public Layout {
|
||||
public:
|
||||
explicit HeaderLayout(CollapsibleHeader& header) :
|
||||
mHeader(header) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
Widget& action = *mHeader.mAction;
|
||||
const Extent own = mHeader.measureContent(axis, crossLength);
|
||||
const Extent child = context.measure(action, axis, crossLength);
|
||||
if (axis == Axis::Vertical) return Extent::natural(std::max(own.preferred, child.preferred));
|
||||
return Extent::shrinkable(own.preferred + child.preferred);
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& area) override {
|
||||
Widget& action = *mHeader.mAction;
|
||||
const halnf width = context.measure(action, Axis::Horizontal, area.size.y).preferred;
|
||||
const halnf height = std::min(area.size.y, context.measure(action, Axis::Vertical, width).preferred);
|
||||
context.place(
|
||||
action, makeRect(area.pos.x + area.size.x - width, area.pos.y + (area.size.y - height) / 2, width, height)
|
||||
);
|
||||
}
|
||||
|
||||
private:
|
||||
CollapsibleHeader& mHeader;
|
||||
};
|
||||
|
||||
void CollapsibleHeader::setAction(Widget* action) {
|
||||
if (mAction) removeChild(*mAction);
|
||||
mAction = action;
|
||||
if (!action) {
|
||||
setLayout(nullptr);
|
||||
return;
|
||||
}
|
||||
addChild(*action);
|
||||
setLayout(std::make_unique<HeaderLayout>(*this));
|
||||
}
|
||||
|
||||
halnf CollapsibleHeader::actionWidth() const { return mAction ? mAction->size().x : 0; }
|
||||
|
||||
Extent CollapsibleHeader::measureContent(Axis axis, halnf) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
if (axis == Axis::Vertical) return Extent::natural(metrics.controlHeight);
|
||||
const halnf summary = mSummary.empty() ? 0 : textWidth(mSummary, metrics.smallFontSize) + 12;
|
||||
return Extent::shrinkable(
|
||||
kChevronWidth + textWidth(mTitle, metrics.fontSize) + summary + metrics.controlPadding.left
|
||||
);
|
||||
}
|
||||
|
||||
RGBA CollapsibleHeader::backgroundTarget() const {
|
||||
const Palette& palette = theme().palette;
|
||||
if (isPressed()) return palette.controlPressed;
|
||||
if (isHovered()) return palette.controlHovered;
|
||||
return { palette.controlHovered.r, palette.controlHovered.g, palette.controlHovered.b, 0 };
|
||||
}
|
||||
|
||||
void CollapsibleHeader::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const Metrics& metrics = style.metrics;
|
||||
painter.fillRect(localRect(), background(), metrics.cornerRadius);
|
||||
if (showsFocusRing())
|
||||
painter.strokeRect(insetRect(localRect(), -2), style.palette.focusRing, metrics.cornerRadius + 2, 2);
|
||||
|
||||
const Vec2F center = { kChevronWidth / 2 + 2, size().y / 2 };
|
||||
const halnf angle = mTurn.value() * kPi / 2;
|
||||
const halnf cosine = std::cos(angle);
|
||||
const halnf sine = std::sin(angle);
|
||||
auto turned = [&](Vec2F point) {
|
||||
return center + Vec2F(point.x * cosine - point.y * sine, point.x * sine + point.y * cosine);
|
||||
};
|
||||
painter.polyline(
|
||||
{ turned(Vec2F(-kChevronHalf / 2, -kChevronHalf)),
|
||||
turned(Vec2F(kChevronHalf / 2, 0.0f)),
|
||||
turned(Vec2F(-kChevronHalf / 2, kChevronHalf)) },
|
||||
style.palette.mutedText,
|
||||
1.5f
|
||||
);
|
||||
|
||||
const RectF textBox = makeRect(
|
||||
kChevronWidth + 4, 0, size().x - kChevronWidth - 4 - metrics.controlPadding.right - actionWidth(), size().y
|
||||
);
|
||||
painter.text(mTitle, textBox, metrics.fontSize, TextAlign::Start, style.palette.text);
|
||||
if (!mSummary.empty())
|
||||
painter.text(mSummary, textBox, metrics.smallFontSize, TextAlign::End, style.palette.mutedText);
|
||||
}
|
||||
|
||||
class Collapsible::SectionLayout : public Layout {
|
||||
public:
|
||||
explicit SectionLayout(Collapsible& section) :
|
||||
mSection(section) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
const Extent header = context.measure(mSection.mHeader, axis, crossLength);
|
||||
if (!mSection.mReveal.isVisible()) return header;
|
||||
|
||||
const Extent body = context.measure(mSection.mBody, axis, crossLength);
|
||||
if (axis == Axis::Horizontal) {
|
||||
return { std::max(header.minimum, body.minimum), std::max(header.preferred, body.preferred), kUnbounded, 0 };
|
||||
}
|
||||
return Extent::rigid(header.preferred + revealed(body.preferred));
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& area) override {
|
||||
const halnf headerHeight = context.measure(mSection.mHeader, Axis::Vertical, area.size.x).preferred;
|
||||
context.place(mSection.mHeader, makeRect(area.pos.x, area.pos.y, area.size.x, headerHeight));
|
||||
if (!mSection.mReveal.isVisible()) return;
|
||||
|
||||
const halnf bodyHeight = context.measure(mSection.mBody, Axis::Vertical, area.size.x).preferred;
|
||||
const halnf shown = revealed(bodyHeight);
|
||||
context.place(mSection.mReveal, makeRect(area.pos.x, area.pos.y + headerHeight, area.size.x, shown));
|
||||
}
|
||||
|
||||
private:
|
||||
[[nodiscard]] halnf revealed(halnf bodyHeight) const {
|
||||
return std::round(bodyHeight * std::clamp(mSection.mOpen.value(), 0.0f, 1.0f));
|
||||
}
|
||||
|
||||
Collapsible& mSection;
|
||||
};
|
||||
|
||||
class CollapsibleRevealLayout : public Layout {
|
||||
public:
|
||||
explicit CollapsibleRevealLayout(Widget& body) :
|
||||
mBody(body) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
return context.measure(mBody, axis, crossLength);
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& area) override {
|
||||
const halnf height = context.measure(mBody, Axis::Vertical, area.size.x).preferred;
|
||||
context.place(mBody, makeRect(area.pos.x, area.pos.y + area.size.y - height, area.size.x, height));
|
||||
}
|
||||
|
||||
private:
|
||||
Widget& mBody;
|
||||
};
|
||||
|
||||
Collapsible::Collapsible(std::string title) :
|
||||
mHeader(std::move(title)) {
|
||||
setDebugName("collapsible");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
setClipsChildren(true);
|
||||
setLayout(std::make_unique<SectionLayout>(*this));
|
||||
mHeader.setGeometryAnimated(false);
|
||||
setGeometryAnimated(false);
|
||||
|
||||
mBody.setDebugName("collapsible body");
|
||||
mBody.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mBody.setGeometryAnimated(false);
|
||||
mBody.emplaceLayout<ColumnLayout>(6).setPadding(Insets{ kChevronWidth + 4, 4, 0, 6 });
|
||||
|
||||
mReveal.setDebugName("collapsible reveal");
|
||||
mReveal.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mReveal.setGeometryAnimated(false);
|
||||
mReveal.setClipsChildren(true);
|
||||
mReveal.setLayout(std::make_unique<CollapsibleRevealLayout>(mBody));
|
||||
mReveal.addChild(mBody);
|
||||
mReveal.setVisible(false);
|
||||
|
||||
mHeader.setOnActivate([this]() { toggle(); });
|
||||
|
||||
addChild(mReveal);
|
||||
addChild(mHeader);
|
||||
}
|
||||
|
||||
void Collapsible::setExpanded(bool expanded, bool animated) {
|
||||
if (expanded == mExpanded) return;
|
||||
mExpanded = expanded;
|
||||
mHeader.setExpanded(expanded, animated);
|
||||
|
||||
const halnf target = expanded ? 1.0f : 0.0f;
|
||||
if (expanded) mReveal.setVisible(true);
|
||||
if (animated && ui()) {
|
||||
mOpen.animateTo(target);
|
||||
startTicking();
|
||||
} else {
|
||||
mOpen.jumpTo(target);
|
||||
mReveal.setVisible(expanded);
|
||||
}
|
||||
requestLayout();
|
||||
if (mOnToggled) mOnToggled(expanded);
|
||||
}
|
||||
|
||||
void Collapsible::finishTransitions() {
|
||||
if (!mOpen.isAnimating()) return;
|
||||
mOpen.finish();
|
||||
if (!mExpanded) mReveal.setVisible(false);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void Collapsible::onTick(halnf deltaMs) {
|
||||
const bool animating = mOpen.advance(deltaMs);
|
||||
if (!animating) {
|
||||
stopTicking();
|
||||
if (!mExpanded) mReveal.setVisible(false);
|
||||
}
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
476
Widgets/src/components/controls/private/Containers.cpp
Normal file
476
Widgets/src/components/controls/private/Containers.cpp
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
#include "Containers.hpp"
|
||||
#include "ScrollPhysics.hpp"
|
||||
|
||||
#include "Event.hpp"
|
||||
#include "Layout.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kShowAfterScrollMs = 700;
|
||||
halnf clampLength(halnf value, halnf minimum, halnf maximum) {
|
||||
if (value < minimum) return minimum;
|
||||
if (value > maximum) return maximum;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
Panel::Panel(Surface surface) :
|
||||
mSurface(surface) {
|
||||
setDebugName("panel");
|
||||
setClipsChildren(surface != Surface::None);
|
||||
}
|
||||
|
||||
void Panel::setSurface(Surface surface) {
|
||||
mSurface = surface;
|
||||
setClipsChildren(surface != Surface::None);
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void Panel::setCornerRadius(halnf radius) {
|
||||
mCornerRadius = radius;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void Panel::setBorder(bool border) {
|
||||
mBorder = border;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void Panel::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const halnf radius = mCornerRadius >= 0 ? mCornerRadius : style.metrics.cornerRadius;
|
||||
|
||||
switch (mSurface) {
|
||||
case Surface::None: break;
|
||||
case Surface::Window:
|
||||
painter.fillRect(localRect(), style.palette.window, mCornerRadius >= 0 ? mCornerRadius : 0);
|
||||
break;
|
||||
case Surface::Base: painter.fillRect(localRect(), style.palette.surface, radius); break;
|
||||
case Surface::Raised: painter.fillRect(localRect(), style.palette.raisedSurface, radius); break;
|
||||
}
|
||||
|
||||
if (mBorder) painter.strokeRect(localRect(), style.palette.border, radius);
|
||||
}
|
||||
|
||||
halnf Panel::effectiveCornerRadius() const { return mCornerRadius >= 0 ? mCornerRadius : theme().metrics.cornerRadius; }
|
||||
|
||||
Separator::Separator(Axis lineAxis) :
|
||||
mLineAxis(lineAxis) {
|
||||
setDebugName("separator");
|
||||
setSizing(mLineAxis, AxisSizing::fill());
|
||||
}
|
||||
|
||||
Extent Separator::measureContent(Axis axis, halnf) const {
|
||||
const halnf thickness = theme().metrics.separatorThickness + theme().metrics.spacing;
|
||||
if (axis == mLineAxis) return Extent::shrinkable(thickness);
|
||||
return Extent::rigid(thickness);
|
||||
}
|
||||
|
||||
void Separator::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const Vec2F middle = size() / 2.0f;
|
||||
|
||||
if (mLineAxis == Axis::Horizontal) {
|
||||
painter.line(
|
||||
Vec2F(0.0f, middle.y), Vec2F(size().x, middle.y), style.palette.separator, style.metrics.separatorThickness
|
||||
);
|
||||
} else {
|
||||
painter.line(
|
||||
Vec2F(middle.x, 0.0f), Vec2F(middle.x, size().y), style.palette.separator, style.metrics.separatorThickness
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Spacer::Spacer() {
|
||||
setDebugName("spacer");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
}
|
||||
|
||||
Spacer::Spacer(halnf length) {
|
||||
setDebugName("spacer");
|
||||
setSizing(AxisSizing::fixed(length), AxisSizing::fixed(length));
|
||||
}
|
||||
|
||||
class ScrollView::ScrollLayout : public Layout {
|
||||
public:
|
||||
explicit ScrollLayout(ScrollView& view) :
|
||||
mView(view) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
if (!mView.mContent || !mView.mContent->isVisible()) return {};
|
||||
|
||||
Extent extent = context.measure(*mView.mContent, axis, crossLength);
|
||||
if (axis == mView.mScrollAxis) extent.minimum = 0;
|
||||
return extent;
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& contentArea) override {
|
||||
const Axis scroll = mView.mScrollAxis;
|
||||
const Axis cross = crossAxis(scroll);
|
||||
|
||||
if (mView.mContent && mView.mContent->isVisible()) {
|
||||
const halnf crossLength = lengthAlong(contentArea, cross);
|
||||
const halnf contentLength = context.measure(*mView.mContent, scroll, crossLength).preferred;
|
||||
|
||||
mView.mContentLength = std::max(contentLength, lengthAlong(contentArea, scroll));
|
||||
mView.keepOffsetInRange();
|
||||
|
||||
RectF rect;
|
||||
setSpanAlong(
|
||||
rect,
|
||||
scroll,
|
||||
startAlong(contentArea, scroll) - mView.mOffset.value() + mView.stretchOffset(),
|
||||
mView.mContentLength
|
||||
);
|
||||
setSpanAlong(rect, cross, startAlong(contentArea, cross), crossLength);
|
||||
context.place(*mView.mContent, rect);
|
||||
}
|
||||
|
||||
const halnf thickness = mView.theme().metrics.scrollBarThickness;
|
||||
RectF bar = contentArea;
|
||||
setSpanAlong(bar, cross, startAlong(contentArea, cross) + lengthAlong(contentArea, cross) - thickness, thickness);
|
||||
mView.mScrollBar.setVisible(mView.mShowsScrollBar && mView.isScrollable());
|
||||
if (mView.mScrollBar.isVisible()) context.place(mView.mScrollBar, bar);
|
||||
}
|
||||
|
||||
private:
|
||||
ScrollView& mView;
|
||||
};
|
||||
|
||||
ScrollBar::ScrollBar(ScrollView& view) :
|
||||
mView(view) {
|
||||
setDebugName("scroll bar");
|
||||
setGeometryAnimated(false);
|
||||
}
|
||||
|
||||
RectF ScrollBar::trackRect() const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
const Axis axis = mView.scrollAxis();
|
||||
const Axis cross = crossAxis(axis);
|
||||
const halnf thickness = metrics.scrollTrackThickness;
|
||||
const halnf outerEdge = lengthAlong(localRect(), cross) - metrics.scrollTrackEdgeGap;
|
||||
|
||||
RectF track;
|
||||
setSpanAlong(
|
||||
track, axis, metrics.scrollTrackEdgeGap, lengthAlong(localRect(), axis) - metrics.scrollTrackEdgeGap * 2
|
||||
);
|
||||
setSpanAlong(track, cross, outerEdge - thickness, thickness);
|
||||
return track;
|
||||
}
|
||||
|
||||
RectF ScrollBar::thumbRect() const {
|
||||
const Axis axis = mView.scrollAxis();
|
||||
const RectF track = trackRect();
|
||||
const halnf trackLength = lengthAlong(track, axis);
|
||||
const halnf visibleFraction = mView.contentLength() > 0 ? mView.viewportLength() / mView.contentLength() : 1;
|
||||
const halnf thumbLength =
|
||||
clampLength(trackLength * visibleFraction, lengthAlong(track, crossAxis(axis)) * 3, trackLength);
|
||||
const halnf travel = trackLength - thumbLength;
|
||||
const halnf progress = mView.maximumOffset() > 0 ? mView.currentOffset() / mView.maximumOffset() : 0;
|
||||
|
||||
RectF thumb = track;
|
||||
setSpanAlong(thumb, axis, startAlong(track, axis) + travel * progress, thumbLength);
|
||||
return thumb;
|
||||
}
|
||||
|
||||
halnf ScrollBar::offsetForThumbAt(halnf pointerAlong) const {
|
||||
const Axis axis = mView.scrollAxis();
|
||||
const RectF track = trackRect();
|
||||
const halnf travel = lengthAlong(track, axis) - lengthAlong(thumbRect(), axis);
|
||||
if (travel <= 0) return 0;
|
||||
|
||||
const halnf thumbStart = pointerAlong - mGrabOffset - startAlong(track, axis);
|
||||
return clampLength(thumbStart / travel, 0, 1) * mView.maximumOffset();
|
||||
}
|
||||
|
||||
void ScrollBar::flash(halnf durationMs) {
|
||||
mFlashMs = std::max(mFlashMs, durationMs);
|
||||
refreshReveal();
|
||||
}
|
||||
|
||||
void ScrollBar::onEvent(Event& event) {
|
||||
const Axis axis = mView.scrollAxis();
|
||||
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
{
|
||||
if (event.button() != PointerButton::Primary) return;
|
||||
const RectF thumb = thumbRect();
|
||||
const halnf pointer = along(event.position(), axis);
|
||||
const bool onThumb =
|
||||
pointer >= startAlong(thumb, axis) && pointer <= startAlong(thumb, axis) + lengthAlong(thumb, axis);
|
||||
|
||||
mGrabOffset = onThumb ? pointer - startAlong(thumb, axis) : lengthAlong(thumb, axis) / 2;
|
||||
mDragging = true;
|
||||
ui()->capturePointer(*this);
|
||||
if (!onThumb) mView.scrollTo(offsetForThumbAt(pointer));
|
||||
break;
|
||||
}
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (!mDragging) return;
|
||||
mView.jumpTo(offsetForThumbAt(along(event.position(), axis)));
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (!mDragging || event.button() != PointerButton::Primary) return;
|
||||
mDragging = false;
|
||||
ui()->releasePointer(*this);
|
||||
break;
|
||||
|
||||
default: return;
|
||||
}
|
||||
|
||||
event.accept();
|
||||
refreshReveal();
|
||||
}
|
||||
|
||||
void ScrollBar::onHoverChanged() { refreshReveal(); }
|
||||
|
||||
void ScrollBar::refreshReveal() {
|
||||
mReveal.animateTo(isHovered() || mDragging || mFlashMs > 0 ? 1.0f : 0.0f);
|
||||
startTicking();
|
||||
}
|
||||
|
||||
void ScrollBar::onTick(halnf deltaMs) {
|
||||
if (mFlashMs > 0) {
|
||||
mFlashMs -= deltaMs;
|
||||
if (mFlashMs <= 0) refreshReveal();
|
||||
}
|
||||
|
||||
if (!mReveal.advance(deltaMs) && mFlashMs <= 0) stopTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void ScrollBar::paint(Painter& painter) {
|
||||
const halnf reveal = clampLength(mReveal.value(), 0, 1);
|
||||
if (reveal <= 0.01f) return;
|
||||
|
||||
const Axis cross = crossAxis(mView.scrollAxis());
|
||||
const auto growFromOuterEdge = [cross, reveal](RectF rect) {
|
||||
const halnf full = lengthAlong(rect, cross);
|
||||
const halnf outerEdge = startAlong(rect, cross) + full;
|
||||
setSpanAlong(rect, cross, outerEdge - full * reveal, full * reveal);
|
||||
return rect;
|
||||
};
|
||||
|
||||
const RGBA& muted = theme().palette.mutedText;
|
||||
const bool active = mDragging || isHovered();
|
||||
|
||||
const RectF track = growFromOuterEdge(trackRect());
|
||||
painter.fillRect(track, { muted.r, muted.g, muted.b, 0.12f }, lengthAlong(track, cross) / 2);
|
||||
|
||||
const RectF thumb = growFromOuterEdge(thumbRect());
|
||||
painter.fillRect(thumb, { muted.r, muted.g, muted.b, active ? 0.65f : 0.45f }, lengthAlong(thumb, cross) / 2);
|
||||
}
|
||||
|
||||
ScrollView::ScrollView(Axis scrollAxis) :
|
||||
mScrollAxis(scrollAxis),
|
||||
mOffset(0, Motion::quick()) {
|
||||
setDebugName("scroll view");
|
||||
setClipsChildren(true);
|
||||
setLayout(std::make_unique<ScrollLayout>(*this));
|
||||
addChild(mScrollBar);
|
||||
}
|
||||
|
||||
void ScrollView::setContent(Widget* content) {
|
||||
if (mContent) removeChild(*mContent);
|
||||
|
||||
mContent = content;
|
||||
if (mContent) {
|
||||
insertChild(0, *mContent);
|
||||
mContent->setGeometryAnimated(false);
|
||||
}
|
||||
scrollTo(0);
|
||||
}
|
||||
|
||||
void ScrollView::scrollTo(halnf offset) {
|
||||
mOffset.animateTo(clampLength(offset, 0, maximumOffset()));
|
||||
if (isScrollable() && mShowsScrollBar) mScrollBar.flash(kShowAfterScrollMs);
|
||||
startTicking();
|
||||
}
|
||||
|
||||
void ScrollView::jumpTo(halnf offset) {
|
||||
mOffset.jumpTo(clampLength(offset, 0, maximumOffset()));
|
||||
applyOffset();
|
||||
}
|
||||
|
||||
void ScrollView::scrollBy(halnf delta) { scrollTo(mOffset.target() + delta); }
|
||||
|
||||
halnf ScrollView::maximumOffset() const {
|
||||
const halnf overflow = mContentLength - viewportLength();
|
||||
return overflow > 0 ? overflow : 0;
|
||||
}
|
||||
|
||||
Extent ScrollView::measureContent(Axis, halnf) const { return {}; }
|
||||
|
||||
void ScrollView::onEvent(Event& event) {
|
||||
if (event.type() == Event::Type::Wheel) {
|
||||
if (!isScrollable() || event.wheelAmount() == 0) return;
|
||||
scrollBy(-event.wheelAmount() * mWheelStep);
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
trackDrag(event);
|
||||
}
|
||||
|
||||
void ScrollView::filterDescendantEvent(Event& event) { trackDrag(event); }
|
||||
|
||||
bool ScrollView::startsDrag(const Event& event) const {
|
||||
return event.type() == Event::Type::PointerDown && event.button() == PointerButton::Primary && ui() &&
|
||||
ui()->isTouchMode() && isScrollable() && !mScrollBar.isDragging();
|
||||
}
|
||||
|
||||
void ScrollView::trackDrag(Event& event) {
|
||||
if (startsDrag(event)) {
|
||||
const bool catching = mFling.isActive() || mStartStretch.isActive() || mEndStretch.isActive();
|
||||
mFling.stop();
|
||||
mFlingVelocity = 0;
|
||||
mStartStretch.pull(0);
|
||||
mEndStretch.pull(0);
|
||||
mDragPending = true;
|
||||
mDragging = catching;
|
||||
mDragStart = event.windowPosition();
|
||||
mDragStartOffset = mOffset.value();
|
||||
mLastDragAlong = 0;
|
||||
mDragSamples.clear();
|
||||
rememberDragSample(event.sample().timeMs);
|
||||
if (catching) {
|
||||
ui()->capturePointer(*this);
|
||||
event.accept();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!mDragPending) return;
|
||||
|
||||
if (event.type() == Event::Type::PointerMove) {
|
||||
const Widget* holder = ui()->pointerCapture();
|
||||
if (!mDragging && holder && holder != this) {
|
||||
mDragPending = false;
|
||||
return;
|
||||
}
|
||||
const halnf moved = along(event.windowPosition() - mDragStart, mScrollAxis);
|
||||
if (!mDragging && std::abs(moved) < kDragThreshold) return;
|
||||
if (!mDragging) {
|
||||
mDragging = true;
|
||||
ui()->capturePointer(*this);
|
||||
}
|
||||
dragBy(mLastDragAlong - moved);
|
||||
mLastDragAlong = moved;
|
||||
rememberDragSample(event.sample().timeMs);
|
||||
event.accept();
|
||||
} else if (event.type() == Event::Type::PointerUp && event.button() == PointerButton::Primary) {
|
||||
mDragPending = false;
|
||||
if (!mDragging) return;
|
||||
mDragging = false;
|
||||
ui()->releasePointer(*this);
|
||||
event.accept();
|
||||
const halnf velocity = averageDragVelocity(event.sample().timeMs);
|
||||
mStartStretch.release();
|
||||
mEndStretch.release();
|
||||
if (std::abs(velocity) >= kFlingMinimumSpeed) {
|
||||
mFling.start(mOffset.value(), velocity * 1000.0f, 0, maximumOffset());
|
||||
mFlingVelocity = velocity;
|
||||
}
|
||||
startTicking();
|
||||
}
|
||||
}
|
||||
|
||||
void ScrollView::rememberDragSample(uint4 timeMs) {
|
||||
if (!mDragSamples.empty() && mDragSamples.back().timeMs == timeMs) {
|
||||
mDragSamples.back().offset = mOffset.value();
|
||||
return;
|
||||
}
|
||||
mDragSamples.push_back({ timeMs, mOffset.value() });
|
||||
while (mDragSamples.size() > 2 && timeMs - mDragSamples[1].timeMs > kFlingRecentMs)
|
||||
mDragSamples.erase(mDragSamples.begin());
|
||||
}
|
||||
|
||||
void ScrollView::dragBy(halnf delta) {
|
||||
halnf remaining = delta;
|
||||
if (remaining < 0 && mEndStretch.distance() > 0) remaining -= mEndStretch.pull(remaining);
|
||||
if (remaining > 0 && mStartStretch.distance() > 0) remaining += mStartStretch.pull(-remaining);
|
||||
const halnf target = mOffset.value() + remaining;
|
||||
const halnf limit = maximumOffset();
|
||||
if (target < 0) {
|
||||
mOffset.jumpTo(0);
|
||||
mStartStretch.setLength(viewportLength());
|
||||
mStartStretch.pull(-target);
|
||||
} else if (target > limit) {
|
||||
mOffset.jumpTo(limit);
|
||||
mEndStretch.setLength(viewportLength());
|
||||
mEndStretch.pull(target - limit);
|
||||
} else {
|
||||
mOffset.jumpTo(target);
|
||||
}
|
||||
applyOffset();
|
||||
}
|
||||
|
||||
halnf ScrollView::stretchOffset() const { return mStartStretch.visualOffset() - mEndStretch.visualOffset(); }
|
||||
|
||||
halnf ScrollView::averageDragVelocity(uint4 nowMs) const { return averageVelocity(mDragSamples, nowMs); }
|
||||
|
||||
halnf ScrollView::averageVelocity(const std::vector<DragSample>& samples, uint4 nowMs) {
|
||||
if (samples.size() < 2) return 0;
|
||||
const DragSample& newest = samples.back();
|
||||
if (nowMs - newest.timeMs >= kFlingRecentMs) return 0;
|
||||
size_t first = samples.size() - 1;
|
||||
while (first > 0 && newest.timeMs - samples[first - 1].timeMs <= kFlingRecentMs)
|
||||
first--;
|
||||
if (first == samples.size() - 1) first--;
|
||||
const DragSample& oldest = samples[first];
|
||||
if (newest.timeMs <= oldest.timeMs) return 0;
|
||||
return (newest.offset - oldest.offset) / (halnf) (newest.timeMs - oldest.timeMs);
|
||||
}
|
||||
|
||||
void ScrollView::onTick(halnf deltaMs) {
|
||||
bool moving = false;
|
||||
if (mFling.isActive()) {
|
||||
moving = mFling.advance(deltaMs);
|
||||
mOffset.jumpTo(mFling.position());
|
||||
mFlingVelocity = mFling.velocity() / 1000.0f;
|
||||
if (!moving) {
|
||||
const halnf impact = mFling.velocity();
|
||||
mFlingVelocity = 0;
|
||||
if (mFling.hitEdge() && impact != 0) {
|
||||
StretchEffect& edge = impact < 0 ? mStartStretch : mEndStretch;
|
||||
edge.setLength(viewportLength());
|
||||
edge.absorb(impact);
|
||||
}
|
||||
}
|
||||
moving = true;
|
||||
} else if (mStartStretch.isActive() || mEndStretch.isActive()) {
|
||||
const bool start = mStartStretch.advance(deltaMs);
|
||||
const bool end = mEndStretch.advance(deltaMs);
|
||||
moving = start || end;
|
||||
} else {
|
||||
moving = mOffset.advance(deltaMs);
|
||||
}
|
||||
applyOffset();
|
||||
if (!moving) stopTicking();
|
||||
}
|
||||
|
||||
halnf ScrollView::viewportLength() const { return lengthAlong(localRect(), mScrollAxis); }
|
||||
|
||||
void ScrollView::keepOffsetInRange() {
|
||||
const halnf limit = maximumOffset();
|
||||
if (mOffset.target() > limit) mOffset.animateTo(limit);
|
||||
if (mOffset.value() > limit) mOffset.jumpTo(limit);
|
||||
}
|
||||
|
||||
void ScrollView::applyOffset() {
|
||||
const halnf current = mOffset.value();
|
||||
const bool moved = current != mReportedOffset;
|
||||
mReportedOffset = current;
|
||||
if (moved && mOnScrolled) mOnScrolled(current);
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
109
Widgets/src/components/controls/private/Control.cpp
Normal file
109
Widgets/src/components/controls/private/Control.cpp
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
#include "Control.hpp"
|
||||
|
||||
#include "Event.hpp"
|
||||
#include "Theme.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
Control::Control() :
|
||||
mBackground(Theme::standard().palette.control) {}
|
||||
|
||||
void Control::onAttached() {
|
||||
mBackground.jumpTo(backgroundTarget());
|
||||
for (AnimatedFloat* value : mRunningFloats)
|
||||
value->finish();
|
||||
mRunningFloats.clear();
|
||||
stopTicking();
|
||||
}
|
||||
|
||||
void Control::activate() {
|
||||
if (!isEffectivelyEnabled()) return;
|
||||
|
||||
onActivated();
|
||||
if (mOnActivate) mOnActivate();
|
||||
}
|
||||
|
||||
void Control::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
case Event::Type::PointerUp:
|
||||
if (event.button() != PointerButton::Primary) return;
|
||||
refreshVisualState();
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::Click:
|
||||
if (mOnClicked && isEffectivelyEnabled()) mOnClicked(event);
|
||||
else activate();
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::LongPress:
|
||||
if (!mOnLongPress || !isEffectivelyEnabled()) return;
|
||||
mOnLongPress();
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::KeyDown:
|
||||
if (event.keyCode() != Key::Enter && event.keyCode() != Key::Space) return;
|
||||
activate();
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void Control::onTick(halnf deltaMs) {
|
||||
bool animating = mBackground.advance(deltaMs);
|
||||
|
||||
for (AnimatedFloat* value : mRunningFloats)
|
||||
animating = value->advance(deltaMs) || animating;
|
||||
mRunningFloats.erase(
|
||||
std::remove_if(
|
||||
mRunningFloats.begin(), mRunningFloats.end(), [](AnimatedFloat* value) { return !value->isAnimating(); }
|
||||
),
|
||||
mRunningFloats.end()
|
||||
);
|
||||
|
||||
if (!animating) stopTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void Control::onHoverChanged() { refreshVisualState(); }
|
||||
|
||||
void Control::onThemeChanged() { refreshVisualState(); }
|
||||
|
||||
void Control::onPressedChanged() { refreshVisualState(); }
|
||||
|
||||
void Control::onOwnedPopupChanged() { refreshVisualState(); }
|
||||
|
||||
RGBA Control::backgroundTarget() const {
|
||||
const Palette& palette = theme().palette;
|
||||
|
||||
if (!isEffectivelyEnabled()) return Palette::mix(palette.control, palette.window, 0.5f);
|
||||
if (isPressed()) return palette.controlPressed;
|
||||
if (isHovered()) return palette.controlHovered;
|
||||
return palette.control;
|
||||
}
|
||||
|
||||
void Control::refreshVisualState() {
|
||||
mBackground.animateTo(backgroundTarget());
|
||||
runAnimations();
|
||||
}
|
||||
|
||||
void Control::animate(AnimatedFloat& value, halnf target) {
|
||||
value.animateTo(target);
|
||||
if (std::find(mRunningFloats.begin(), mRunningFloats.end(), &value) == mRunningFloats.end()) {
|
||||
mRunningFloats.push_back(&value);
|
||||
}
|
||||
runAnimations();
|
||||
}
|
||||
|
||||
void Control::runAnimations() {
|
||||
startTicking();
|
||||
requestPaint();
|
||||
}
|
||||
247
Widgets/src/components/controls/private/Label.cpp
Normal file
247
Widgets/src/components/controls/private/Label.cpp
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
#include "Label.hpp"
|
||||
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
std::vector<std::string> splitLines(const std::string& text) {
|
||||
std::vector<std::string> lines;
|
||||
size_t start = 0;
|
||||
while (true) {
|
||||
const size_t end = text.find('\n', start);
|
||||
lines.push_back(text.substr(start, end == std::string::npos ? std::string::npos : end - start));
|
||||
if (end == std::string::npos) return lines;
|
||||
start = end + 1;
|
||||
}
|
||||
}
|
||||
|
||||
bool isBlank(const std::string& line) { return line.find_first_not_of(" \t") == std::string::npos; }
|
||||
|
||||
bool isContinuationByte(char byte) { return (static_cast<unsigned char>(byte) & 0xC0) == 0x80; }
|
||||
|
||||
void removeLastCharacter(std::string& text) {
|
||||
while (!text.empty() && isContinuationByte(text.back()))
|
||||
text.pop_back();
|
||||
if (!text.empty()) text.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::string> tp::ui::wrapText(
|
||||
const std::string& text,
|
||||
halnf width,
|
||||
const std::function<halnf(const std::string&)>& measure,
|
||||
std::optional<size_t> maximumLines
|
||||
) {
|
||||
std::vector<std::string> lines;
|
||||
const auto isFull = [&] { return maximumLines && lines.size() > *maximumLines; };
|
||||
|
||||
for (const std::string& paragraph : splitLines(text)) {
|
||||
std::string line;
|
||||
size_t position = 0;
|
||||
while (position <= paragraph.size() && !isFull()) {
|
||||
const size_t space = paragraph.find(' ', position);
|
||||
const size_t end = space == std::string::npos ? paragraph.size() : space;
|
||||
std::string word = paragraph.substr(position, end - position);
|
||||
position = end + 1;
|
||||
const std::string candidate = line.empty() ? word : line + " " + word;
|
||||
const halnf candidateWidth = measure(candidate);
|
||||
if (line.empty() || candidateWidth <= width) {
|
||||
line = candidate;
|
||||
} else {
|
||||
lines.push_back(line);
|
||||
line = word;
|
||||
}
|
||||
while (line.size() > 1 && (line == candidate ? candidateWidth : measure(line)) > width && !isFull()) {
|
||||
size_t cut = line.size() - 1;
|
||||
while (cut > 1 && measure(line.substr(0, cut)) > width)
|
||||
cut--;
|
||||
lines.push_back(line.substr(0, cut));
|
||||
line = line.substr(cut);
|
||||
}
|
||||
if (space == std::string::npos) break;
|
||||
}
|
||||
if (isFull()) return lines;
|
||||
lines.push_back(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
Label::Label(std::string text) :
|
||||
mText(std::move(text)),
|
||||
mLines(splitLines(mText)) {
|
||||
setDebugName("label");
|
||||
}
|
||||
|
||||
void Label::setText(std::string text) {
|
||||
if (text == mText) return;
|
||||
|
||||
mText = std::move(text);
|
||||
mLines = splitLines(mText);
|
||||
invalidateLines();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void Label::setAlignment(TextAlign alignment) {
|
||||
mAlignment = alignment;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void Label::setFontSize(halnf fontSize) {
|
||||
mFontSize = fontSize;
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void Label::setWrap(bool wrap) {
|
||||
if (wrap == mWrap) return;
|
||||
mWrap = wrap;
|
||||
invalidateLines();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void Label::setMaximumLines(std::optional<size_t> lines) {
|
||||
if (lines == mMaximumLines) return;
|
||||
mMaximumLines = lines;
|
||||
invalidateLines();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void Label::setElidesToHeight(bool elides) {
|
||||
if (elides == mElidesToHeight) return;
|
||||
mElidesToHeight = elides;
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void Label::invalidateLines() {
|
||||
mLaidOut.key.reset();
|
||||
mWidestLine.key.reset();
|
||||
}
|
||||
|
||||
Label::TextLayoutKey Label::layoutKey(halnf width) const {
|
||||
const TextMetrics* metrics = ui() ? &ui()->textMetrics() : &Ui::fallbackTextMetrics();
|
||||
return { width, effectiveFontSize(), metrics };
|
||||
}
|
||||
|
||||
const std::vector<std::string>& Label::linesFor(halnf width) const {
|
||||
const TextLayoutKey key = layoutKey(width);
|
||||
if (mLaidOut.key != key) {
|
||||
mLaidOut.lines = layOutLines(width);
|
||||
mLaidOut.key = key;
|
||||
}
|
||||
return mLaidOut.lines;
|
||||
}
|
||||
|
||||
std::vector<std::string> Label::layOutLines(halnf width) const {
|
||||
if (!mWrap || width >= kUnbounded || width <= 0) return limitedLines(mLines, width);
|
||||
|
||||
const halnf fontSize = effectiveFontSize();
|
||||
const auto measure = [this, fontSize](const std::string& line) { return textWidth(line, fontSize); };
|
||||
return limitedLines(wrapText(mText, width, measure, mMaximumLines), width);
|
||||
}
|
||||
|
||||
std::vector<std::string> Label::limitedLines(std::vector<std::string> lines, halnf width) const {
|
||||
if (!mMaximumLines || lines.size() <= *mMaximumLines) return lines;
|
||||
return elidedLines(std::move(lines), *mMaximumLines, width);
|
||||
}
|
||||
|
||||
std::vector<std::string> Label::elidedLines(std::vector<std::string> lines, size_t count, halnf width) const {
|
||||
lines.resize(count);
|
||||
while (lines.size() > 1 && isBlank(lines.back()))
|
||||
lines.pop_back();
|
||||
if (lines.empty()) return lines;
|
||||
|
||||
const std::string ellipsis = "\u2026";
|
||||
std::string& last = lines.back();
|
||||
while (!last.empty() && width < kUnbounded && textWidth(last + ellipsis, effectiveFontSize()) > width)
|
||||
removeLastCharacter(last);
|
||||
while (!last.empty() && last.back() == ' ')
|
||||
last.pop_back();
|
||||
last += ellipsis;
|
||||
return lines;
|
||||
}
|
||||
|
||||
size_t Label::linesFittingHeight() const {
|
||||
const halnf height = lineHeight();
|
||||
if (height <= 0) return 1;
|
||||
return std::max<size_t>(1, size_t(std::floor(size().y / height + 0.01f)));
|
||||
}
|
||||
|
||||
std::vector<std::string> Label::visibleLines() const {
|
||||
const std::vector<std::string>& lines = linesFor(size().x);
|
||||
if (!mElidesToHeight || lines.size() <= linesFittingHeight()) return lines;
|
||||
return elidedLines(lines, linesFittingHeight(), size().x);
|
||||
}
|
||||
|
||||
void Label::setTone(Tone tone) {
|
||||
mTone = tone;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void Label::setReservedText(std::string text) {
|
||||
if (mReservedText == text) return;
|
||||
mReservedText = std::move(text);
|
||||
invalidateLines();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
Extent Label::measureContent(Axis axis, halnf crossLength) const {
|
||||
if (axis == Axis::Vertical) {
|
||||
const halnf natural = lineHeight() * (halnf) linesFor(crossLength).size();
|
||||
if (mElidesToHeight) return Extent::shrinkable(natural, std::min(natural, lineHeight()));
|
||||
return Extent::natural(natural);
|
||||
}
|
||||
|
||||
const halnf widest = widestLine();
|
||||
if (mWrap) return Extent::shrinkable(widest, std::min(widest, effectiveFontSize() * 4));
|
||||
return Extent::natural(widest);
|
||||
}
|
||||
|
||||
halnf Label::widestLine() const {
|
||||
const TextLayoutKey key = layoutKey(kUnbounded);
|
||||
if (mWidestLine.key == key) return mWidestLine.width;
|
||||
|
||||
halnf widest = mReservedText.empty() ? 0 : textWidth(mReservedText, effectiveFontSize());
|
||||
for (const std::string& line : mLines)
|
||||
widest = std::max(widest, textWidth(line, effectiveFontSize()));
|
||||
|
||||
mWidestLine = { key, widest };
|
||||
return widest;
|
||||
}
|
||||
|
||||
void Label::paint(Painter& painter) {
|
||||
const Palette& palette = theme().palette;
|
||||
|
||||
RGBA color = palette.text;
|
||||
if (mTone == Tone::Muted) color = palette.mutedText;
|
||||
if (mTone == Tone::Warning) color = palette.warningText;
|
||||
if (!isEffectivelyEnabled()) color = palette.disabledText;
|
||||
|
||||
const std::vector<std::string>& laidOut = linesFor(size().x);
|
||||
if (mElidesToHeight && laidOut.size() > linesFittingHeight()) paintLines(painter, visibleLines(), color);
|
||||
else paintLines(painter, laidOut, color);
|
||||
}
|
||||
|
||||
void Label::paintLines(Painter& painter, const std::vector<std::string>& lines, const RGBA& color) const {
|
||||
if (lines.size() == 1) {
|
||||
painter.text(lines.front(), localRect(), effectiveFontSize(), mAlignment, color);
|
||||
return;
|
||||
}
|
||||
|
||||
const halnf height = lineHeight();
|
||||
for (size_t index = 0; index < lines.size(); index++) {
|
||||
const RectF line = makeRect(0, height * (halnf) index, size().x, height);
|
||||
painter.text(lines[index], line, effectiveFontSize(), mAlignment, color);
|
||||
}
|
||||
}
|
||||
|
||||
halnf Label::lineHeight() const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
return metrics.lineHeight * effectiveFontSize() / metrics.fontSize;
|
||||
}
|
||||
|
||||
halnf Label::effectiveFontSize() const { return mFontSize > 0 ? mFontSize : theme().metrics.fontSize; }
|
||||
49
Widgets/src/components/controls/private/Notice.cpp
Normal file
49
Widgets/src/components/controls/private/Notice.cpp
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
#include "Notice.hpp"
|
||||
|
||||
#include "Ui.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
Notice::Notice() { setDebugName("notice"); }
|
||||
|
||||
Notice::~Notice() { hide(); }
|
||||
|
||||
void Notice::show(Widget& anchor, std::string text, halnf durationMs) {
|
||||
Ui* ui = anchor.ui();
|
||||
if (!ui) return;
|
||||
|
||||
setText(std::move(text));
|
||||
mRemainingMs = durationMs;
|
||||
|
||||
if (!ui->isPopupOpen(*this)) {
|
||||
const RectF area = anchor.windowRect();
|
||||
PopupRequest request;
|
||||
request.content = this;
|
||||
request.anchor = makeRect(area.pos.x, area.pos.y + area.size.y / 3, area.size.x, 0);
|
||||
request.options.placement = Placement::Over;
|
||||
request.options.align = PlacementAlign::Center;
|
||||
request.options.interactive = false;
|
||||
request.options.closeOnOutsidePress = false;
|
||||
request.options.closeOnEscape = false;
|
||||
ui->openPopup(request);
|
||||
}
|
||||
|
||||
startTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void Notice::hide() {
|
||||
mRemainingMs = 0;
|
||||
if (ui() && ui()->isPopupOpen(*this)) ui()->closePopup(*this);
|
||||
}
|
||||
|
||||
bool Notice::isShown() const { return ui() && ui()->isPopupOpen(*this); }
|
||||
|
||||
void Notice::onTick(halnf deltaMs) {
|
||||
mRemainingMs -= deltaMs;
|
||||
if (mRemainingMs > 0) return;
|
||||
|
||||
stopTicking();
|
||||
hide();
|
||||
}
|
||||
84
Widgets/src/components/controls/private/ResizeHandle.cpp
Normal file
84
Widgets/src/components/controls/private/ResizeHandle.cpp
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#include "ResizeHandle.hpp"
|
||||
|
||||
#include "Event.hpp"
|
||||
#include "Painter.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kLineThickness = 2;
|
||||
constexpr halnf kLineInset = 8;
|
||||
}
|
||||
|
||||
ResizeHandle::ResizeHandle(Axis dragAxis) :
|
||||
mDragAxis(dragAxis) {
|
||||
setDebugName("resize handle");
|
||||
setGeometryAnimated(false);
|
||||
}
|
||||
|
||||
RectF ResizeHandle::lineRect() const {
|
||||
const Axis lengthAxis = crossAxis(mDragAxis);
|
||||
const halnf available = lengthAlong(localRect(), lengthAxis);
|
||||
const halnf inset = available > kLineInset * 4 ? kLineInset : 0;
|
||||
|
||||
RectF line;
|
||||
setSpanAlong(line, mDragAxis, (lengthAlong(localRect(), mDragAxis) - kLineThickness) / 2, kLineThickness);
|
||||
setSpanAlong(line, lengthAxis, inset, available - inset * 2);
|
||||
return line;
|
||||
}
|
||||
|
||||
void ResizeHandle::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
if (event.button() != PointerButton::Primary) return;
|
||||
mDragging = true;
|
||||
mGrabOffset = along(event.position(), mDragAxis) - lengthAlong(localRect(), mDragAxis) / 2;
|
||||
ui()->capturePointer(*this);
|
||||
if (mOnStarted) mOnStarted();
|
||||
break;
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (!mDragging) return;
|
||||
if (mOnDragged) mOnDragged(along(event.windowPosition(), mDragAxis) - mGrabOffset);
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (!mDragging || event.button() != PointerButton::Primary) return;
|
||||
mDragging = false;
|
||||
ui()->releasePointer(*this);
|
||||
if (mOnFinished) mOnFinished();
|
||||
break;
|
||||
|
||||
default: return;
|
||||
}
|
||||
|
||||
event.accept();
|
||||
refreshReveal();
|
||||
}
|
||||
|
||||
void ResizeHandle::onHoverChanged() { refreshReveal(); }
|
||||
|
||||
void ResizeHandle::refreshReveal() {
|
||||
const halnf target = isHovered() || mDragging ? 1.0f : 0.0f;
|
||||
if (mReveal.target() == target && !mReveal.isAnimating()) return;
|
||||
|
||||
mReveal.animateTo(target);
|
||||
startTicking();
|
||||
}
|
||||
|
||||
void ResizeHandle::onTick(halnf deltaMs) {
|
||||
if (!mReveal.advance(deltaMs)) stopTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void ResizeHandle::paint(Painter& painter) {
|
||||
const halnf reveal = mReveal.value();
|
||||
if (reveal <= 0.01f) return;
|
||||
|
||||
const Palette& palette = theme().palette;
|
||||
const RGBA base = mDragging ? palette.text : palette.mutedText;
|
||||
painter.fillRect(lineRect(), { base.r, base.g, base.b, base.a * reveal }, kLineThickness / 2);
|
||||
}
|
||||
326
Widgets/src/components/controls/private/Slider.cpp
Normal file
326
Widgets/src/components/controls/private/Slider.cpp
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
#include "Slider.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Event.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
halnf clampValue(halnf value, halnf minimum, halnf maximum) {
|
||||
if (value < minimum) return minimum;
|
||||
if (value > maximum) return maximum;
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string formatTwoDecimals(halnf value) {
|
||||
char buffer[32];
|
||||
std::snprintf(buffer, sizeof(buffer), "%.2f", value);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
Slider::Slider(halnf minimum, halnf maximum, halnf value) :
|
||||
mMinimum(minimum),
|
||||
mMaximum(maximum > minimum ? maximum : minimum),
|
||||
mValue(clampValue(value, mMinimum, mMaximum)) {
|
||||
setDebugName("slider");
|
||||
}
|
||||
|
||||
void Slider::setRange(halnf minimum, halnf maximum) {
|
||||
mMinimum = minimum;
|
||||
mMaximum = maximum > minimum ? maximum : minimum;
|
||||
setValue(mValue);
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void Slider::setValue(halnf value) {
|
||||
const halnf next = snapped(clampValue(value, mMinimum, mMaximum));
|
||||
if (next == mValue) return;
|
||||
|
||||
mValue = next;
|
||||
requestPaint();
|
||||
if (mOnValueShown) mOnValueShown();
|
||||
}
|
||||
|
||||
void Slider::changeValue(halnf value) {
|
||||
const halnf previous = mValue;
|
||||
setValue(value);
|
||||
if (mValue != previous && mOnChanged) mOnChanged(mValue);
|
||||
}
|
||||
|
||||
std::vector<halnf> tp::ui::groupTickMarks(std::vector<halnf> fractions, halnf minimumGap) {
|
||||
std::sort(fractions.begin(), fractions.end());
|
||||
|
||||
std::vector<halnf> grouped;
|
||||
for (halnf fraction : fractions) {
|
||||
const halnf clamped = clampValue(fraction, 0, 1);
|
||||
if (!grouped.empty() && clamped - grouped.back() < minimumGap) continue;
|
||||
grouped.push_back(clamped);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
void Slider::setTickMarks(std::vector<halnf> fractions) {
|
||||
mTickMarks = std::move(fractions);
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
std::vector<halnf> Slider::visibleTickMarks() const {
|
||||
const RectF track = trackRect();
|
||||
const halnf length = isVertical() ? track.size.y : track.size.x;
|
||||
if (length <= 0) return {};
|
||||
return groupTickMarks(mTickMarks, theme().metrics.sliderTickSpacing / length);
|
||||
}
|
||||
|
||||
void Slider::paintTickMarks(Painter& painter, const RectF& track) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
const halnf half = metrics.sliderTickLength / 2;
|
||||
const RGBA color = theme().palette.mutedText;
|
||||
|
||||
for (halnf fraction : visibleTickMarks()) {
|
||||
const Vec2F center = pointOnTrack(track, fraction);
|
||||
const Vec2F across = isVertical() ? Vec2F(half, 0.0f) : Vec2F(0.0f, half);
|
||||
painter.line(center - across, center + across, color, metrics.sliderTickThickness);
|
||||
}
|
||||
}
|
||||
|
||||
void Slider::setStep(halnf step) {
|
||||
mStep = step > 0 ? step : 0;
|
||||
setValue(mValue);
|
||||
}
|
||||
|
||||
void Slider::setScale(Scale scale) {
|
||||
mScale = scale;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void Slider::setOrientation(Axis orientation) {
|
||||
if (orientation == mOrientation) return;
|
||||
mOrientation = orientation;
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
bool Slider::isLogarithmic() const { return mScale == Scale::Logarithmic && mMinimum > 0 && mMaximum > mMinimum; }
|
||||
|
||||
halnf Slider::fractionForValue(halnf value) const {
|
||||
const halnf span = mMaximum - mMinimum;
|
||||
if (span <= 0) return 0;
|
||||
const halnf clamped = clampValue(value, mMinimum, mMaximum);
|
||||
if (!isLogarithmic()) return (clamped - mMinimum) / span;
|
||||
return std::log(clamped / mMinimum) / std::log(mMaximum / mMinimum);
|
||||
}
|
||||
|
||||
halnf Slider::valueForFraction(halnf fraction) const {
|
||||
const halnf amount = clampValue(fraction, 0, 1);
|
||||
if (!isLogarithmic()) return mMinimum + (mMaximum - mMinimum) * amount;
|
||||
return mMinimum * std::pow(mMaximum / mMinimum, amount);
|
||||
}
|
||||
|
||||
halnf Slider::fraction() const { return fractionForValue(mValue); }
|
||||
|
||||
Extent Slider::measureContent(Axis axis, halnf) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
if (axis == mOrientation) return Extent::shrinkable(metrics.sliderHandleSize * 8, metrics.sliderHandleSize * 2);
|
||||
return Extent::natural(metrics.sliderHandleSize + 4);
|
||||
}
|
||||
|
||||
void Slider::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
if (event.button() != PointerButton::Primary) return;
|
||||
mDragging = true;
|
||||
ui()->capturePointer(*this);
|
||||
if (mOnDragging) mOnDragging(true);
|
||||
changeValue(valueAt(event.position()));
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (!mDragging) return;
|
||||
changeValue(valueAt(event.position()));
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (!mDragging || event.button() != PointerButton::Primary) return;
|
||||
mDragging = false;
|
||||
ui()->releasePointer(*this);
|
||||
if (mOnDragging) mOnDragging(false);
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::KeyDown:
|
||||
if (event.keyCode() == Key::Left || event.keyCode() == Key::Down) stepBy(-1);
|
||||
else if (event.keyCode() == Key::Right || event.keyCode() == Key::Up) stepBy(1);
|
||||
else return;
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
default: Control::onEvent(event); return;
|
||||
}
|
||||
|
||||
refreshVisualState();
|
||||
}
|
||||
|
||||
void Slider::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const RectF track = trackRect();
|
||||
const halnf radius = std::min(track.size.x, track.size.y) / 2;
|
||||
|
||||
const std::vector<RGBA> stops = trackGradient();
|
||||
if (stops.size() >= 2) {
|
||||
const halnf length = isVertical() ? track.size.y : track.size.x;
|
||||
const halnf segment = length / (halnf) (stops.size() - 1);
|
||||
for (size_t index = 0; index + 1 < stops.size(); index++) {
|
||||
if (isVertical()) {
|
||||
const halnf bottom = track.pos.y + track.size.y - segment * (halnf) (index + 1);
|
||||
const RectF part = makeRect(track.pos.x, bottom, track.size.x, segment);
|
||||
painter.fillGradientRect(part, stops[index + 1], stops[index], false);
|
||||
} else {
|
||||
const RectF part = makeRect(track.pos.x + segment * (halnf) index, track.pos.y, segment, track.size.y);
|
||||
painter.fillGradientRect(part, stops[index], stops[index + 1], true);
|
||||
}
|
||||
}
|
||||
painter.strokeRect(track, style.palette.controlBorder, 0, 1);
|
||||
} else {
|
||||
painter.fillRect(track, style.palette.track, radius);
|
||||
const RectF filled =
|
||||
isVertical()
|
||||
? makeRect(track.pos.x, track.pos.y + track.size.y * (1 - fraction()), track.size.x, track.size.y * fraction())
|
||||
: makeRect(track.pos, { track.size.x * fraction(), track.size.y });
|
||||
painter.fillRect(filled, style.palette.sliderFill, radius);
|
||||
}
|
||||
paintTickMarks(painter, track);
|
||||
|
||||
const halnf reveal = mHandleReveal.value();
|
||||
if (reveal <= 0.01f) return;
|
||||
|
||||
const halnf handleRadius = style.metrics.sliderHandleSize / 2 * reveal;
|
||||
const Vec2F handleCenter = pointOnTrack(track, fraction());
|
||||
const RGBA ring = mDragging ? style.palette.mutedText : style.palette.controlBorder;
|
||||
|
||||
if (showsFocusRing()) painter.strokeCircle(handleCenter, handleRadius + 3, style.palette.focusRing, 2);
|
||||
painter.fillCircle(handleCenter + Vec2F(0.0f, 0.5f), handleRadius, style.palette.shadow);
|
||||
painter.fillCircle(handleCenter, handleRadius, style.palette.text);
|
||||
painter.strokeCircle(handleCenter, handleRadius, ring, 1.5f);
|
||||
}
|
||||
|
||||
bool Slider::showsHandle() const { return isHovered() || mDragging || showsFocusRing(); }
|
||||
|
||||
void Slider::refreshVisualState() {
|
||||
animate(mHandleReveal, showsHandle() ? 1.0f : 0.0f);
|
||||
Control::refreshVisualState();
|
||||
}
|
||||
|
||||
RectF Slider::trackRect() const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
const halnf inset = metrics.sliderHandleSize / 2;
|
||||
const halnf thickness = mTrackGradient ? metrics.sliderHandleSize : metrics.sliderTrackThickness;
|
||||
if (isVertical()) return makeRect((size().x - thickness) / 2, inset, thickness, size().y - inset * 2);
|
||||
return makeRect(inset, (size().y - thickness) / 2, size().x - inset * 2, thickness);
|
||||
}
|
||||
|
||||
Vec2F Slider::pointOnTrack(const RectF& track, halnf amount) const {
|
||||
if (isVertical()) return { size().x / 2, track.pos.y + track.size.y * (1 - amount) };
|
||||
return { track.pos.x + track.size.x * amount, size().y / 2 };
|
||||
}
|
||||
|
||||
Vec2F Slider::windowPointForValue(halnf value) const {
|
||||
const RectF track = trackRect();
|
||||
return windowPosition() + pointOnTrack(track, fractionForValue(value));
|
||||
}
|
||||
|
||||
halnf Slider::valueAt(Vec2F local) const {
|
||||
const RectF track = trackRect();
|
||||
if (isVertical()) {
|
||||
const halnf amount = track.size.y > 0 ? clampValue((local.y - track.pos.y) / track.size.y, 0, 1) : 0;
|
||||
return valueForFraction(1 - amount);
|
||||
}
|
||||
const halnf amount = track.size.x > 0 ? clampValue((local.x - track.pos.x) / track.size.x, 0, 1) : 0;
|
||||
return valueForFraction(amount);
|
||||
}
|
||||
|
||||
halnf Slider::snapped(halnf value) const {
|
||||
if (mStep <= 0) return value;
|
||||
return clampValue(mMinimum + std::round((value - mMinimum) / mStep) * mStep, mMinimum, mMaximum);
|
||||
}
|
||||
|
||||
void Slider::stepBy(halnf steps) {
|
||||
if (mStep <= 0 && isLogarithmic()) {
|
||||
changeValue(valueForFraction(fraction() + steps / 100));
|
||||
return;
|
||||
}
|
||||
const halnf step = mStep > 0 ? mStep : (mMaximum - mMinimum) / 100;
|
||||
changeValue(mValue + step * steps);
|
||||
}
|
||||
|
||||
LabeledSlider::LabeledSlider(std::string name, halnf minimum, halnf maximum, halnf value) :
|
||||
mName(std::move(name)),
|
||||
mSlider(minimum, maximum, value),
|
||||
mFormatter(formatTwoDecimals) {
|
||||
setDebugName("labeled slider");
|
||||
|
||||
emplaceLayout<ColumnLayout>(2);
|
||||
mHeader.emplaceLayout<RowLayout>(theme().metrics.spacing);
|
||||
|
||||
mName.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mValueText.setAlignment(TextAlign::End);
|
||||
mValueText.setTone(Label::Tone::Muted);
|
||||
|
||||
mHeader.addChild(mName);
|
||||
mHeader.addChild(mValueText);
|
||||
addChild(mHeader);
|
||||
addChild(mSlider);
|
||||
|
||||
mSlider.setOnValueShown([this]() { refreshValueText(); });
|
||||
mSlider.setOnChanged([this](halnf changed) {
|
||||
if (mOnChanged) mOnChanged(changed);
|
||||
});
|
||||
refreshValueText();
|
||||
}
|
||||
|
||||
void LabeledSlider::setValueFormatter(std::function<std::string(halnf)> formatter) {
|
||||
mFormatter = std::move(formatter);
|
||||
refreshValueText();
|
||||
}
|
||||
|
||||
void LabeledSlider::setOnChanged(std::function<void(halnf)> onChanged) { mOnChanged = std::move(onChanged); }
|
||||
|
||||
void LabeledSlider::refreshValueText() { mValueText.setText(mFormatter ? mFormatter(mSlider.value()) : std::string()); }
|
||||
|
||||
ProgressBar::ProgressBar() :
|
||||
mFraction(0, Motion::smooth()) {
|
||||
setDebugName("progress");
|
||||
}
|
||||
|
||||
void ProgressBar::setFraction(halnf fraction) {
|
||||
mFraction.animateTo(clampValue(fraction, 0, 1));
|
||||
startTicking();
|
||||
}
|
||||
|
||||
Extent ProgressBar::measureContent(Axis axis, halnf) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
if (axis == Axis::Horizontal) return Extent::shrinkable(metrics.sliderHandleSize * 8, metrics.sliderHandleSize);
|
||||
return Extent::natural(metrics.sliderTrackThickness * 1.5f);
|
||||
}
|
||||
|
||||
void ProgressBar::onTick(halnf deltaMs) {
|
||||
if (!mFraction.advance(deltaMs)) stopTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void ProgressBar::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const halnf radius = size().y / 2;
|
||||
|
||||
painter.fillRect(localRect(), style.palette.track, radius);
|
||||
painter.fillRect(makeRect({ 0, 0 }, { size().x * mFraction.value(), size().y }), style.palette.sliderFill, radius);
|
||||
}
|
||||
665
Widgets/src/components/controls/private/TextArea.cpp
Normal file
665
Widgets/src/components/controls/private/TextArea.cpp
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
#include "TextArea.hpp"
|
||||
|
||||
#include "Event.hpp"
|
||||
#include "Layout.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
#include "WindowHost.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kBlinkPeriodMs = 1060;
|
||||
constexpr halnf kCaretWidth = 1.5f;
|
||||
constexpr halnf kHandleThickness = 8;
|
||||
constexpr halnf kTextInset = 8;
|
||||
|
||||
bool isSpace(char character) { return character == ' '; }
|
||||
}
|
||||
|
||||
class TextArea::AreaLayout : public Layout {
|
||||
public:
|
||||
explicit AreaLayout(TextArea& area) :
|
||||
mArea(area) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext&, Axis axis, halnf) const override {
|
||||
if (axis == Axis::Vertical && mArea.mFillsHeight)
|
||||
return Extent::shrinkable(mArea.mMinimumHeight, mArea.mMinimumHeight);
|
||||
if (axis == Axis::Vertical) return Extent::rigid(mArea.mBoxHeight);
|
||||
return { 40, 200, kUnbounded, 0 };
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& contentArea) override {
|
||||
context.place(mArea.mScroll, contentArea);
|
||||
const halnf top = contentArea.pos.y + contentArea.size.y - kHandleThickness;
|
||||
const halnf handleThickness = mArea.mFillsHeight ? 0 : kHandleThickness;
|
||||
context.place(mArea.mHandle, makeRect(contentArea.pos.x, top, contentArea.size.x, handleThickness));
|
||||
mArea.viewportArranged(contentArea.size.y);
|
||||
}
|
||||
|
||||
private:
|
||||
TextArea& mArea;
|
||||
};
|
||||
|
||||
TextAreaEditor::TextAreaEditor() {
|
||||
setDebugName("text area editor");
|
||||
setCursor(CursorShape::Text);
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
}
|
||||
|
||||
void TextAreaEditor::setText(std::string text) {
|
||||
if (text == mBuffer.text()) return;
|
||||
|
||||
mBuffer.setText(std::move(text));
|
||||
invalidateAdvances();
|
||||
mLayoutDirty = true;
|
||||
mHighlightsDirty = true;
|
||||
mPreferredX = -1;
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void TextAreaEditor::setPlaceholder(std::string placeholder) {
|
||||
mPlaceholder = std::move(placeholder);
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void TextAreaEditor::setCaret(size_t position, bool extendSelection) {
|
||||
mBuffer.setCaret(position, extendSelection);
|
||||
mPreferredX = -1;
|
||||
caretMoved();
|
||||
}
|
||||
|
||||
void TextAreaEditor::selectAll() {
|
||||
mBuffer.selectAll();
|
||||
caretMoved();
|
||||
}
|
||||
|
||||
halnf TextAreaEditor::fontSize() const { return theme().metrics.fontSize * mFontScale; }
|
||||
|
||||
halnf TextAreaEditor::lineHeight() const { return std::round(theme().metrics.lineHeight * mFontScale); }
|
||||
|
||||
halnf TextAreaEditor::textTop() const { return kTextInset / 2; }
|
||||
|
||||
halnf TextAreaEditor::topOfLineContaining(size_t position) const {
|
||||
ensureLayout();
|
||||
return textTop() + halnf(lineOf(position)) * lineHeight();
|
||||
}
|
||||
|
||||
size_t TextAreaEditor::positionAtTop(halnf localY) const {
|
||||
ensureLayout();
|
||||
if (mLines.empty()) return 0;
|
||||
|
||||
const halnf row = std::max(0.0f, localY - textTop()) / lineHeight();
|
||||
const size_t index = std::min((size_t) row, mLines.size() - 1);
|
||||
return mLines[index].start;
|
||||
}
|
||||
|
||||
void TextAreaEditor::invalidateTextLayout() {
|
||||
mLayoutDirty = true;
|
||||
mPreferredX = -1;
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void TextAreaEditor::setFontScale(halnf scale) {
|
||||
if (scale == mFontScale) return;
|
||||
|
||||
mFontScale = scale;
|
||||
invalidateTextLayout();
|
||||
}
|
||||
|
||||
halnf TextAreaEditor::wrapWidth() const { return std::max(1.0f, size().x - kTextInset * 2); }
|
||||
|
||||
void TextAreaEditor::invalidateAdvances() { mAdvancesKey.reset(); }
|
||||
|
||||
void TextAreaEditor::ensureAdvances() const {
|
||||
const AdvancesKey key = { fontSize(), mFace, &textMetrics() };
|
||||
if (mAdvancesKey == key) return;
|
||||
|
||||
const std::string& text = mBuffer.text();
|
||||
mParagraphAdvances.assign(text.size() + 1, 0);
|
||||
mParagraphStarts.assign(text.size() + 1, 0);
|
||||
|
||||
size_t start = 0;
|
||||
while (start <= text.size()) {
|
||||
const size_t end = mBuffer.lineEnd(start);
|
||||
const std::vector<halnf> widths = prefixWidths(text.substr(start, end - start), textStyle());
|
||||
for (size_t offset = 0; offset <= end - start; offset++) {
|
||||
mParagraphAdvances[start + offset] = widths[offset];
|
||||
mParagraphStarts[start + offset] = start;
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
mAdvancesKey = key;
|
||||
}
|
||||
|
||||
halnf TextAreaEditor::textWidthOf(size_t from, size_t to) const {
|
||||
if (to <= from) return 0;
|
||||
|
||||
ensureAdvances();
|
||||
if (mParagraphStarts[from] != mParagraphStarts[to])
|
||||
return textWidth(mBuffer.text().substr(from, to - from), textStyle());
|
||||
return mParagraphAdvances[to] - mParagraphAdvances[from];
|
||||
}
|
||||
|
||||
void TextAreaEditor::setFontFace(FontFace face) {
|
||||
if (face == mFace) return;
|
||||
|
||||
mFace = face;
|
||||
invalidateTextLayout();
|
||||
}
|
||||
|
||||
void TextAreaEditor::setHighlighter(TextHighlighter highlighter) {
|
||||
mHighlighter = std::move(highlighter);
|
||||
mHighlightsDirty = true;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
const std::vector<HighlightSpan>& TextAreaEditor::highlights() const {
|
||||
if (!mHighlightsDirty) return mHighlights;
|
||||
|
||||
mHighlights = mHighlighter ? mHighlighter(mBuffer.text()) : std::vector<HighlightSpan>{};
|
||||
std::sort(mHighlights.begin(), mHighlights.end(), [](const HighlightSpan& a, const HighlightSpan& b) {
|
||||
return a.start < b.start;
|
||||
});
|
||||
mHighlightsDirty = false;
|
||||
return mHighlights;
|
||||
}
|
||||
|
||||
void TextAreaEditor::paintLineText(
|
||||
Painter& painter, const TextLayoutLine& line, const RectF& box, const RGBA& baseColor
|
||||
) const {
|
||||
const std::string& text = mBuffer.text();
|
||||
const std::vector<HighlightSpan>& spans = highlights();
|
||||
if (spans.empty() || !isEffectivelyEnabled()) {
|
||||
painter.styledText(text.substr(line.start, line.end - line.start), box, textStyle(), TextAlign::Start, baseColor);
|
||||
return;
|
||||
}
|
||||
|
||||
size_t position = line.start;
|
||||
auto span =
|
||||
std::lower_bound(spans.begin(), spans.end(), line.start, [](const HighlightSpan& candidate, size_t start) {
|
||||
return candidate.end <= start;
|
||||
});
|
||||
|
||||
const auto drawPiece = [&](size_t from, size_t to, const RGBA& color) {
|
||||
if (to <= from) return;
|
||||
const RectF piece = makeRect(box.pos.x + textWidthOf(line.start, from), box.pos.y, box.size.x, box.size.y);
|
||||
painter.styledText(text.substr(from, to - from), piece, textStyle(), TextAlign::Start, color);
|
||||
};
|
||||
|
||||
while (position < line.end) {
|
||||
while (span != spans.end() && span->end <= position)
|
||||
++span;
|
||||
if (span == spans.end() || span->start >= line.end) {
|
||||
drawPiece(position, line.end, baseColor);
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t start = std::max(span->start, position);
|
||||
drawPiece(position, start, baseColor);
|
||||
const size_t end = std::min(span->end, line.end);
|
||||
drawPiece(start, end, span->color);
|
||||
position = end;
|
||||
}
|
||||
}
|
||||
|
||||
void TextAreaEditor::relayoutText(halnf width) const {
|
||||
const std::string& text = mBuffer.text();
|
||||
mLines.clear();
|
||||
|
||||
size_t paragraphStart = 0;
|
||||
while (true) {
|
||||
const size_t paragraphEnd = mBuffer.lineEnd(paragraphStart);
|
||||
size_t lineStart = paragraphStart;
|
||||
|
||||
while (true) {
|
||||
size_t lineEnd = lineStart;
|
||||
size_t lastBreak = std::string::npos;
|
||||
|
||||
while (lineEnd < paragraphEnd) {
|
||||
const size_t next = mBuffer.nextBoundary(lineEnd);
|
||||
if (textWidthOf(lineStart, next) > width && lineEnd > lineStart) break;
|
||||
if (isSpace(text[lineEnd])) lastBreak = next;
|
||||
lineEnd = next;
|
||||
}
|
||||
|
||||
if (lineEnd < paragraphEnd && lastBreak != std::string::npos && lastBreak > lineStart) lineEnd = lastBreak;
|
||||
mLines.push_back({ lineStart, lineEnd });
|
||||
if (lineEnd >= paragraphEnd) break;
|
||||
lineStart = lineEnd;
|
||||
}
|
||||
|
||||
if (paragraphEnd >= text.size()) break;
|
||||
paragraphStart = paragraphEnd + 1;
|
||||
}
|
||||
|
||||
mLayoutWidth = width;
|
||||
mLayoutDirty = false;
|
||||
}
|
||||
|
||||
void TextAreaEditor::ensureLayout() const {
|
||||
if (mLayoutDirty || mLayoutWidth != wrapWidth()) relayoutText(wrapWidth());
|
||||
}
|
||||
|
||||
size_t TextAreaEditor::lineOf(size_t position) const {
|
||||
ensureLayout();
|
||||
for (size_t index = 0; index + 1 < mLines.size(); index++) {
|
||||
if (position < mLines[index + 1].start) return index;
|
||||
}
|
||||
return mLines.size() - 1;
|
||||
}
|
||||
|
||||
halnf TextAreaEditor::columnX(size_t position) const {
|
||||
const TextLayoutLine& line = mLines[lineOf(position)];
|
||||
return kTextInset + textWidthOf(line.start, std::min(position, line.end));
|
||||
}
|
||||
|
||||
RectF TextAreaEditor::caretRect() const {
|
||||
const size_t line = lineOf(mBuffer.caret());
|
||||
return makeRect(
|
||||
columnX(mBuffer.caret()) - kCaretWidth / 2, kTextInset / 2 + halnf(line) * lineHeight(), kCaretWidth, lineHeight()
|
||||
);
|
||||
}
|
||||
|
||||
size_t TextAreaEditor::positionInLine(const TextLayoutLine& line, halnf x) const {
|
||||
size_t best = line.start;
|
||||
halnf bestDistance = std::fabs(x - kTextInset);
|
||||
|
||||
for (size_t position = line.start; position < line.end;) {
|
||||
position = mBuffer.nextBoundary(position);
|
||||
const halnf distance = std::fabs(kTextInset + textWidthOf(line.start, position) - x);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = position;
|
||||
}
|
||||
}
|
||||
|
||||
const bool softWrapped = line.end < mBuffer.text().size() && mBuffer.text()[line.end] != '\n';
|
||||
if (softWrapped && best == line.end && line.end > line.start) best = mBuffer.previousBoundary(line.end);
|
||||
return best;
|
||||
}
|
||||
|
||||
size_t TextAreaEditor::positionAt(Vec2F localPoint) const {
|
||||
ensureLayout();
|
||||
const halnf row = std::floor((localPoint.y - kTextInset / 2) / lineHeight());
|
||||
const size_t index = (size_t) std::clamp(row, 0.0f, halnf(mLines.size() - 1));
|
||||
return positionInLine(mLines[index], localPoint.x);
|
||||
}
|
||||
|
||||
Extent TextAreaEditor::measureContent(Axis axis, halnf crossLength) const {
|
||||
if (axis == Axis::Horizontal) return { 40, 200, kUnbounded, 0 };
|
||||
|
||||
const halnf width = std::max(1.0f, crossLength - kTextInset * 2);
|
||||
if (mLayoutDirty || mLayoutWidth != width) relayoutText(width);
|
||||
return Extent::rigid(halnf(mLines.size()) * lineHeight() + kTextInset);
|
||||
}
|
||||
|
||||
RGBA TextAreaEditor::backgroundTarget() const {
|
||||
const Palette& palette = theme().palette;
|
||||
return mWindowColored ? palette.window : palette.field;
|
||||
}
|
||||
|
||||
void TextAreaEditor::setFillsWithWindowColor(bool window) {
|
||||
mWindowColored = window;
|
||||
refreshVisualState();
|
||||
}
|
||||
|
||||
void TextAreaEditor::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
case Event::Type::PointerMove:
|
||||
case Event::Type::PointerUp: handlePointer(event); break;
|
||||
case Event::Type::KeyDown: handleKey(event); break;
|
||||
case Event::Type::TextInput:
|
||||
insert(event.text());
|
||||
event.accept();
|
||||
break;
|
||||
default: return;
|
||||
}
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void TextAreaEditor::handlePointer(Event& event) {
|
||||
if (event.button() != PointerButton::Primary && event.type() != Event::Type::PointerMove) return;
|
||||
|
||||
if (event.type() == Event::Type::PointerDown) {
|
||||
const size_t position = positionAt(event.position());
|
||||
if (event.clickCount() >= 3) {
|
||||
mBuffer.select(mBuffer.lineStart(position), mBuffer.lineEnd(position));
|
||||
} else if (event.clickCount() == 2) {
|
||||
mBuffer.select(mBuffer.previousWord(position), mBuffer.nextWord(position));
|
||||
} else {
|
||||
mBuffer.setCaret(position, event.modifiers().shift);
|
||||
}
|
||||
mPreferredX = -1;
|
||||
mDragging = event.clickCount() == 1;
|
||||
ui()->setKeyboardFocus(this);
|
||||
ui()->capturePointer(*this);
|
||||
caretMoved();
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type() == Event::Type::PointerMove) {
|
||||
if (!mDragging) return;
|
||||
mBuffer.setCaret(positionAt(event.position()), true);
|
||||
caretMoved();
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mDragging) return;
|
||||
mDragging = false;
|
||||
ui()->releasePointer(*this);
|
||||
event.accept();
|
||||
}
|
||||
|
||||
void TextAreaEditor::handleKey(Event& event) {
|
||||
const bool shift = event.modifiers().shift;
|
||||
const bool control = event.modifiers().control;
|
||||
const size_t caret = mBuffer.caret();
|
||||
|
||||
switch (event.keyCode()) {
|
||||
case Key::Left:
|
||||
if (mBuffer.hasSelection() && !shift) setCaret(mBuffer.selectionStart());
|
||||
else setCaret(control ? mBuffer.previousWord(caret) : mBuffer.previousBoundary(caret), shift);
|
||||
break;
|
||||
case Key::Right:
|
||||
if (mBuffer.hasSelection() && !shift) setCaret(mBuffer.selectionEnd());
|
||||
else setCaret(control ? mBuffer.nextWord(caret) : mBuffer.nextBoundary(caret), shift);
|
||||
break;
|
||||
case Key::Up: moveVertically(-1, shift); break;
|
||||
case Key::Down: moveVertically(1, shift); break;
|
||||
case Key::PageUp: moveVertically(-8, shift); break;
|
||||
case Key::PageDown: moveVertically(8, shift); break;
|
||||
case Key::Home:
|
||||
if (control) setCaret(0, shift);
|
||||
else moveToLineEdge(false, shift);
|
||||
break;
|
||||
case Key::End:
|
||||
if (control) setCaret(mBuffer.text().size(), shift);
|
||||
else moveToLineEdge(true, shift);
|
||||
break;
|
||||
case Key::Backspace:
|
||||
if (mBuffer.eraseBackward(control)) edited();
|
||||
break;
|
||||
case Key::Delete:
|
||||
if (mBuffer.eraseForward(control)) edited();
|
||||
break;
|
||||
case Key::Enter:
|
||||
case Key::KeypadEnter:
|
||||
if (control) {
|
||||
if (mOnCommitted) mOnCommitted(mBuffer.text());
|
||||
break;
|
||||
}
|
||||
insert("\n");
|
||||
break;
|
||||
case Key::Escape: ui()->setKeyboardFocus(nullptr); break;
|
||||
case Key::A:
|
||||
if (!control) return;
|
||||
selectAll();
|
||||
break;
|
||||
case Key::C:
|
||||
case Key::X:
|
||||
if (!control) return;
|
||||
copySelection(event.keyCode() == Key::X);
|
||||
break;
|
||||
case Key::V:
|
||||
if (!control) return;
|
||||
paste();
|
||||
break;
|
||||
default:
|
||||
if (control || event.modifiers().alt || event.keyCode() == Key::Tab) return;
|
||||
break;
|
||||
}
|
||||
|
||||
event.accept();
|
||||
restartBlink();
|
||||
}
|
||||
|
||||
void TextAreaEditor::moveVertically(int lines, bool extendSelection) {
|
||||
ensureLayout();
|
||||
const size_t current = lineOf(mBuffer.caret());
|
||||
if (mPreferredX < 0) mPreferredX = columnX(mBuffer.caret());
|
||||
|
||||
const long target = std::clamp<long>(long(current) + lines, 0, long(mLines.size()) - 1);
|
||||
size_t position = positionInLine(mLines[target], mPreferredX);
|
||||
if (lines < 0 && long(current) + lines < 0) position = 0;
|
||||
if (lines > 0 && long(current) + lines >= long(mLines.size())) position = mBuffer.text().size();
|
||||
|
||||
const halnf preferredX = mPreferredX;
|
||||
mBuffer.setCaret(position, extendSelection);
|
||||
mPreferredX = preferredX;
|
||||
caretMoved();
|
||||
}
|
||||
|
||||
void TextAreaEditor::moveToLineEdge(bool end, bool extendSelection) {
|
||||
ensureLayout();
|
||||
const TextLayoutLine& line = mLines[lineOf(mBuffer.caret())];
|
||||
setCaret(end ? line.end : line.start, extendSelection);
|
||||
}
|
||||
|
||||
void TextAreaEditor::copySelection(bool cut) {
|
||||
WindowHost* host = ui() ? ui()->windowHost() : nullptr;
|
||||
if (!mBuffer.hasSelection()) return;
|
||||
|
||||
if (host) host->setClipboardText(mBuffer.selectedText());
|
||||
if (cut && mBuffer.eraseSelection()) edited();
|
||||
}
|
||||
|
||||
void TextAreaEditor::paste() {
|
||||
if (mPasteHandler && mPasteHandler()) return;
|
||||
|
||||
WindowHost* host = ui() ? ui()->windowHost() : nullptr;
|
||||
if (host) insert(host->clipboardText());
|
||||
}
|
||||
|
||||
void TextAreaEditor::insert(const std::string& text) {
|
||||
if (mBuffer.insert(text)) edited();
|
||||
}
|
||||
|
||||
void TextAreaEditor::edited() {
|
||||
invalidateAdvances();
|
||||
mLayoutDirty = true;
|
||||
mHighlightsDirty = true;
|
||||
mPreferredX = -1;
|
||||
requestLayout();
|
||||
if (mOnEdited) mOnEdited(mBuffer.text());
|
||||
caretMoved();
|
||||
}
|
||||
|
||||
void TextAreaEditor::caretMoved() {
|
||||
restartBlink();
|
||||
if (mOnCaretMoved) mOnCaretMoved();
|
||||
}
|
||||
|
||||
void TextAreaEditor::restartBlink() {
|
||||
mBlinkMs = 0;
|
||||
if (mFocused) startTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void TextAreaEditor::updateTextInputArea() {
|
||||
WindowHost* host = ui() ? ui()->windowHost() : nullptr;
|
||||
if (!host) return;
|
||||
|
||||
const RectF area = windowRect();
|
||||
host->setTextInputArea(mFocused ? &area : nullptr);
|
||||
}
|
||||
|
||||
void TextAreaEditor::onKeyboardFocusChanged() {
|
||||
const bool focused = hasKeyboardFocus();
|
||||
if (focused == mFocused) return;
|
||||
|
||||
mFocused = focused;
|
||||
updateTextInputArea();
|
||||
restartBlink();
|
||||
if (focused) {
|
||||
if (mOnEditingStarted) mOnEditingStarted();
|
||||
return;
|
||||
}
|
||||
|
||||
mBuffer.collapseSelection();
|
||||
mDragging = false;
|
||||
stopTicking();
|
||||
if (mOnCommitted) mOnCommitted(mBuffer.text());
|
||||
}
|
||||
|
||||
void TextAreaEditor::onDetached() {
|
||||
if (!mFocused) return;
|
||||
mFocused = false;
|
||||
mDragging = false;
|
||||
updateTextInputArea();
|
||||
}
|
||||
|
||||
void TextAreaEditor::onTick(halnf deltaMs) {
|
||||
Control::onTick(deltaMs);
|
||||
if (!mFocused) return;
|
||||
|
||||
const bool visibleBefore = std::fmod(mBlinkMs, kBlinkPeriodMs) < kBlinkPeriodMs / 2;
|
||||
mBlinkMs += deltaMs;
|
||||
const bool visibleAfter = std::fmod(mBlinkMs, kBlinkPeriodMs) < kBlinkPeriodMs / 2;
|
||||
if (visibleBefore != visibleAfter) requestPaint();
|
||||
startTicking();
|
||||
}
|
||||
|
||||
void TextAreaEditor::paint(Painter& painter) {
|
||||
ensureLayout();
|
||||
const Palette& palette = theme().palette;
|
||||
const halnf height = lineHeight();
|
||||
const halnf top = kTextInset / 2;
|
||||
|
||||
if (mBuffer.text().empty() && !mPlaceholder.empty()) {
|
||||
painter.text(
|
||||
mPlaceholder, makeRect(kTextInset, top, wrapWidth(), height), fontSize(), TextAlign::Start, palette.disabledText
|
||||
);
|
||||
}
|
||||
|
||||
for (size_t index = 0; index < mLines.size(); index++) {
|
||||
const TextLayoutLine& line = mLines[index];
|
||||
const halnf y = top + halnf(index) * height;
|
||||
if (painter.isClippedOut(makeRect(0, y, size().x, height))) continue;
|
||||
|
||||
if (mFocused && mBuffer.hasSelection()) {
|
||||
const size_t from = std::max(line.start, mBuffer.selectionStart());
|
||||
const size_t to = std::min(line.end, mBuffer.selectionEnd());
|
||||
const bool coversBreak = mBuffer.selectionEnd() > line.end && mBuffer.selectionStart() <= line.end;
|
||||
if (from < to || coversBreak) {
|
||||
const halnf x = kTextInset + textWidthOf(line.start, std::min(from, line.end));
|
||||
const halnf width =
|
||||
textWidthOf(std::min(from, line.end), std::max(from, to)) + (coversBreak ? fontSize() / 3 : 0);
|
||||
painter.fillRect(makeRect(x, y, width, height), palette.neutralSelection, 2);
|
||||
}
|
||||
}
|
||||
|
||||
const RGBA color = isEffectivelyEnabled() ? palette.text : palette.disabledText;
|
||||
paintLineText(painter, line, makeRect(kTextInset, y, wrapWidth() + kTextInset, height), color);
|
||||
}
|
||||
|
||||
const bool caretVisible = std::fmod(mBlinkMs, kBlinkPeriodMs) < kBlinkPeriodMs / 2;
|
||||
if (mFocused && caretVisible) painter.fillRect(caretRect(), palette.text);
|
||||
}
|
||||
|
||||
TextArea::TextArea(std::string text) {
|
||||
setDebugName("text area");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
setClipsChildren(true);
|
||||
setLayout(std::make_unique<AreaLayout>(*this));
|
||||
|
||||
mEditor.setText(std::move(text));
|
||||
mEditor.setOnCaretMoved([this] {
|
||||
revealCaret();
|
||||
if (mOnCaretMoved) mOnCaretMoved();
|
||||
});
|
||||
mScroll.setContent(&mEditor);
|
||||
mScroll.setWheelStep(theme().metrics.lineHeight * 3);
|
||||
|
||||
mHandle.setCursor(CursorShape::ResizeVertical);
|
||||
mHandle.setOnDragStarted([this] {
|
||||
mDragStartHeight = mBoxHeight;
|
||||
mDragStartCoordinate = mHandle.windowRect().center().y;
|
||||
});
|
||||
mHandle.setOnDragged([this](halnf coordinate) {
|
||||
setBoxHeight(mDragStartHeight + coordinate - mDragStartCoordinate);
|
||||
});
|
||||
|
||||
addChild(mScroll);
|
||||
addChild(mHandle);
|
||||
applyBoxHeight();
|
||||
}
|
||||
|
||||
void TextArea::setHeightRange(halnf minimum, halnf maximum) {
|
||||
mMinimumHeight = minimum;
|
||||
mMaximumHeight = std::max(minimum, maximum);
|
||||
setBoxHeight(mBoxHeight);
|
||||
}
|
||||
|
||||
void TextArea::setBoxHeight(halnf height) {
|
||||
mBoxHeight = std::clamp(height, mMinimumHeight, mMaximumHeight);
|
||||
applyBoxHeight();
|
||||
}
|
||||
|
||||
void TextArea::applyBoxHeight() { requestLayout(); }
|
||||
|
||||
void TextArea::setFontScale(halnf scale) {
|
||||
mEditor.setFontScale(scale);
|
||||
mScroll.setWheelStep(mEditor.lineHeight() * 3);
|
||||
}
|
||||
|
||||
void TextArea::setFillsHeight(bool fills) {
|
||||
if (fills == mFillsHeight) return;
|
||||
|
||||
mFillsHeight = fills;
|
||||
mHandle.setVisible(!fills);
|
||||
setSizing(AxisSizing::fill(), fills ? AxisSizing::fill() : AxisSizing::fit());
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void TextArea::viewportArranged(halnf height) {
|
||||
if (height == mArrangedViewportHeight) return;
|
||||
|
||||
mArrangedViewportHeight = height;
|
||||
if (mEditor.isEditing()) startTicking();
|
||||
}
|
||||
|
||||
void TextArea::onTick(halnf) {
|
||||
stopTicking();
|
||||
revealCaret();
|
||||
}
|
||||
|
||||
halnf TextArea::caretRevealOffset(halnf viewportHeight) const {
|
||||
const RectF caret = mEditor.caretRect();
|
||||
const halnf top = caret.pos.y - kTextInset / 2;
|
||||
const halnf bottom = caret.pos.y + caret.size.y + kTextInset / 2;
|
||||
const halnf offset = mScroll.offset();
|
||||
|
||||
if (top < offset) return top;
|
||||
if (bottom > offset + viewportHeight) return bottom - viewportHeight;
|
||||
return offset;
|
||||
}
|
||||
|
||||
void TextArea::revealCaret() {
|
||||
if (ui()) ui()->updateLayout();
|
||||
if (!mScroll.size().y) return;
|
||||
|
||||
mScroll.jumpTo(caretRevealOffset(mScroll.size().y));
|
||||
}
|
||||
|
||||
void TextArea::setFillsWithWindowColor(bool window) {
|
||||
mWindowColored = window;
|
||||
mEditor.setFillsWithWindowColor(window);
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void TextArea::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const RGBA fill = mWindowColored ? style.palette.window : style.palette.field;
|
||||
painter.fillRect(localRect(), fill, style.metrics.cornerRadius);
|
||||
}
|
||||
180
Widgets/src/components/controls/private/TextEditBuffer.cpp
Normal file
180
Widgets/src/components/controls/private/TextEditBuffer.cpp
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
#include "TextEditBuffer.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
bool isContinuationByte(char byte) { return ((unsigned char) byte & 0xC0) == 0x80; }
|
||||
|
||||
bool isWordCharacter(char byte) {
|
||||
const auto value = (unsigned char) byte;
|
||||
return std::isalnum(value) || value == '_' || value >= 0x80;
|
||||
}
|
||||
|
||||
size_t characterLength(const std::string& text, size_t index) {
|
||||
size_t length = 1;
|
||||
while (index + length < text.size() && isContinuationByte(text[index + length]))
|
||||
length++;
|
||||
return length;
|
||||
}
|
||||
|
||||
size_t cutAtBoundary(const std::string& text, size_t length) {
|
||||
while (length > 0 && length < text.size() && isContinuationByte(text[length]))
|
||||
length--;
|
||||
return length;
|
||||
}
|
||||
}
|
||||
|
||||
TextEditBuffer::TextEditBuffer(Lines lines, std::string text) :
|
||||
mLines(lines) {
|
||||
setText(std::move(text));
|
||||
}
|
||||
|
||||
void TextEditBuffer::setText(std::string text) {
|
||||
mText = std::move(text);
|
||||
if (mMaximumLength && mText.size() > mMaximumLength) mText.resize(cutAtBoundary(mText, mMaximumLength));
|
||||
mCaret = mAnchor = mText.size();
|
||||
}
|
||||
|
||||
void TextEditBuffer::setMaximumLength(size_t maximumLength) {
|
||||
mMaximumLength = maximumLength;
|
||||
if (!mMaximumLength || mText.size() <= mMaximumLength) return;
|
||||
|
||||
mText.resize(cutAtBoundary(mText, mMaximumLength));
|
||||
mCaret = std::min(mCaret, mText.size());
|
||||
mAnchor = std::min(mAnchor, mText.size());
|
||||
}
|
||||
|
||||
std::string TextEditBuffer::selectedText() const {
|
||||
return mText.substr(selectionStart(), selectionEnd() - selectionStart());
|
||||
}
|
||||
|
||||
size_t TextEditBuffer::clampedToBoundary(size_t position) const {
|
||||
position = std::min(position, mText.size());
|
||||
while (position > 0 && position < mText.size() && isContinuationByte(mText[position]))
|
||||
position--;
|
||||
return position;
|
||||
}
|
||||
|
||||
void TextEditBuffer::setCaret(size_t position, bool extendSelection) {
|
||||
mCaret = clampedToBoundary(position);
|
||||
if (!extendSelection) mAnchor = mCaret;
|
||||
}
|
||||
|
||||
void TextEditBuffer::select(size_t anchor, size_t caret) {
|
||||
mAnchor = clampedToBoundary(anchor);
|
||||
mCaret = clampedToBoundary(caret);
|
||||
}
|
||||
|
||||
void TextEditBuffer::selectAll() { select(0, mText.size()); }
|
||||
|
||||
bool TextEditBuffer::accepts(const std::string& character) const {
|
||||
const auto lead = (unsigned char) character[0];
|
||||
const bool newline = character == "\n";
|
||||
|
||||
if (newline && mLines == Lines::Single) return false;
|
||||
if (lead < 0x20 && !newline) return false;
|
||||
return !mAccepts || mAccepts(character);
|
||||
}
|
||||
|
||||
std::string TextEditBuffer::acceptedCharacters(const std::string& text) const {
|
||||
std::string accepted;
|
||||
for (size_t index = 0; index < text.size();) {
|
||||
const size_t length = characterLength(text, index);
|
||||
std::string character = text.substr(index, length);
|
||||
index += length;
|
||||
|
||||
if (character == "\r") continue;
|
||||
if (character == "\t") character = " ";
|
||||
if (accepts(character)) accepted += character;
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
bool TextEditBuffer::insert(const std::string& text) {
|
||||
std::string accepted = acceptedCharacters(text);
|
||||
if (accepted.empty()) return false;
|
||||
|
||||
const size_t remaining = mText.size() - (selectionEnd() - selectionStart());
|
||||
if (mMaximumLength && remaining + accepted.size() > mMaximumLength) {
|
||||
const size_t room = mMaximumLength > remaining ? mMaximumLength - remaining : 0;
|
||||
accepted.resize(cutAtBoundary(accepted, std::min(room, accepted.size())));
|
||||
if (accepted.empty()) return false;
|
||||
}
|
||||
|
||||
eraseSelection();
|
||||
mText.insert(mCaret, accepted);
|
||||
setCaret(mCaret + accepted.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TextEditBuffer::eraseSelection() {
|
||||
if (!hasSelection()) return false;
|
||||
return erase(selectionStart(), selectionEnd());
|
||||
}
|
||||
|
||||
bool TextEditBuffer::erase(size_t from, size_t to) {
|
||||
from = clampedToBoundary(from);
|
||||
to = clampedToBoundary(to);
|
||||
if (to <= from) return false;
|
||||
|
||||
mText.erase(from, to - from);
|
||||
setCaret(from);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TextEditBuffer::eraseBackward(bool wholeWord) {
|
||||
if (hasSelection()) return eraseSelection();
|
||||
return erase(wholeWord ? previousWord(mCaret) : previousBoundary(mCaret), mCaret);
|
||||
}
|
||||
|
||||
bool TextEditBuffer::eraseForward(bool wholeWord) {
|
||||
if (hasSelection()) return eraseSelection();
|
||||
return erase(mCaret, wholeWord ? nextWord(mCaret) : nextBoundary(mCaret));
|
||||
}
|
||||
|
||||
size_t TextEditBuffer::previousBoundary(size_t position) const {
|
||||
if (position == 0) return 0;
|
||||
position--;
|
||||
while (position > 0 && isContinuationByte(mText[position]))
|
||||
position--;
|
||||
return position;
|
||||
}
|
||||
|
||||
size_t TextEditBuffer::nextBoundary(size_t position) const {
|
||||
if (position >= mText.size()) return mText.size();
|
||||
position++;
|
||||
while (position < mText.size() && isContinuationByte(mText[position]))
|
||||
position++;
|
||||
return position;
|
||||
}
|
||||
|
||||
size_t TextEditBuffer::previousWord(size_t position) const {
|
||||
while (position > 0 && !isWordCharacter(mText[position - 1]))
|
||||
position--;
|
||||
while (position > 0 && isWordCharacter(mText[position - 1]))
|
||||
position--;
|
||||
return position;
|
||||
}
|
||||
|
||||
size_t TextEditBuffer::nextWord(size_t position) const {
|
||||
while (position < mText.size() && !isWordCharacter(mText[position]))
|
||||
position++;
|
||||
while (position < mText.size() && isWordCharacter(mText[position]))
|
||||
position++;
|
||||
return position;
|
||||
}
|
||||
|
||||
size_t TextEditBuffer::lineStart(size_t position) const {
|
||||
position = std::min(position, mText.size());
|
||||
const size_t newline = position == 0 ? std::string::npos : mText.rfind('\n', position - 1);
|
||||
return newline == std::string::npos ? 0 : newline + 1;
|
||||
}
|
||||
|
||||
size_t TextEditBuffer::lineEnd(size_t position) const {
|
||||
const size_t newline = mText.find('\n', std::min(position, mText.size()));
|
||||
return newline == std::string::npos ? mText.size() : newline;
|
||||
}
|
||||
433
Widgets/src/components/controls/private/TextField.cpp
Normal file
433
Widgets/src/components/controls/private/TextField.cpp
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
#include "TextField.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
#include "WindowHost.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kBlinkPeriodMs = 1060;
|
||||
constexpr halnf kCaretWidth = 1.5f;
|
||||
constexpr halnf kStepButtonWidth = 28;
|
||||
constexpr halnf kStepIconSize = 10;
|
||||
constexpr halnf kStepIconStroke = 1.6f;
|
||||
}
|
||||
|
||||
TextField::TextField(std::string text) :
|
||||
mBuffer(TextEditBuffer::Lines::Single, std::move(text)) {
|
||||
setDebugName("text field");
|
||||
setCursor(CursorShape::Text);
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
}
|
||||
|
||||
void TextField::setText(std::string text) {
|
||||
if (text == mBuffer.text()) return;
|
||||
|
||||
mBuffer.setText(std::move(text));
|
||||
mScroll = 0;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void TextField::setPlaceholder(std::string placeholder) {
|
||||
mPlaceholder = std::move(placeholder);
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void TextField::selectAll() {
|
||||
mBuffer.selectAll();
|
||||
restartBlink();
|
||||
}
|
||||
|
||||
void TextField::setCaret(size_t position, bool extendSelection) {
|
||||
mBuffer.setCaret(position, extendSelection);
|
||||
restartBlink();
|
||||
}
|
||||
|
||||
Extent TextField::measureContent(Axis axis, halnf) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
if (axis == Axis::Vertical) return Extent::rigid(metrics.controlHeight + fontSize() - metrics.fontSize);
|
||||
|
||||
const halnf padding = metrics.controlPadding.left + metrics.controlPadding.right;
|
||||
const halnf sample = textWidth(text().empty() ? mPlaceholder : text(), fontSize());
|
||||
return { padding + 24, padding + sample, kUnbounded, 0 };
|
||||
}
|
||||
|
||||
RGBA TextField::backgroundTarget() const {
|
||||
const Palette& palette = theme().palette;
|
||||
if (!isEffectivelyEnabled()) return Palette::mix(palette.field, palette.window, 0.5f);
|
||||
return palette.field;
|
||||
}
|
||||
|
||||
void TextField::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
{
|
||||
if (event.button() != PointerButton::Primary) return;
|
||||
const size_t position = positionAt(event.position().x);
|
||||
if (event.clickCount() >= 3 || (mFocusedByPress && event.clickCount() == 1)) {
|
||||
selectAll();
|
||||
} else if (event.clickCount() == 2) {
|
||||
mBuffer.select(mBuffer.previousWord(position), mBuffer.nextWord(position));
|
||||
} else {
|
||||
setCaret(position, event.modifiers().shift);
|
||||
}
|
||||
mDragging = event.clickCount() == 1 && !mFocusedByPress;
|
||||
mFocusedByPress = false;
|
||||
if (ui()) ui()->capturePointer(*this);
|
||||
event.accept();
|
||||
break;
|
||||
}
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (!mDragging) return;
|
||||
setCaret(positionAt(event.position().x), true);
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (event.button() != PointerButton::Primary) return;
|
||||
mDragging = false;
|
||||
if (ui()) ui()->releasePointer(*this);
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
case Event::Type::KeyDown: handleKey(event); break;
|
||||
|
||||
case Event::Type::TextInput:
|
||||
insert(event.text());
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
default: return;
|
||||
}
|
||||
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void TextField::handleKey(Event& event) {
|
||||
const bool shift = event.modifiers().shift;
|
||||
const bool control = event.modifiers().control;
|
||||
|
||||
switch (event.keyCode()) {
|
||||
case Key::Left:
|
||||
if (hasSelection() && !shift) setCaret(mBuffer.selectionStart());
|
||||
else setCaret(control ? mBuffer.previousWord(caret()) : mBuffer.previousBoundary(caret()), shift);
|
||||
break;
|
||||
case Key::Right:
|
||||
if (hasSelection() && !shift) setCaret(mBuffer.selectionEnd());
|
||||
else setCaret(control ? mBuffer.nextWord(caret()) : mBuffer.nextBoundary(caret()), shift);
|
||||
break;
|
||||
case Key::Home: setCaret(0, shift); break;
|
||||
case Key::End: setCaret(text().size(), shift); break;
|
||||
case Key::Backspace:
|
||||
if (mBuffer.eraseBackward(control)) edited();
|
||||
restartBlink();
|
||||
break;
|
||||
case Key::Delete:
|
||||
if (mBuffer.eraseForward(control)) edited();
|
||||
restartBlink();
|
||||
break;
|
||||
case Key::Enter:
|
||||
case Key::KeypadEnter: commit(); break;
|
||||
case Key::Escape:
|
||||
if (ui()) ui()->setKeyboardFocus(nullptr);
|
||||
commit();
|
||||
break;
|
||||
case Key::A:
|
||||
if (!control) return;
|
||||
selectAll();
|
||||
break;
|
||||
case Key::C:
|
||||
case Key::X:
|
||||
{
|
||||
if (!control) return;
|
||||
WindowHost* host = ui() ? ui()->windowHost() : nullptr;
|
||||
if (host && hasSelection()) host->setClipboardText(selectedText());
|
||||
if (event.keyCode() == Key::X && mBuffer.eraseSelection()) edited();
|
||||
break;
|
||||
}
|
||||
case Key::V:
|
||||
{
|
||||
if (!control) return;
|
||||
WindowHost* host = ui() ? ui()->windowHost() : nullptr;
|
||||
if (host) insert(host->clipboardText());
|
||||
break;
|
||||
}
|
||||
default:
|
||||
if (control || event.modifiers().alt) return;
|
||||
if (event.keyCode() == Key::Tab || event.keyCode() == Key::Up || event.keyCode() == Key::Down) return;
|
||||
break;
|
||||
}
|
||||
|
||||
event.accept();
|
||||
}
|
||||
|
||||
void TextField::insert(const std::string& text) {
|
||||
if (!mBuffer.insert(text)) return;
|
||||
restartBlink();
|
||||
edited();
|
||||
}
|
||||
|
||||
void TextField::commit() {
|
||||
if (mOnCommitted) mOnCommitted(text());
|
||||
}
|
||||
|
||||
void TextField::edited() {
|
||||
if (mOnEdited) mOnEdited(text());
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void TextField::updateTextInputArea() {
|
||||
WindowHost* host = ui() ? ui()->windowHost() : nullptr;
|
||||
if (!host) return;
|
||||
|
||||
const RectF area = windowRect();
|
||||
host->setTextInputArea(mFocused ? &area : nullptr);
|
||||
}
|
||||
|
||||
void TextField::restartBlink() {
|
||||
mBlinkMs = 0;
|
||||
startTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void TextField::onKeyboardFocusChanged() {
|
||||
const bool focused = hasKeyboardFocus();
|
||||
if (focused == mFocused) return;
|
||||
|
||||
mFocused = focused;
|
||||
mFocusedByPress = focused && ui() && ui()->isPrimaryButtonDown();
|
||||
updateTextInputArea();
|
||||
restartBlink();
|
||||
if (focused) {
|
||||
if (mOnEditingStarted) mOnEditingStarted();
|
||||
return;
|
||||
}
|
||||
|
||||
mBuffer.collapseSelection();
|
||||
mDragging = false;
|
||||
commit();
|
||||
}
|
||||
|
||||
void TextField::onDetached() {
|
||||
if (!mFocused) return;
|
||||
mFocused = false;
|
||||
mDragging = false;
|
||||
updateTextInputArea();
|
||||
}
|
||||
|
||||
void TextField::onTick(halnf deltaMs) {
|
||||
Control::onTick(deltaMs);
|
||||
if (!mFocused) return;
|
||||
|
||||
const bool visibleBefore = std::fmod(mBlinkMs, kBlinkPeriodMs) < kBlinkPeriodMs / 2;
|
||||
mBlinkMs += deltaMs;
|
||||
const bool visibleAfter = std::fmod(mBlinkMs, kBlinkPeriodMs) < kBlinkPeriodMs / 2;
|
||||
if (visibleBefore != visibleAfter) requestPaint();
|
||||
startTicking();
|
||||
}
|
||||
|
||||
halnf TextField::offsetOf(size_t position) const { return textWidth(text().substr(0, position), fontSize()); }
|
||||
|
||||
size_t TextField::positionAt(halnf localX) const {
|
||||
const halnf x = localX - textBox().pos.x + mScroll;
|
||||
size_t best = 0;
|
||||
halnf bestDistance = std::fabs(x);
|
||||
|
||||
for (size_t position = mBuffer.nextBoundary(0); position <= text().size();
|
||||
position = mBuffer.nextBoundary(position)) {
|
||||
const halnf distance = std::fabs(offsetOf(position) - x);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = position;
|
||||
}
|
||||
if (position == text().size()) break;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
RectF TextField::textBox() const {
|
||||
const Insets& padding = theme().metrics.controlPadding;
|
||||
return makeRect(padding.left, 0, size().x - padding.left - padding.right, size().y);
|
||||
}
|
||||
|
||||
halnf TextField::fontSize() const { return mFontSize > 0 ? mFontSize : theme().metrics.fontSize; }
|
||||
|
||||
halnf TextField::lineHeight() const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
return metrics.lineHeight * fontSize() / metrics.fontSize;
|
||||
}
|
||||
|
||||
void TextField::setFontSize(halnf fontSize) {
|
||||
mFontSize = fontSize;
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void TextField::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const Palette& palette = style.palette;
|
||||
const RectF box = textBox();
|
||||
|
||||
const halnf caretX = offsetOf(caret());
|
||||
if (mFocused) {
|
||||
if (caretX - mScroll > box.size.x) mScroll = caretX - box.size.x;
|
||||
if (caretX - mScroll < 0) mScroll = caretX;
|
||||
const halnf overflow = offsetOf(text().size()) - box.size.x;
|
||||
mScroll = std::clamp(mScroll, 0.0f, std::max(0.0f, overflow));
|
||||
} else {
|
||||
mScroll = 0;
|
||||
}
|
||||
|
||||
painter.fillRect(localRect(), background(), style.metrics.cornerRadius);
|
||||
const RGBA border = mFocused ? palette.mutedText : palette.controlBorder;
|
||||
painter.strokeRect(localRect(), border, style.metrics.cornerRadius, style.metrics.borderWidth);
|
||||
|
||||
ScopedClip clip(painter, box);
|
||||
const RectF scrolled = makeRect(box.pos.x - mScroll, 0, box.size.x + mScroll, box.size.y);
|
||||
|
||||
if (mFocused && hasSelection()) {
|
||||
const halnf from = offsetOf(mBuffer.selectionStart());
|
||||
const halnf to = offsetOf(mBuffer.selectionEnd());
|
||||
const halnf height = lineHeight();
|
||||
painter.fillRect(
|
||||
makeRect(scrolled.pos.x + from, (size().y - height) / 2, to - from, height), palette.neutralSelection, 2
|
||||
);
|
||||
}
|
||||
|
||||
if (text().empty() && !mPlaceholder.empty()) {
|
||||
painter.text(mPlaceholder, box, fontSize(), TextAlign::Start, palette.disabledText);
|
||||
} else {
|
||||
const RGBA color = isEffectivelyEnabled() ? palette.text : palette.disabledText;
|
||||
painter.text(text(), scrolled, fontSize(), TextAlign::Start, color);
|
||||
}
|
||||
|
||||
const bool caretVisible = std::fmod(mBlinkMs, kBlinkPeriodMs) < kBlinkPeriodMs / 2;
|
||||
if (mFocused && caretVisible) {
|
||||
const halnf height = lineHeight();
|
||||
painter.fillRect(
|
||||
makeRect(scrolled.pos.x + caretX - kCaretWidth / 2, (size().y - height) / 2, kCaretWidth, height), palette.text
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
StepButton::StepButton(int direction) :
|
||||
Button(""),
|
||||
mDirection(direction) {
|
||||
setDebugName(direction < 0 ? "decrement" : "increment");
|
||||
setTooltip(direction < 0 ? "Decrease" : "Increase");
|
||||
setIconSize(kStepIconSize);
|
||||
}
|
||||
|
||||
void StepButton::paintIcon(Painter& painter, const RectF& box) {
|
||||
const Vec2F center = box.pos + box.size / 2.0f;
|
||||
const halnf half = std::min(box.size.x, box.size.y) / 2;
|
||||
const RGBA color = isEffectivelyEnabled() ? theme().palette.text : theme().palette.mutedText;
|
||||
painter.line(center - Vec2F(half, 0.0f), center + Vec2F(half, 0.0f), color, kStepIconStroke);
|
||||
if (mDirection > 0) painter.line(center - Vec2F(0.0f, half), center + Vec2F(0.0f, half), color, kStepIconStroke);
|
||||
}
|
||||
|
||||
NumberField::NumberField(halnf minimum, halnf maximum, halnf value, halnf step) :
|
||||
mMinimum(minimum),
|
||||
mMaximum(maximum),
|
||||
mValue(value),
|
||||
mStep(step) {
|
||||
setDebugName("number field");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
emplaceLayout<RowLayout>(2).setCrossAlign(CrossAlign::Center);
|
||||
|
||||
for (Button* button : { &mDecrement, &mIncrement })
|
||||
button->setSizing(AxisSizing::fixed(kStepButtonWidth), AxisSizing::fit());
|
||||
mDecrement.setOnActivate([this]() { stepBy(-1); });
|
||||
mIncrement.setOnActivate([this]() { stepBy(1); });
|
||||
|
||||
mField.setCharacterFilter([](const std::string& character) {
|
||||
return character.size() == 1 &&
|
||||
(std::isdigit((unsigned char) character[0]) || character == "." || character == "-" || character == ",");
|
||||
});
|
||||
mField.setOnCommitted([this](const std::string& text) {
|
||||
std::string normalized = text;
|
||||
for (char& character : normalized) {
|
||||
if (character == ',') character = '.';
|
||||
}
|
||||
char* end = nullptr;
|
||||
const double parsed = std::strtod(normalized.c_str(), &end);
|
||||
if (end != normalized.c_str()) apply((halnf) parsed);
|
||||
refreshText();
|
||||
});
|
||||
|
||||
addChild(mDecrement);
|
||||
addChild(mField);
|
||||
addChild(mIncrement);
|
||||
|
||||
mValue = clamped(mValue);
|
||||
refreshText();
|
||||
}
|
||||
|
||||
void NumberField::setValue(halnf value) {
|
||||
mValue = clamped(value);
|
||||
refreshText();
|
||||
}
|
||||
|
||||
void NumberField::setRange(halnf minimum, halnf maximum) {
|
||||
mMinimum = minimum;
|
||||
mMaximum = maximum;
|
||||
setValue(mValue);
|
||||
}
|
||||
|
||||
void NumberField::setDecimals(int decimals) {
|
||||
mDecimals = decimals;
|
||||
refreshText();
|
||||
}
|
||||
|
||||
void NumberField::setSuffix(std::string suffix) {
|
||||
mSuffix = std::move(suffix);
|
||||
refreshText();
|
||||
}
|
||||
|
||||
void NumberField::stepBy(int steps) { apply(mValue + mStep * (halnf) steps); }
|
||||
|
||||
void NumberField::onEvent(Event& event) {
|
||||
if (event.type() == Event::Type::KeyDown) {
|
||||
if (event.keyCode() == Key::Up || event.keyCode() == Key::PageUp) stepBy(1);
|
||||
else if (event.keyCode() == Key::Down || event.keyCode() == Key::PageDown) stepBy(-1);
|
||||
else return;
|
||||
event.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type() == Event::Type::Wheel && event.wheelAmount() != 0) {
|
||||
stepBy(event.wheelAmount() > 0 ? 1 : -1);
|
||||
event.accept();
|
||||
}
|
||||
}
|
||||
|
||||
void NumberField::apply(halnf value) {
|
||||
const halnf next = clamped(value);
|
||||
const bool changed = next != mValue;
|
||||
mValue = next;
|
||||
refreshText();
|
||||
if (changed && mOnChanged) mOnChanged(mValue);
|
||||
}
|
||||
|
||||
void NumberField::refreshText() { mField.setText(format(mValue)); }
|
||||
|
||||
halnf NumberField::clamped(halnf value) const {
|
||||
if (value < mMinimum) return mMinimum;
|
||||
if (value > mMaximum) return mMaximum;
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string NumberField::format(halnf value) const {
|
||||
char text[64];
|
||||
std::snprintf(text, sizeof(text), "%.*f", mDecimals, (double) value);
|
||||
return text + mSuffix;
|
||||
}
|
||||
186
Widgets/src/components/controls/private/Toggles.cpp
Normal file
186
Widgets/src/components/controls/private/Toggles.cpp
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
#include "Toggles.hpp"
|
||||
|
||||
#include "Theme.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
RGBA mix(const RGBA& from, const RGBA& to, halnf amount) { return Palette::mix(from, to, amount); }
|
||||
|
||||
RGBA withAlpha(const RGBA& color, halnf alpha) { return { color.r, color.g, color.b, color.a * alpha }; }
|
||||
}
|
||||
|
||||
ToggleControl::ToggleControl(std::string text) :
|
||||
mText(std::move(text)),
|
||||
mCheckedAmount(0) {}
|
||||
|
||||
void ToggleControl::setChecked(bool checked) {
|
||||
if (checked == mChecked) return;
|
||||
|
||||
mChecked = checked;
|
||||
animate(mCheckedAmount, checked ? 1.0f : 0.0f);
|
||||
if (mOnChanged) mOnChanged(checked);
|
||||
}
|
||||
|
||||
void ToggleControl::setText(std::string text) {
|
||||
mText = std::move(text);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
Extent ToggleControl::measureContent(Axis axis, halnf) const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
|
||||
if (axis == Axis::Vertical) return Extent::natural(metrics.controlHeight);
|
||||
|
||||
const halnf label = mText.empty() ? 0 : metrics.spacing + textWidth(mText, metrics.fontSize);
|
||||
return Extent::natural(indicatorWidth() + label);
|
||||
}
|
||||
|
||||
void ToggleControl::onActivated() {
|
||||
if (togglesOnActivate()) setChecked(!mChecked);
|
||||
}
|
||||
|
||||
void ToggleControl::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
|
||||
paintIndicator(painter, indicatorBox(), mCheckedAmount.value());
|
||||
|
||||
if (mText.empty()) return;
|
||||
|
||||
const halnf labelLeft = indicatorWidth() + style.metrics.spacing;
|
||||
const RectF labelBox = makeRect(labelLeft, 0, size().x - labelLeft, size().y);
|
||||
const RGBA color = isEffectivelyEnabled() ? style.palette.text : style.palette.disabledText;
|
||||
painter.text(mText, labelBox, style.metrics.fontSize, TextAlign::Start, color);
|
||||
}
|
||||
|
||||
halnf ToggleControl::indicatorWidth() const { return theme().metrics.indicatorSize; }
|
||||
|
||||
RectF ToggleControl::indicatorBox() const {
|
||||
const halnf height = theme().metrics.indicatorSize;
|
||||
return makeRect(0, (size().y - height) / 2, indicatorWidth(), height);
|
||||
}
|
||||
|
||||
Checkbox::Checkbox(std::string text) :
|
||||
ToggleControl(std::move(text)) {
|
||||
setDebugName("checkbox");
|
||||
}
|
||||
|
||||
void Checkbox::paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) {
|
||||
const Palette& palette = theme().palette;
|
||||
const halnf radius = theme().metrics.indicatorCornerRadius + 1;
|
||||
const RGBA uncheckedFill = isHovered() ? palette.controlHovered : palette.field;
|
||||
const RGBA checkedFill = isHovered() ? palette.toggleOnHovered : palette.toggleOn;
|
||||
const RGBA uncheckedBorder = isHovered() ? palette.mutedText : palette.controlBorder;
|
||||
|
||||
if (showsFocusRing()) painter.strokeRect(insetRect(box, -3), palette.focusRing, radius + 3, 2);
|
||||
painter.fillRect(box, mix(uncheckedFill, checkedFill, checkedAmount), radius);
|
||||
painter.strokeRect(box, withAlpha(uncheckedBorder, 1 - checkedAmount), radius);
|
||||
|
||||
if (checkedAmount <= 0.01f) return;
|
||||
|
||||
const Vec2F origin = box.pos;
|
||||
const halnf unit = box.size.x;
|
||||
const halnf grow = 0.6f + 0.4f * checkedAmount;
|
||||
const Vec2F center = box.center();
|
||||
const auto point = [&](halnf x, halnf y) { return center + (origin + Vec2F(unit * x, unit * y) - center) * grow; };
|
||||
painter.polyline(
|
||||
{ point(0.27f, 0.52f), point(0.44f, 0.68f), point(0.74f, 0.35f) }, withAlpha(palette.onToggle, checkedAmount), 2
|
||||
);
|
||||
}
|
||||
|
||||
Switch::Switch(std::string text) :
|
||||
ToggleControl(std::move(text)) {
|
||||
setDebugName("switch");
|
||||
}
|
||||
|
||||
void Switch::paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) {
|
||||
const Palette& palette = theme().palette;
|
||||
const halnf radius = box.size.y / 2;
|
||||
const RGBA offTrack = isHovered() ? palette.controlPressed : palette.track;
|
||||
const RGBA onTrack = isHovered() ? palette.toggleOnHovered : palette.toggleOn;
|
||||
|
||||
if (showsFocusRing()) painter.strokeRect(insetRect(box, -3), palette.focusRing, radius + 3, 2);
|
||||
painter.fillRect(box, mix(offTrack, onTrack, checkedAmount), radius);
|
||||
|
||||
const halnf knobRadius = radius - 3;
|
||||
const halnf travel = box.size.x - box.size.y;
|
||||
const Vec2F knobCenter = { box.pos.x + radius + travel * checkedAmount, box.pos.y + radius };
|
||||
const RGBA offKnob = palette.mutedText;
|
||||
painter.fillCircle(knobCenter, knobRadius, mix(offKnob, palette.onToggle, checkedAmount));
|
||||
}
|
||||
|
||||
halnf Switch::indicatorWidth() const { return theme().metrics.indicatorSize * 1.9f; }
|
||||
|
||||
RadioButton::RadioButton(std::string text) :
|
||||
ToggleControl(std::move(text)) {
|
||||
setDebugName("radio");
|
||||
}
|
||||
|
||||
RadioButton::~RadioButton() {
|
||||
if (mGroup) mGroup->remove(*this);
|
||||
}
|
||||
|
||||
void RadioButton::paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) {
|
||||
const Palette& palette = theme().palette;
|
||||
const Vec2F center = box.center();
|
||||
const halnf radius = box.size.y / 2;
|
||||
const RGBA uncheckedFill = isHovered() ? palette.controlHovered : palette.field;
|
||||
const RGBA checkedFill = isHovered() ? palette.toggleOnHovered : palette.toggleOn;
|
||||
const RGBA uncheckedBorder = isHovered() ? palette.mutedText : palette.controlBorder;
|
||||
|
||||
if (showsFocusRing()) painter.strokeCircle(center, radius + 3, palette.focusRing, 2);
|
||||
painter.fillCircle(center, radius, mix(uncheckedFill, checkedFill, checkedAmount));
|
||||
painter.strokeCircle(center, radius, withAlpha(uncheckedBorder, 1 - checkedAmount));
|
||||
|
||||
if (checkedAmount > 0.01f) painter.fillCircle(center, radius * 0.4f * checkedAmount, palette.onToggle);
|
||||
}
|
||||
|
||||
void RadioButton::onActivated() {
|
||||
if (mGroup) mGroup->select(*this);
|
||||
else setChecked(true);
|
||||
}
|
||||
|
||||
RadioGroup::~RadioGroup() {
|
||||
for (RadioButton* button : mButtons)
|
||||
button->mGroup = nullptr;
|
||||
}
|
||||
|
||||
void RadioGroup::add(RadioButton& button) {
|
||||
if (button.mGroup) button.mGroup->remove(button);
|
||||
|
||||
button.mGroup = this;
|
||||
mButtons.push_back(&button);
|
||||
}
|
||||
|
||||
void RadioGroup::remove(RadioButton& button) {
|
||||
mButtons.erase(std::remove(mButtons.begin(), mButtons.end(), &button), mButtons.end());
|
||||
button.mGroup = nullptr;
|
||||
}
|
||||
|
||||
void RadioGroup::select(RadioButton& button) {
|
||||
if (button.mGroup != this) return;
|
||||
if (button.isChecked()) return;
|
||||
|
||||
for (RadioButton* other : mButtons)
|
||||
other->setChecked(other == &button);
|
||||
if (mOnChanged) mOnChanged((size_t) selectedIndex());
|
||||
}
|
||||
|
||||
void RadioGroup::selectIndex(size_t index) {
|
||||
if (index < mButtons.size()) select(*mButtons[index]);
|
||||
}
|
||||
|
||||
long RadioGroup::selectedIndex() const {
|
||||
for (size_t index = 0; index < mButtons.size(); index++) {
|
||||
if (mButtons[index]->isChecked()) return (long) index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
RadioButton* RadioGroup::selected() const {
|
||||
const long index = selectedIndex();
|
||||
return index >= 0 ? mButtons[(size_t) index] : nullptr;
|
||||
}
|
||||
44
Widgets/src/components/controls/public/Breadcrumb.hpp
Normal file
44
Widgets/src/components/controls/public/Breadcrumb.hpp
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#pragma once
|
||||
|
||||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class BreadcrumbPart : public Button {
|
||||
public:
|
||||
BreadcrumbPart(std::string text, bool current);
|
||||
|
||||
[[nodiscard]] bool isCurrent() const { return mCurrent; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] RGBA labelColor() const override;
|
||||
|
||||
private:
|
||||
bool mCurrent;
|
||||
};
|
||||
|
||||
class Breadcrumb : public Widget {
|
||||
public:
|
||||
Breadcrumb();
|
||||
|
||||
void setParts(const std::vector<std::string>& parts);
|
||||
[[nodiscard]] size_t partCount() const { return mParts.size(); }
|
||||
[[nodiscard]] BreadcrumbPart& part(size_t index) { return *mParts[index]; }
|
||||
|
||||
void setOnPartChosen(std::function<void(size_t)> callback) { mOnPartChosen = std::move(callback); }
|
||||
|
||||
private:
|
||||
ScrollView mScroll{ Axis::Horizontal };
|
||||
Widget mRow;
|
||||
std::vector<std::unique_ptr<BreadcrumbPart>> mParts;
|
||||
std::vector<std::unique_ptr<Label>> mSeparators;
|
||||
std::function<void(size_t)> mOnPartChosen;
|
||||
};
|
||||
}
|
||||
89
Widgets/src/components/controls/public/Button.hpp
Normal file
89
Widgets/src/components/controls/public/Button.hpp
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
#pragma once
|
||||
|
||||
#include "Control.hpp"
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class Button : public Control {
|
||||
public:
|
||||
enum class Style { Normal, Accent, Flat, Tab };
|
||||
enum class IconPlacement { Leading, Above };
|
||||
|
||||
explicit Button(std::string text = {}, std::function<void()> onClick = {});
|
||||
|
||||
void setText(std::string text);
|
||||
void setIconSize(halnf size) {
|
||||
mIconSize = size;
|
||||
requestLayout();
|
||||
}
|
||||
[[nodiscard]] halnf iconSize() const;
|
||||
[[nodiscard]] halnf cornerRadius() const;
|
||||
void setFill(std::optional<RGBA> fill);
|
||||
void setCornerRadius(halnf radius) {
|
||||
mCornerRadius = radius;
|
||||
requestPaint();
|
||||
}
|
||||
[[nodiscard]] const std::string& text() const { return mText; }
|
||||
void setFontFace(FontFace face);
|
||||
[[nodiscard]] FontFace fontFace() const { return mFontFace; }
|
||||
|
||||
void setIcon(ImageId icon);
|
||||
[[nodiscard]] ImageId icon() const { return mIcon; }
|
||||
void setIconPlacement(IconPlacement placement);
|
||||
void setStyle(Style style);
|
||||
void setSelected(bool selected);
|
||||
[[nodiscard]] bool isSelected() const { return mSelected; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Painter& painter) override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
|
||||
[[nodiscard]] virtual bool hasIcon() const { return mIcon.isValid(); }
|
||||
virtual void paintIcon(Painter& painter, const RectF& box);
|
||||
[[nodiscard]] virtual RGBA labelColor() const;
|
||||
|
||||
private:
|
||||
struct ContentBoxes {
|
||||
RectF icon;
|
||||
RectF text;
|
||||
};
|
||||
|
||||
[[nodiscard]] ContentBoxes contentBoxes() const;
|
||||
[[nodiscard]] halnf labelFontSize() const;
|
||||
[[nodiscard]] TextStyle labelStyle() const { return { labelFontSize(), mFontFace }; }
|
||||
|
||||
std::string mText;
|
||||
ImageId mIcon;
|
||||
IconPlacement mIconPlacement = IconPlacement::Leading;
|
||||
Style mStyle = Style::Normal;
|
||||
FontFace mFontFace = FontFace::Regular;
|
||||
bool mSelected = false;
|
||||
halnf mIconSize = 0;
|
||||
halnf mCornerRadius = -1;
|
||||
std::optional<RGBA> mFill;
|
||||
};
|
||||
|
||||
class GlyphButton : public Button {
|
||||
public:
|
||||
enum class Glyph { Options, Hide, Previous, Next, Up };
|
||||
|
||||
explicit GlyphButton(Glyph glyph);
|
||||
|
||||
[[nodiscard]] Glyph glyph() const { return mGlyph; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Painter& painter) override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
|
||||
private:
|
||||
void paintChevron(Painter& painter, halnf direction, const RGBA& color) const;
|
||||
void paintUpChevron(Painter& painter, const RGBA& color) const;
|
||||
|
||||
Glyph mGlyph;
|
||||
};
|
||||
}
|
||||
73
Widgets/src/components/controls/public/Collapsible.hpp
Normal file
73
Widgets/src/components/controls/public/Collapsible.hpp
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#pragma once
|
||||
|
||||
#include "Containers.hpp"
|
||||
#include "Control.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class CollapsibleHeader : public Control {
|
||||
public:
|
||||
explicit CollapsibleHeader(std::string title);
|
||||
|
||||
void setTitle(std::string title);
|
||||
[[nodiscard]] const std::string& title() const { return mTitle; }
|
||||
void setSummary(std::string summary);
|
||||
[[nodiscard]] const std::string& summary() const { return mSummary; }
|
||||
void setExpanded(bool expanded, bool animated = true);
|
||||
[[nodiscard]] halnf chevronTurn() const { return mTurn.value(); }
|
||||
void setAction(Widget* action);
|
||||
[[nodiscard]] Widget* action() const { return mAction; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Painter& painter) override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
|
||||
private:
|
||||
class HeaderLayout;
|
||||
friend class HeaderLayout;
|
||||
|
||||
[[nodiscard]] halnf actionWidth() const;
|
||||
|
||||
std::string mTitle;
|
||||
std::string mSummary;
|
||||
Widget* mAction = nullptr;
|
||||
AnimatedFloat mTurn{ 0 };
|
||||
};
|
||||
|
||||
class Collapsible : public Widget {
|
||||
public:
|
||||
explicit Collapsible(std::string title = {});
|
||||
|
||||
void setExpanded(bool expanded, bool animated = true);
|
||||
[[nodiscard]] bool isExpanded() const { return mExpanded; }
|
||||
void toggle() { setExpanded(!mExpanded); }
|
||||
|
||||
void setOnToggled(std::function<void(bool)> onToggled) { mOnToggled = std::move(onToggled); }
|
||||
|
||||
[[nodiscard]] Widget& body() { return mBody; }
|
||||
[[nodiscard]] CollapsibleHeader& header() { return mHeader; }
|
||||
void setHeaderAction(Widget* action) { mHeader.setAction(action); }
|
||||
[[nodiscard]] halnf openAmount() const { return mOpen.value(); }
|
||||
[[nodiscard]] bool isBodyShown() const { return mReveal.isVisible(); }
|
||||
[[nodiscard]] bool isTransitioning() const override { return mOpen.isAnimating(); }
|
||||
|
||||
protected:
|
||||
void onTick(halnf deltaMs) override;
|
||||
void finishTransitions() override;
|
||||
|
||||
private:
|
||||
class SectionLayout;
|
||||
friend class SectionLayout;
|
||||
|
||||
CollapsibleHeader mHeader;
|
||||
Widget mReveal;
|
||||
Widget mBody;
|
||||
bool mExpanded = false;
|
||||
AnimatedFloat mOpen{ 0, Motion::smooth() };
|
||||
std::function<void(bool)> mOnToggled;
|
||||
};
|
||||
}
|
||||
167
Widgets/src/components/controls/public/Containers.hpp
Normal file
167
Widgets/src/components/controls/public/Containers.hpp
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
#include "ScrollPhysics.hpp"
|
||||
#include "Animation.hpp"
|
||||
#include "Painter.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class Panel : public Widget {
|
||||
public:
|
||||
enum class Surface { None, Window, Base, Raised };
|
||||
|
||||
explicit Panel(Surface surface = Surface::Base);
|
||||
|
||||
void setSurface(Surface surface);
|
||||
void setCornerRadius(halnf radius);
|
||||
void setBorder(bool border);
|
||||
[[nodiscard]] halnf effectiveCornerRadius() const;
|
||||
|
||||
protected:
|
||||
void paint(Painter& painter) override;
|
||||
|
||||
private:
|
||||
Surface mSurface;
|
||||
halnf mCornerRadius = -1;
|
||||
bool mBorder = false;
|
||||
};
|
||||
|
||||
class Separator : public Widget {
|
||||
public:
|
||||
explicit Separator(Axis lineAxis = Axis::Horizontal);
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Painter& painter) override;
|
||||
[[nodiscard]] bool hitTest(Vec2F) const override { return false; }
|
||||
|
||||
private:
|
||||
Axis mLineAxis;
|
||||
};
|
||||
|
||||
class Spacer : public Widget {
|
||||
public:
|
||||
Spacer();
|
||||
explicit Spacer(halnf length);
|
||||
|
||||
protected:
|
||||
[[nodiscard]] bool hitTest(Vec2F) const override { return false; }
|
||||
};
|
||||
|
||||
class ScrollView;
|
||||
|
||||
class ScrollBar : public Widget {
|
||||
public:
|
||||
explicit ScrollBar(ScrollView& view);
|
||||
|
||||
[[nodiscard]] halnf revealAmount() const { return mReveal.value(); }
|
||||
[[nodiscard]] bool isDragging() const { return mDragging; }
|
||||
[[nodiscard]] RectF trackRect() const;
|
||||
[[nodiscard]] RectF thumbRect() const;
|
||||
|
||||
void flash(halnf durationMs);
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
void onHoverChanged() override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void paint(Painter& painter) override;
|
||||
|
||||
private:
|
||||
[[nodiscard]] halnf offsetForThumbAt(halnf pointerAlong) const;
|
||||
void refreshReveal();
|
||||
|
||||
ScrollView& mView;
|
||||
bool mDragging = false;
|
||||
halnf mGrabOffset = 0;
|
||||
halnf mFlashMs = 0;
|
||||
AnimatedFloat mReveal{ 0, Motion::fluid() };
|
||||
};
|
||||
|
||||
class ScrollView : public Widget {
|
||||
public:
|
||||
struct DragSample {
|
||||
uint4 timeMs;
|
||||
halnf offset;
|
||||
};
|
||||
|
||||
explicit ScrollView(Axis scrollAxis = Axis::Vertical);
|
||||
|
||||
void setContent(Widget* content);
|
||||
[[nodiscard]] Widget* content() const { return mContent; }
|
||||
|
||||
void scrollTo(halnf offset);
|
||||
void scrollBy(halnf delta);
|
||||
[[nodiscard]] halnf offset() const { return mOffset.target(); }
|
||||
[[nodiscard]] halnf maximumOffset() const;
|
||||
[[nodiscard]] halnf contentLength() const { return mContentLength; }
|
||||
[[nodiscard]] bool isScrollable() const { return maximumOffset() > 0; }
|
||||
|
||||
void setWheelStep(halnf step) { mWheelStep = step; }
|
||||
void setOnScrolled(std::function<void(halnf offset)> callback) { mOnScrolled = std::move(callback); }
|
||||
void setShowsScrollBar(bool shows) {
|
||||
mShowsScrollBar = shows;
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
[[nodiscard]] Axis scrollAxis() const { return mScrollAxis; }
|
||||
[[nodiscard]] halnf viewportLength() const;
|
||||
[[nodiscard]] halnf currentOffset() const { return mOffset.value(); }
|
||||
[[nodiscard]] bool isBouncing() const { return mStartStretch.isActive() || mEndStretch.isActive(); }
|
||||
[[nodiscard]] halnf stretchOffset() const;
|
||||
[[nodiscard]] const StretchEffect& startStretch() const { return mStartStretch; }
|
||||
[[nodiscard]] const StretchEffect& endStretch() const { return mEndStretch; }
|
||||
[[nodiscard]] const FlingSpline& fling() const { return mFling; }
|
||||
void jumpTo(halnf offset);
|
||||
|
||||
[[nodiscard]] ScrollBar& scrollBar() { return mScrollBar; }
|
||||
[[nodiscard]] halnf scrollBarReveal() const { return mScrollBar.revealAmount(); }
|
||||
[[nodiscard]] halnf flingVelocity() const { return mFlingVelocity; }
|
||||
[[nodiscard]] static halnf averageVelocity(const std::vector<DragSample>& samples, uint4 nowMs);
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void onEvent(Event& event) override;
|
||||
void filterDescendantEvent(Event& event) override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
|
||||
private:
|
||||
class ScrollLayout;
|
||||
friend class ScrollBar;
|
||||
|
||||
static constexpr halnf kDragThreshold = 8;
|
||||
static constexpr halnf kFlingMinimumSpeed = 0.1f;
|
||||
static constexpr halnf kFlingRecentMs = 100;
|
||||
|
||||
void keepOffsetInRange();
|
||||
void applyOffset();
|
||||
void trackDrag(Event& event);
|
||||
void dragBy(halnf delta);
|
||||
void rememberDragSample(uint4 timeMs);
|
||||
[[nodiscard]] halnf averageDragVelocity(uint4 nowMs) const;
|
||||
[[nodiscard]] bool startsDrag(const Event& event) const;
|
||||
|
||||
Axis mScrollAxis;
|
||||
Widget* mContent = nullptr;
|
||||
ScrollBar mScrollBar{ *this };
|
||||
AnimatedFloat mOffset;
|
||||
halnf mContentLength = 0;
|
||||
halnf mWheelStep = 48;
|
||||
std::function<void(halnf)> mOnScrolled;
|
||||
halnf mReportedOffset = 0;
|
||||
bool mShowsScrollBar = true;
|
||||
bool mDragPending = false;
|
||||
bool mDragging = false;
|
||||
Vec2F mDragStart = { 0, 0 };
|
||||
halnf mDragStartOffset = 0;
|
||||
std::vector<DragSample> mDragSamples;
|
||||
halnf mFlingVelocity = 0;
|
||||
halnf mLastDragAlong = 0;
|
||||
FlingSpline mFling;
|
||||
StretchEffect mStartStretch;
|
||||
StretchEffect mEndStretch;
|
||||
};
|
||||
}
|
||||
50
Widgets/src/components/controls/public/Control.hpp
Normal file
50
Widgets/src/components/controls/public/Control.hpp
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#pragma once
|
||||
|
||||
#include "Animation.hpp"
|
||||
#include "Painter.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class Control : public Widget {
|
||||
public:
|
||||
Control();
|
||||
|
||||
void setOnActivate(std::function<void()> onActivate) { mOnActivate = std::move(onActivate); }
|
||||
void setOnClicked(std::function<void(const Event&)> onClicked) { mOnClicked = std::move(onClicked); }
|
||||
void setOnLongPress(std::function<void()> onLongPress) { mOnLongPress = std::move(onLongPress); }
|
||||
[[nodiscard]] bool hasLongPress() const { return (bool) mOnLongPress; }
|
||||
|
||||
void activate();
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void onHoverChanged() override;
|
||||
[[nodiscard]] bool acceptsKeyboardFocus() const override { return true; }
|
||||
|
||||
[[nodiscard]] virtual RGBA backgroundTarget() const;
|
||||
void onThemeChanged() override;
|
||||
void onPressedChanged() override;
|
||||
void onOwnedPopupChanged() override;
|
||||
void onAttached() override;
|
||||
virtual void onActivated() {}
|
||||
|
||||
[[nodiscard]] RGBA background() const { return mBackground.value(); }
|
||||
virtual void refreshVisualState();
|
||||
|
||||
void animate(AnimatedFloat& value, halnf target);
|
||||
|
||||
private:
|
||||
void runAnimations();
|
||||
|
||||
AnimatedColor mBackground;
|
||||
std::vector<AnimatedFloat*> mRunningFloats;
|
||||
std::function<void()> mOnActivate;
|
||||
std::function<void(const Event&)> mOnClicked;
|
||||
std::function<void()> mOnLongPress;
|
||||
};
|
||||
}
|
||||
94
Widgets/src/components/controls/public/Label.hpp
Normal file
94
Widgets/src/components/controls/public/Label.hpp
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
#pragma once
|
||||
|
||||
#include "Tooltip.hpp"
|
||||
#include "Widget.hpp"
|
||||
#include "Painter.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class Label : public Widget {
|
||||
public:
|
||||
enum class Tone { Normal, Muted, Warning };
|
||||
|
||||
explicit Label(std::string text = {});
|
||||
|
||||
void setText(std::string text);
|
||||
[[nodiscard]] const std::string& text() const { return mText; }
|
||||
|
||||
void setAlignment(TextAlign alignment);
|
||||
[[nodiscard]] TextAlign alignment() const { return mAlignment; }
|
||||
void setFontSize(halnf fontSize);
|
||||
void setTone(Tone tone);
|
||||
void setWrap(bool wrap);
|
||||
[[nodiscard]] bool wraps() const { return mWrap; }
|
||||
void setMaximumLines(std::optional<size_t> lines);
|
||||
[[nodiscard]] std::optional<size_t> maximumLines() const { return mMaximumLines; }
|
||||
void setElidesToHeight(bool elides);
|
||||
[[nodiscard]] bool elidesToHeight() const { return mElidesToHeight; }
|
||||
[[nodiscard]] std::vector<std::string> visibleLines() const;
|
||||
[[nodiscard]] const std::vector<std::string>& linesFor(halnf width) const;
|
||||
void setReservedText(std::string text);
|
||||
[[nodiscard]] const std::string& reservedText() const { return mReservedText; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Painter& painter) override;
|
||||
|
||||
public:
|
||||
[[nodiscard]] halnf effectiveFontSize() const;
|
||||
|
||||
private:
|
||||
struct TextLayoutKey {
|
||||
halnf width = -1;
|
||||
halnf fontSize = 0;
|
||||
const TextMetrics* metrics = nullptr;
|
||||
|
||||
bool operator==(const TextLayoutKey&) const = default;
|
||||
};
|
||||
|
||||
struct LaidOutLines {
|
||||
std::optional<TextLayoutKey> key;
|
||||
std::vector<std::string> lines;
|
||||
};
|
||||
|
||||
struct WidestLine {
|
||||
std::optional<TextLayoutKey> key;
|
||||
halnf width = 0;
|
||||
};
|
||||
|
||||
[[nodiscard]] halnf lineHeight() const;
|
||||
void paintLines(Painter& painter, const std::vector<std::string>& lines, const RGBA& color) const;
|
||||
[[nodiscard]] std::vector<std::string> layOutLines(halnf width) const;
|
||||
[[nodiscard]] std::vector<std::string> limitedLines(std::vector<std::string> lines, halnf width) const;
|
||||
[[nodiscard]] std::vector<std::string> elidedLines(std::vector<std::string> lines, size_t count, halnf width) const;
|
||||
[[nodiscard]] size_t linesFittingHeight() const;
|
||||
[[nodiscard]] halnf widestLine() const;
|
||||
[[nodiscard]] TextLayoutKey layoutKey(halnf width) const;
|
||||
void invalidateLines();
|
||||
|
||||
std::string mText;
|
||||
std::string mReservedText;
|
||||
std::vector<std::string> mLines;
|
||||
TextAlign mAlignment = TextAlign::Start;
|
||||
halnf mFontSize = 0;
|
||||
Tone mTone = Tone::Normal;
|
||||
bool mWrap = false;
|
||||
std::optional<size_t> mMaximumLines;
|
||||
bool mElidesToHeight = false;
|
||||
mutable LaidOutLines mLaidOut;
|
||||
mutable WidestLine mWidestLine;
|
||||
};
|
||||
|
||||
[[nodiscard]] std::vector<std::string> wrapText(
|
||||
const std::string& text,
|
||||
halnf width,
|
||||
const std::function<halnf(const std::string&)>& measure,
|
||||
std::optional<size_t> maximumLines = std::nullopt
|
||||
);
|
||||
|
||||
}
|
||||
28
Widgets/src/components/controls/public/Notice.hpp
Normal file
28
Widgets/src/components/controls/public/Notice.hpp
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#pragma once
|
||||
|
||||
#include "Animation.hpp"
|
||||
#include "Label.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class Notice : public TooltipBubble {
|
||||
public:
|
||||
static constexpr halnf kDefaultDurationMs = 2500;
|
||||
|
||||
Notice();
|
||||
~Notice() override;
|
||||
|
||||
void show(Widget& anchor, std::string text, halnf durationMs = kDefaultDurationMs);
|
||||
void hide();
|
||||
[[nodiscard]] bool isShown() const;
|
||||
[[nodiscard]] halnf remainingMs() const { return mRemainingMs; }
|
||||
|
||||
protected:
|
||||
void onTick(halnf deltaMs) override;
|
||||
|
||||
private:
|
||||
halnf mRemainingMs = 0;
|
||||
};
|
||||
}
|
||||
42
Widgets/src/components/controls/public/ResizeHandle.hpp
Normal file
42
Widgets/src/components/controls/public/ResizeHandle.hpp
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#pragma once
|
||||
|
||||
#include "Animation.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include <functional>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class ResizeHandle : public Widget {
|
||||
public:
|
||||
explicit ResizeHandle(Axis dragAxis);
|
||||
|
||||
void setOnDragStarted(std::function<void()> onStarted) { mOnStarted = std::move(onStarted); }
|
||||
void setOnDragged(std::function<void(halnf windowCoordinate)> onDragged) { mOnDragged = std::move(onDragged); }
|
||||
void setOnDragFinished(std::function<void()> onFinished) { mOnFinished = std::move(onFinished); }
|
||||
|
||||
[[nodiscard]] Axis dragAxis() const { return mDragAxis; }
|
||||
[[nodiscard]] bool isDragging() const { return mDragging; }
|
||||
[[nodiscard]] halnf revealAmount() const { return mReveal.value(); }
|
||||
|
||||
[[nodiscard]] RectF lineRect() const;
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
void onHoverChanged() override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void paint(Painter& painter) override;
|
||||
|
||||
private:
|
||||
void refreshReveal();
|
||||
|
||||
Axis mDragAxis;
|
||||
bool mDragging = false;
|
||||
halnf mGrabOffset = 0;
|
||||
AnimatedFloat mReveal{ 0, Motion::quick() };
|
||||
|
||||
std::function<void()> mOnStarted;
|
||||
std::function<void(halnf)> mOnDragged;
|
||||
std::function<void()> mOnFinished;
|
||||
};
|
||||
}
|
||||
124
Widgets/src/components/controls/public/Slider.hpp
Normal file
124
Widgets/src/components/controls/public/Slider.hpp
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
#pragma once
|
||||
|
||||
#include "Control.hpp"
|
||||
#include "Label.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class Slider : public Control {
|
||||
public:
|
||||
enum class Scale { Linear, Logarithmic };
|
||||
|
||||
explicit Slider(halnf minimum = 0, halnf maximum = 1, halnf value = 0);
|
||||
|
||||
void setScale(Scale scale);
|
||||
[[nodiscard]] Scale scale() const { return mScale; }
|
||||
void setOrientation(Axis orientation);
|
||||
[[nodiscard]] Axis orientation() const { return mOrientation; }
|
||||
[[nodiscard]] halnf fractionForValue(halnf value) const;
|
||||
[[nodiscard]] halnf valueForFraction(halnf fraction) const;
|
||||
|
||||
void setRange(halnf minimum, halnf maximum);
|
||||
void setValue(halnf value);
|
||||
void setStep(halnf step);
|
||||
|
||||
[[nodiscard]] halnf value() const { return mValue; }
|
||||
[[nodiscard]] halnf minimum() const { return mMinimum; }
|
||||
[[nodiscard]] halnf maximum() const { return mMaximum; }
|
||||
[[nodiscard]] halnf fraction() const;
|
||||
[[nodiscard]] Vec2F windowPointForValue(halnf value) const;
|
||||
[[nodiscard]] bool isDragging() const { return mDragging; }
|
||||
|
||||
void setOnChanged(std::function<void(halnf)> onChanged) { mOnChanged = std::move(onChanged); }
|
||||
void setOnValueShown(std::function<void()> onValueShown) { mOnValueShown = std::move(onValueShown); }
|
||||
void setOnDragging(std::function<void(bool)> onDragging) { mOnDragging = std::move(onDragging); }
|
||||
void setTrackGradient(std::function<std::vector<RGBA>()> stops) {
|
||||
mTrackGradient = std::move(stops);
|
||||
requestPaint();
|
||||
}
|
||||
[[nodiscard]] std::vector<RGBA> trackGradient() const {
|
||||
return mTrackGradient ? mTrackGradient() : std::vector<RGBA>();
|
||||
}
|
||||
void setTickMarks(std::vector<halnf> fractions);
|
||||
[[nodiscard]] const std::vector<halnf>& tickMarks() const { return mTickMarks; }
|
||||
[[nodiscard]] std::vector<halnf> visibleTickMarks() const;
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void onEvent(Event& event) override;
|
||||
void paint(Painter& painter) override;
|
||||
void refreshVisualState() override;
|
||||
|
||||
private:
|
||||
[[nodiscard]] RectF trackRect() const;
|
||||
[[nodiscard]] bool showsHandle() const;
|
||||
[[nodiscard]] halnf valueAt(Vec2F local) const;
|
||||
[[nodiscard]] bool isVertical() const { return mOrientation == Axis::Vertical; }
|
||||
[[nodiscard]] Vec2F pointOnTrack(const RectF& track, halnf amount) const;
|
||||
void paintTickMarks(Painter& painter, const RectF& track) const;
|
||||
[[nodiscard]] halnf snapped(halnf value) const;
|
||||
[[nodiscard]] bool isLogarithmic() const;
|
||||
void stepBy(halnf steps);
|
||||
void changeValue(halnf value);
|
||||
|
||||
halnf mMinimum;
|
||||
halnf mMaximum;
|
||||
halnf mValue;
|
||||
halnf mStep = 0;
|
||||
Scale mScale = Scale::Linear;
|
||||
Axis mOrientation = Axis::Horizontal;
|
||||
bool mDragging = false;
|
||||
AnimatedFloat mHandleReveal{ 0 };
|
||||
std::function<void(halnf)> mOnChanged;
|
||||
std::function<void()> mOnValueShown;
|
||||
std::function<void(bool)> mOnDragging;
|
||||
std::function<std::vector<RGBA>()> mTrackGradient;
|
||||
std::vector<halnf> mTickMarks;
|
||||
};
|
||||
|
||||
[[nodiscard]] std::vector<halnf> groupTickMarks(std::vector<halnf> fractions, halnf minimumGap);
|
||||
|
||||
class LabeledSlider : public Widget {
|
||||
public:
|
||||
explicit LabeledSlider(std::string name, halnf minimum = 0, halnf maximum = 1, halnf value = 0);
|
||||
|
||||
void setValueFormatter(std::function<std::string(halnf)> formatter);
|
||||
void setName(std::string name) { mName.setText(std::move(name)); }
|
||||
[[nodiscard]] const std::string& name() const { return mName.text(); }
|
||||
|
||||
[[nodiscard]] Slider& slider() { return mSlider; }
|
||||
void setScale(Slider::Scale scale) { mSlider.setScale(scale); }
|
||||
[[nodiscard]] halnf value() const { return mSlider.value(); }
|
||||
void setValue(halnf value) { mSlider.setValue(value); }
|
||||
void setOnChanged(std::function<void(halnf)> onChanged);
|
||||
|
||||
private:
|
||||
void refreshValueText();
|
||||
|
||||
Widget mHeader;
|
||||
Label mName;
|
||||
Label mValueText;
|
||||
Slider mSlider;
|
||||
std::function<std::string(halnf)> mFormatter;
|
||||
std::function<void(halnf)> mOnChanged;
|
||||
};
|
||||
|
||||
class ProgressBar : public Widget {
|
||||
public:
|
||||
ProgressBar();
|
||||
|
||||
void setFraction(halnf fraction);
|
||||
[[nodiscard]] halnf fraction() const { return mFraction.target(); }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void paint(Painter& painter) override;
|
||||
|
||||
private:
|
||||
AnimatedFloat mFraction;
|
||||
};
|
||||
}
|
||||
193
Widgets/src/components/controls/public/TextArea.hpp
Normal file
193
Widgets/src/components/controls/public/TextArea.hpp
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
#pragma once
|
||||
|
||||
#include "Containers.hpp"
|
||||
#include "Control.hpp"
|
||||
#include "ResizeHandle.hpp"
|
||||
#include "TextEditBuffer.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
struct TextLayoutLine {
|
||||
size_t start = 0;
|
||||
size_t end = 0;
|
||||
};
|
||||
|
||||
struct HighlightSpan {
|
||||
size_t start = 0;
|
||||
size_t end = 0;
|
||||
RGBA color;
|
||||
};
|
||||
|
||||
using TextHighlighter = std::function<std::vector<HighlightSpan>(const std::string& text)>;
|
||||
|
||||
class TextAreaEditor : public Control {
|
||||
public:
|
||||
TextAreaEditor();
|
||||
|
||||
void setText(std::string text);
|
||||
[[nodiscard]] const std::string& text() const { return mBuffer.text(); }
|
||||
void setPlaceholder(std::string placeholder);
|
||||
void setFillsWithWindowColor(bool window);
|
||||
[[nodiscard]] TextEditBuffer& buffer() { return mBuffer; }
|
||||
[[nodiscard]] const TextEditBuffer& buffer() const { return mBuffer; }
|
||||
|
||||
void setCaret(size_t position, bool extendSelection = false);
|
||||
void selectAll();
|
||||
|
||||
[[nodiscard]] const std::vector<TextLayoutLine>& lines() const { return mLines; }
|
||||
[[nodiscard]] size_t lineOf(size_t position) const;
|
||||
[[nodiscard]] RectF caretRect() const;
|
||||
[[nodiscard]] size_t positionAt(Vec2F localPoint) const;
|
||||
[[nodiscard]] halnf lineHeight() const;
|
||||
[[nodiscard]] bool isEditing() const { return mFocused; }
|
||||
|
||||
void setOnEdited(std::function<void(const std::string&)> callback) { mOnEdited = std::move(callback); }
|
||||
void setOnCommitted(std::function<void(const std::string&)> callback) { mOnCommitted = std::move(callback); }
|
||||
void setOnCaretMoved(std::function<void()> callback) { mOnCaretMoved = std::move(callback); }
|
||||
void setOnEditingStarted(std::function<void()> callback) { mOnEditingStarted = std::move(callback); }
|
||||
void setPasteHandler(std::function<bool()> handler) { mPasteHandler = std::move(handler); }
|
||||
void insert(const std::string& text);
|
||||
|
||||
void setFontFace(FontFace face);
|
||||
[[nodiscard]] FontFace fontFace() const { return mFace; }
|
||||
void setFontScale(halnf scale);
|
||||
[[nodiscard]] halnf fontScale() const { return mFontScale; }
|
||||
[[nodiscard]] halnf textTop() const;
|
||||
[[nodiscard]] halnf topOfLineContaining(size_t position) const;
|
||||
[[nodiscard]] size_t positionAtTop(halnf localY) const;
|
||||
void setHighlighter(TextHighlighter highlighter);
|
||||
[[nodiscard]] const std::vector<HighlightSpan>& highlights() const;
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void onEvent(Event& event) override;
|
||||
void paint(Painter& painter) override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void onKeyboardFocusChanged() override;
|
||||
void onDetached() override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
|
||||
private:
|
||||
struct AdvancesKey {
|
||||
halnf fontSize = 0;
|
||||
FontFace face = FontFace::Regular;
|
||||
const TextMetrics* metrics = nullptr;
|
||||
|
||||
bool operator==(const AdvancesKey&) const = default;
|
||||
};
|
||||
|
||||
void handlePointer(Event& event);
|
||||
void handleKey(Event& event);
|
||||
void moveVertically(int lines, bool extendSelection);
|
||||
void moveToLineEdge(bool end, bool extendSelection);
|
||||
void copySelection(bool cut);
|
||||
void paste();
|
||||
void edited();
|
||||
void caretMoved();
|
||||
void restartBlink();
|
||||
void updateTextInputArea();
|
||||
|
||||
void relayoutText(halnf width) const;
|
||||
void ensureLayout() const;
|
||||
void ensureAdvances() const;
|
||||
void invalidateAdvances();
|
||||
[[nodiscard]] halnf textWidthOf(size_t from, size_t to) const;
|
||||
[[nodiscard]] halnf columnX(size_t position) const;
|
||||
[[nodiscard]] size_t positionInLine(const TextLayoutLine& line, halnf x) const;
|
||||
[[nodiscard]] halnf wrapWidth() const;
|
||||
[[nodiscard]] halnf fontSize() const;
|
||||
[[nodiscard]] TextStyle textStyle() const { return { fontSize(), mFace }; }
|
||||
void invalidateTextLayout();
|
||||
void paintLineText(Painter& painter, const TextLayoutLine& line, const RectF& box, const RGBA& baseColor) const;
|
||||
|
||||
TextEditBuffer mBuffer{ TextEditBuffer::Lines::Multiple };
|
||||
FontFace mFace = FontFace::Regular;
|
||||
halnf mFontScale = 1;
|
||||
TextHighlighter mHighlighter;
|
||||
mutable std::vector<HighlightSpan> mHighlights;
|
||||
mutable bool mHighlightsDirty = true;
|
||||
std::string mPlaceholder;
|
||||
bool mWindowColored = false;
|
||||
mutable std::vector<TextLayoutLine> mLines;
|
||||
mutable std::vector<halnf> mParagraphAdvances;
|
||||
mutable std::vector<size_t> mParagraphStarts;
|
||||
mutable std::optional<AdvancesKey> mAdvancesKey;
|
||||
mutable halnf mLayoutWidth = -1;
|
||||
mutable bool mLayoutDirty = true;
|
||||
halnf mPreferredX = -1;
|
||||
halnf mBlinkMs = 0;
|
||||
bool mFocused = false;
|
||||
bool mDragging = false;
|
||||
std::function<void(const std::string&)> mOnEdited;
|
||||
std::function<void(const std::string&)> mOnCommitted;
|
||||
std::function<void()> mOnCaretMoved;
|
||||
std::function<void()> mOnEditingStarted;
|
||||
std::function<bool()> mPasteHandler;
|
||||
};
|
||||
|
||||
class TextArea : public Widget {
|
||||
public:
|
||||
explicit TextArea(std::string text = {});
|
||||
|
||||
void setText(std::string text) { mEditor.setText(std::move(text)); }
|
||||
[[nodiscard]] const std::string& text() const { return mEditor.text(); }
|
||||
void setPlaceholder(std::string placeholder) { mEditor.setPlaceholder(std::move(placeholder)); }
|
||||
|
||||
void setOnEdited(std::function<void(const std::string&)> callback) { mEditor.setOnEdited(std::move(callback)); }
|
||||
void setOnCommitted(std::function<void(const std::string&)> callback) {
|
||||
mEditor.setOnCommitted(std::move(callback));
|
||||
}
|
||||
void setOnEditingStarted(std::function<void()> callback) { mEditor.setOnEditingStarted(std::move(callback)); }
|
||||
void setOnCaretMoved(std::function<void()> callback) { mOnCaretMoved = std::move(callback); }
|
||||
[[nodiscard]] bool isEditing() const { return mEditor.isEditing(); }
|
||||
|
||||
void setBoxHeight(halnf height);
|
||||
[[nodiscard]] halnf boxHeight() const { return mBoxHeight; }
|
||||
void setHeightRange(halnf minimum, halnf maximum);
|
||||
void setFillsHeight(bool fills);
|
||||
[[nodiscard]] bool fillsHeight() const { return mFillsHeight; }
|
||||
[[nodiscard]] halnf minimumHeight() const { return mMinimumHeight; }
|
||||
[[nodiscard]] halnf maximumHeight() const { return mMaximumHeight; }
|
||||
|
||||
void setFontFace(FontFace face) { mEditor.setFontFace(face); }
|
||||
void setFillsWithWindowColor(bool window);
|
||||
void setFontScale(halnf scale);
|
||||
void setHighlighter(TextHighlighter highlighter) { mEditor.setHighlighter(std::move(highlighter)); }
|
||||
|
||||
[[nodiscard]] TextAreaEditor& editor() { return mEditor; }
|
||||
[[nodiscard]] const TextAreaEditor& editor() const { return mEditor; }
|
||||
[[nodiscard]] ScrollView& scroll() { return mScroll; }
|
||||
[[nodiscard]] const ScrollView& scroll() const { return mScroll; }
|
||||
[[nodiscard]] ResizeHandle& resizeHandle() { return mHandle; }
|
||||
|
||||
protected:
|
||||
void paint(Painter& painter) override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
|
||||
private:
|
||||
class AreaLayout;
|
||||
|
||||
void revealCaret();
|
||||
void applyBoxHeight();
|
||||
void viewportArranged(halnf height);
|
||||
[[nodiscard]] halnf caretRevealOffset(halnf viewportHeight) const;
|
||||
|
||||
ScrollView mScroll{ Axis::Vertical };
|
||||
TextAreaEditor mEditor;
|
||||
bool mWindowColored = false;
|
||||
ResizeHandle mHandle{ Axis::Vertical };
|
||||
halnf mBoxHeight = 96;
|
||||
halnf mMinimumHeight = 48;
|
||||
halnf mMaximumHeight = 600;
|
||||
halnf mDragStartHeight = 0;
|
||||
halnf mDragStartCoordinate = 0;
|
||||
halnf mArrangedViewportHeight = -1;
|
||||
bool mFillsHeight = false;
|
||||
std::function<void()> mOnCaretMoved;
|
||||
};
|
||||
}
|
||||
58
Widgets/src/components/controls/public/TextEditBuffer.hpp
Normal file
58
Widgets/src/components/controls/public/TextEditBuffer.hpp
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class TextEditBuffer {
|
||||
public:
|
||||
enum class Lines { Single, Multiple };
|
||||
|
||||
explicit TextEditBuffer(Lines lines = Lines::Single, std::string text = {});
|
||||
|
||||
void setText(std::string text);
|
||||
[[nodiscard]] const std::string& text() const { return mText; }
|
||||
|
||||
void setMaximumLength(size_t maximumLength);
|
||||
void setCharacterFilter(std::function<bool(const std::string&)> accepts) { mAccepts = std::move(accepts); }
|
||||
|
||||
[[nodiscard]] size_t caret() const { return mCaret; }
|
||||
[[nodiscard]] size_t anchor() const { return mAnchor; }
|
||||
[[nodiscard]] size_t selectionStart() const { return mAnchor < mCaret ? mAnchor : mCaret; }
|
||||
[[nodiscard]] size_t selectionEnd() const { return mAnchor < mCaret ? mCaret : mAnchor; }
|
||||
[[nodiscard]] bool hasSelection() const { return mAnchor != mCaret; }
|
||||
[[nodiscard]] std::string selectedText() const;
|
||||
|
||||
void setCaret(size_t position, bool extendSelection = false);
|
||||
void select(size_t anchor, size_t caret);
|
||||
void selectAll();
|
||||
void collapseSelection() { mAnchor = mCaret; }
|
||||
|
||||
bool insert(const std::string& text);
|
||||
bool eraseSelection();
|
||||
bool erase(size_t from, size_t to);
|
||||
bool eraseBackward(bool wholeWord);
|
||||
bool eraseForward(bool wholeWord);
|
||||
|
||||
[[nodiscard]] size_t previousBoundary(size_t position) const;
|
||||
[[nodiscard]] size_t nextBoundary(size_t position) const;
|
||||
[[nodiscard]] size_t previousWord(size_t position) const;
|
||||
[[nodiscard]] size_t nextWord(size_t position) const;
|
||||
[[nodiscard]] size_t lineStart(size_t position) const;
|
||||
[[nodiscard]] size_t lineEnd(size_t position) const;
|
||||
|
||||
private:
|
||||
[[nodiscard]] std::string acceptedCharacters(const std::string& text) const;
|
||||
[[nodiscard]] bool accepts(const std::string& character) const;
|
||||
[[nodiscard]] size_t clampedToBoundary(size_t position) const;
|
||||
|
||||
Lines mLines;
|
||||
std::string mText;
|
||||
size_t mCaret = 0;
|
||||
size_t mAnchor = 0;
|
||||
size_t mMaximumLength = 0;
|
||||
std::function<bool(const std::string&)> mAccepts;
|
||||
};
|
||||
}
|
||||
128
Widgets/src/components/controls/public/TextField.hpp
Normal file
128
Widgets/src/components/controls/public/TextField.hpp
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
#pragma once
|
||||
|
||||
#include "Button.hpp"
|
||||
#include "Control.hpp"
|
||||
#include "TextEditBuffer.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class TextField : public Control {
|
||||
public:
|
||||
explicit TextField(std::string text = {});
|
||||
|
||||
void setText(std::string text);
|
||||
[[nodiscard]] const std::string& text() const { return mBuffer.text(); }
|
||||
|
||||
void setPlaceholder(std::string placeholder);
|
||||
void setFontSize(halnf fontSize);
|
||||
void setMaximumLength(size_t maximumLength) { mBuffer.setMaximumLength(maximumLength); }
|
||||
void setCharacterFilter(std::function<bool(const std::string&)> accepts) {
|
||||
mBuffer.setCharacterFilter(std::move(accepts));
|
||||
}
|
||||
|
||||
[[nodiscard]] size_t caret() const { return mBuffer.caret(); }
|
||||
[[nodiscard]] size_t selectionAnchor() const { return mBuffer.anchor(); }
|
||||
[[nodiscard]] bool hasSelection() const { return mBuffer.hasSelection(); }
|
||||
[[nodiscard]] std::string selectedText() const { return mBuffer.selectedText(); }
|
||||
void selectAll();
|
||||
void setCaret(size_t position, bool extendSelection = false);
|
||||
|
||||
void setOnEdited(std::function<void(const std::string&)> onEdited) { mOnEdited = std::move(onEdited); }
|
||||
void setOnEditingStarted(std::function<void()> callback) { mOnEditingStarted = std::move(callback); }
|
||||
void setOnCommitted(std::function<void(const std::string&)> onCommitted) { mOnCommitted = std::move(onCommitted); }
|
||||
|
||||
[[nodiscard]] bool isEditing() const { return hasKeyboardFocus(); }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void onEvent(Event& event) override;
|
||||
void paint(Painter& painter) override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void onKeyboardFocusChanged() override;
|
||||
void onDetached() override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
|
||||
private:
|
||||
void handleKey(Event& event);
|
||||
void insert(const std::string& text);
|
||||
void commit();
|
||||
void edited();
|
||||
void updateTextInputArea();
|
||||
void restartBlink();
|
||||
|
||||
[[nodiscard]] size_t positionAt(halnf localX) const;
|
||||
[[nodiscard]] halnf offsetOf(size_t position) const;
|
||||
[[nodiscard]] RectF textBox() const;
|
||||
[[nodiscard]] halnf fontSize() const;
|
||||
[[nodiscard]] halnf lineHeight() const;
|
||||
|
||||
TextEditBuffer mBuffer;
|
||||
std::string mPlaceholder;
|
||||
halnf mFontSize = 0;
|
||||
halnf mScroll = 0;
|
||||
halnf mBlinkMs = 0;
|
||||
bool mDragging = false;
|
||||
bool mFocused = false;
|
||||
bool mFocusedByPress = false;
|
||||
std::function<void(const std::string&)> mOnEdited;
|
||||
std::function<void()> mOnEditingStarted;
|
||||
std::function<void(const std::string&)> mOnCommitted;
|
||||
};
|
||||
|
||||
class StepButton : public Button {
|
||||
public:
|
||||
explicit StepButton(int direction);
|
||||
|
||||
[[nodiscard]] int direction() const { return mDirection; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] bool hasIcon() const override { return true; }
|
||||
void paintIcon(Painter& painter, const RectF& box) override;
|
||||
|
||||
private:
|
||||
int mDirection;
|
||||
};
|
||||
|
||||
class NumberField : public Widget {
|
||||
public:
|
||||
NumberField(halnf minimum, halnf maximum, halnf value, halnf step = 1);
|
||||
|
||||
void setValue(halnf value);
|
||||
[[nodiscard]] halnf value() const { return mValue; }
|
||||
void setRange(halnf minimum, halnf maximum);
|
||||
void setStep(halnf step) { mStep = step; }
|
||||
void setDecimals(int decimals);
|
||||
void setSuffix(std::string suffix);
|
||||
|
||||
void setOnChanged(std::function<void(halnf)> onChanged) { mOnChanged = std::move(onChanged); }
|
||||
|
||||
[[nodiscard]] TextField& field() { return mField; }
|
||||
[[nodiscard]] StepButton& decrementButton() { return mDecrement; }
|
||||
[[nodiscard]] StepButton& incrementButton() { return mIncrement; }
|
||||
|
||||
void stepBy(int steps);
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
|
||||
private:
|
||||
void apply(halnf value);
|
||||
void refreshText();
|
||||
[[nodiscard]] halnf clamped(halnf value) const;
|
||||
[[nodiscard]] std::string format(halnf value) const;
|
||||
|
||||
halnf mMinimum;
|
||||
halnf mMaximum;
|
||||
halnf mValue;
|
||||
halnf mStep;
|
||||
int mDecimals = 0;
|
||||
std::string mSuffix;
|
||||
StepButton mDecrement{ -1 };
|
||||
TextField mField;
|
||||
StepButton mIncrement{ 1 };
|
||||
std::function<void(halnf)> mOnChanged;
|
||||
};
|
||||
}
|
||||
99
Widgets/src/components/controls/public/Toggles.hpp
Normal file
99
Widgets/src/components/controls/public/Toggles.hpp
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
#pragma once
|
||||
|
||||
#include "Control.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class ToggleControl : public Control {
|
||||
public:
|
||||
explicit ToggleControl(std::string text);
|
||||
|
||||
void setChecked(bool checked);
|
||||
[[nodiscard]] bool isChecked() const { return mChecked; }
|
||||
|
||||
void setOnChanged(std::function<void(bool)> onChanged) { mOnChanged = std::move(onChanged); }
|
||||
|
||||
void setText(std::string text);
|
||||
[[nodiscard]] const std::string& text() const { return mText; }
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void onActivated() override;
|
||||
void paint(Painter& painter) override;
|
||||
|
||||
virtual void paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) = 0;
|
||||
[[nodiscard]] virtual halnf indicatorWidth() const;
|
||||
[[nodiscard]] virtual bool togglesOnActivate() const { return true; }
|
||||
|
||||
[[nodiscard]] RectF indicatorBox() const;
|
||||
|
||||
private:
|
||||
std::string mText;
|
||||
bool mChecked = false;
|
||||
AnimatedFloat mCheckedAmount;
|
||||
std::function<void(bool)> mOnChanged;
|
||||
};
|
||||
|
||||
class Checkbox : public ToggleControl {
|
||||
public:
|
||||
explicit Checkbox(std::string text = {});
|
||||
|
||||
protected:
|
||||
void paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) override;
|
||||
};
|
||||
|
||||
class Switch : public ToggleControl {
|
||||
public:
|
||||
explicit Switch(std::string text = {});
|
||||
|
||||
protected:
|
||||
void paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) override;
|
||||
[[nodiscard]] halnf indicatorWidth() const override;
|
||||
};
|
||||
|
||||
class RadioGroup;
|
||||
|
||||
class RadioButton : public ToggleControl {
|
||||
public:
|
||||
explicit RadioButton(std::string text = {});
|
||||
~RadioButton() override;
|
||||
|
||||
[[nodiscard]] RadioGroup* group() const { return mGroup; }
|
||||
|
||||
protected:
|
||||
void paintIndicator(Painter& painter, const RectF& box, halnf checkedAmount) override;
|
||||
[[nodiscard]] bool togglesOnActivate() const override { return false; }
|
||||
void onActivated() override;
|
||||
|
||||
private:
|
||||
friend class RadioGroup;
|
||||
|
||||
RadioGroup* mGroup = nullptr;
|
||||
};
|
||||
|
||||
class RadioGroup {
|
||||
public:
|
||||
RadioGroup() = default;
|
||||
~RadioGroup();
|
||||
|
||||
RadioGroup(const RadioGroup&) = delete;
|
||||
RadioGroup& operator=(const RadioGroup&) = delete;
|
||||
|
||||
void add(RadioButton& button);
|
||||
void remove(RadioButton& button);
|
||||
|
||||
void select(RadioButton& button);
|
||||
void selectIndex(size_t index);
|
||||
[[nodiscard]] long selectedIndex() const;
|
||||
[[nodiscard]] RadioButton* selected() const;
|
||||
|
||||
void setOnChanged(std::function<void(size_t)> onChanged) { mOnChanged = std::move(onChanged); }
|
||||
|
||||
private:
|
||||
std::vector<RadioButton*> mButtons;
|
||||
std::function<void(size_t)> mOnChanged;
|
||||
};
|
||||
}
|
||||
609
Widgets/src/components/debug/private/DebugInspector.cpp
Normal file
609
Widgets/src/components/debug/private/DebugInspector.cpp
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
#include "DebugInspector.hpp"
|
||||
|
||||
#include "Assert.hpp"
|
||||
#include "Layout.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include "imgui.h"
|
||||
#include "implot.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr size_t kMaximumEventRecords = 400;
|
||||
constexpr size_t kMaximumLayoutRecords = 400;
|
||||
constexpr float kMinimumPanelFraction = 0.2f;
|
||||
constexpr float kMaximumPanelFraction = 0.6f;
|
||||
constexpr float kMinimumPanelWidth = 320;
|
||||
|
||||
const RGBA kOutlineColor = { 0.2f, 0.8f, 1.0f, 0.35f };
|
||||
const RGBA kHoverOutlineColor = { 1.0f, 0.75f, 0.1f, 0.9f };
|
||||
const RGBA kSelectionColor = { 1.0f, 0.25f, 0.35f, 0.95f };
|
||||
const RGBA kSelectionFill = { 1.0f, 0.25f, 0.35f, 0.12f };
|
||||
|
||||
float nanosecondsToMs(time_ns nanoseconds) { return (float) ((double) nanoseconds / 1.0e6); }
|
||||
|
||||
constexpr AxisSizing::Mode kSizingModes[] = {
|
||||
AxisSizing::Mode::Fit, AxisSizing::Mode::Fixed, AxisSizing::Mode::Preferred, AxisSizing::Mode::Fill
|
||||
};
|
||||
|
||||
const char* sizingModeName(AxisSizing::Mode mode) {
|
||||
switch (mode) {
|
||||
case AxisSizing::Mode::Fit: return "fit";
|
||||
case AxisSizing::Mode::Fixed: return "fixed";
|
||||
case AxisSizing::Mode::Preferred: return "preferred";
|
||||
case AxisSizing::Mode::Fill: return "fill";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
void plotSeries(const char* label, const TimeSeries& series) {
|
||||
ImPlot::PlotLine(label, series.data(), series.count(), 1.0, 0.0, 0, series.offset());
|
||||
}
|
||||
|
||||
void statisticsRow(const char* name, const TimeSeries& series, const char* unit) {
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextUnformatted(name);
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%.3f %s", series.latest(), unit);
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%.3f", series.average());
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%.3f", series.maximum());
|
||||
}
|
||||
}
|
||||
|
||||
void TimeSeries::add(float value) {
|
||||
mValues[mNext] = value;
|
||||
mNext = (mNext + 1) % kCapacity;
|
||||
}
|
||||
|
||||
float TimeSeries::latest() const { return mValues[(mNext + kCapacity - 1) % kCapacity]; }
|
||||
|
||||
float TimeSeries::average() const {
|
||||
float total = 0;
|
||||
for (float value : mValues)
|
||||
total += value;
|
||||
return total / (float) kCapacity;
|
||||
}
|
||||
|
||||
float TimeSeries::maximum() const {
|
||||
float result = 0;
|
||||
for (float value : mValues)
|
||||
result = value > result ? value : result;
|
||||
return result;
|
||||
}
|
||||
|
||||
DebugInspector::DebugInspector(Ui& ui) :
|
||||
mUi(ui) {
|
||||
mUi.setObserver(this);
|
||||
}
|
||||
|
||||
DebugInspector::~DebugInspector() { mUi.setObserver(nullptr); }
|
||||
|
||||
void DebugInspector::handleShortcuts(const InputView& input) {
|
||||
if (ImGui::GetCurrentContext() && ImGui::GetIO().WantTextInput) return;
|
||||
|
||||
if (mToggleKey && input.isPressed(*mToggleKey)) setVisible(!mVisible);
|
||||
if (!mVisible) return;
|
||||
|
||||
if (input.isPressed(Key::K)) mFreezeProcessing = !mFreezeProcessing;
|
||||
|
||||
Widget* hovered = mUi.hoveredWidget();
|
||||
if (!hovered) return;
|
||||
|
||||
if (input.isPressed(Key::S)) mSelected = hovered;
|
||||
if (input.isPressed(Key::P)) mEventBreakpoints.insert(hovered);
|
||||
if (input.isPressed(Key::L)) mLayoutBreakpoints.insert(hovered);
|
||||
}
|
||||
|
||||
DebugInspector::WindowSplit DebugInspector::splitWindow(Vec2F windowSize) const {
|
||||
if (!mVisible) return { makeRect({ 0, 0 }, windowSize), makeRect(windowSize.x, 0, 0, windowSize.y) };
|
||||
|
||||
float panelWidth = windowSize.x * mPanelFraction;
|
||||
if (panelWidth < kMinimumPanelWidth) panelWidth = kMinimumPanelWidth;
|
||||
if (panelWidth > windowSize.x) panelWidth = windowSize.x;
|
||||
|
||||
const float contentWidth = windowSize.x - panelWidth;
|
||||
return { makeRect(0, 0, contentWidth, windowSize.y), makeRect(contentWidth, 0, panelWidth, windowSize.y) };
|
||||
}
|
||||
|
||||
InputRouting DebugInspector::inputRouting(Vec2F pointer, Vec2F windowSize) const {
|
||||
InputRouting routing;
|
||||
if (!ImGui::GetCurrentContext()) return routing;
|
||||
|
||||
const ImGuiIO& io = ImGui::GetIO();
|
||||
const bool pointerOverPanel = mVisible && contains(splitWindow(windowSize).panel, pointer);
|
||||
routing.pointerBlocked = pointerOverPanel || io.WantCaptureMouse;
|
||||
routing.keyboardBlocked = io.WantCaptureKeyboard;
|
||||
return routing;
|
||||
}
|
||||
|
||||
void DebugInspector::recordFrame(
|
||||
halnf processMs, halnf drawMs, halnf framesProcessedPerSecond, halnf framesDrawnPerSecond
|
||||
) {
|
||||
const FrameStats& stats = mUi.stats();
|
||||
|
||||
mProcessMs.add(processMs);
|
||||
mDrawMs.add(drawMs);
|
||||
mInputMs.add(nanosecondsToMs(stats.inputNs - mPreviousStats.inputNs));
|
||||
mAnimationMs.add(nanosecondsToMs(stats.animationNs - mPreviousStats.animationNs));
|
||||
mLayoutMs.add(nanosecondsToMs(stats.layoutNs - mPreviousStats.layoutNs));
|
||||
mPaintMs.add(nanosecondsToMs(stats.paintNs - mPreviousStats.paintNs));
|
||||
mArranged.add((float) (stats.arrangedWidgets - mPreviousStats.arrangedWidgets));
|
||||
mMeasured.add((float) (stats.measuredWidgets - mPreviousStats.measuredWidgets));
|
||||
|
||||
mFramesProcessedPerSecond = framesProcessedPerSecond;
|
||||
mFramesDrawnPerSecond = framesDrawnPerSecond;
|
||||
mPreviousStats = stats;
|
||||
mFrame++;
|
||||
}
|
||||
|
||||
void DebugInspector::draw() {
|
||||
if (!mVisible) return;
|
||||
|
||||
const ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
const WindowSplit split = splitWindow({ viewport->Size.x, viewport->Size.y });
|
||||
|
||||
ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + split.panel.pos.x, viewport->Pos.y), ImGuiCond_Always);
|
||||
ImGui::SetNextWindowSize(ImVec2(split.panel.size.x, split.panel.size.y), ImGuiCond_Always);
|
||||
|
||||
const ImGuiWindowFlags flags = ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoBringToFrontOnFocus;
|
||||
if (ImGui::Begin("Widget Debugger", nullptr, flags)) {
|
||||
drawPanelWidthControl();
|
||||
drawToolbar();
|
||||
|
||||
if (ImGui::BeginTabBar("debugger tabs")) {
|
||||
if (ImGui::BeginTabItem("Performance")) {
|
||||
drawPerformanceTab();
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
if (ImGui::BeginTabItem("Under Cursor")) {
|
||||
drawHoverStackTab();
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
if (ImGui::BeginTabItem("Tree")) {
|
||||
drawWidgetTreeTab();
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
if (ImGui::BeginTabItem("Events")) {
|
||||
drawEventsTab();
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
if (ImGui::BeginTabItem("Layout")) {
|
||||
drawLayoutTab();
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
if (ImGui::BeginTabItem("Popups")) {
|
||||
drawPopupsTab();
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
ImGui::EndTabBar();
|
||||
}
|
||||
}
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
void DebugInspector::paintOverlay(Painter& painter) {
|
||||
if (!mVisible) return;
|
||||
|
||||
if (mShowOutlines && mUi.root()) paintOutlines(painter, *mUi.root(), 0);
|
||||
|
||||
if (Widget* hovered = mUi.hoveredWidget()) painter.strokeRect(hovered->windowRect(), kHoverOutlineColor);
|
||||
|
||||
if (mSelected) {
|
||||
const RectF rect = mSelected->windowRect();
|
||||
painter.fillRect(rect, kSelectionFill);
|
||||
painter.strokeRect(rect, kSelectionColor);
|
||||
painter.text(
|
||||
nameOf(*mSelected), makeRect(rect.pos.x + 4, rect.pos.y - 18, 400, 18), 12, TextAlign::Start, kSelectionColor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void DebugInspector::onEventDelivering(const Widget& receiver, const Event&) {
|
||||
if (mEventBreakpoints.erase(&receiver)) DEBUG_BREAK(true)
|
||||
}
|
||||
|
||||
void DebugInspector::onEventDelivered(const Widget& receiver, const Event& event) {
|
||||
if (!mVisible) return;
|
||||
if (event.type() == Event::Type::PointerMove && !mLogPointerMoves) return;
|
||||
|
||||
mEvents.push_front({ mFrame, eventTypeName(event), nameOf(receiver), event.windowPosition(), event.isAccepted() });
|
||||
if (mEvents.size() > kMaximumEventRecords) mEvents.pop_back();
|
||||
}
|
||||
|
||||
void DebugInspector::onLayoutPassStarted() {
|
||||
if (!mCurrentLayoutOrder.empty()) mLastLayoutOrder.swap(mCurrentLayoutOrder);
|
||||
mCurrentLayoutOrder.clear();
|
||||
}
|
||||
|
||||
void DebugInspector::onWidgetArranged(const Widget& widget) {
|
||||
if (mLayoutBreakpoints.erase(&widget)) DEBUG_BREAK(true)
|
||||
if (mVisible && mCurrentLayoutOrder.size() < kMaximumLayoutRecords) mCurrentLayoutOrder.push_back(nameOf(widget));
|
||||
}
|
||||
|
||||
void DebugInspector::onWidgetDetached(const Widget& widget) {
|
||||
if (mSelected && widget.isSelfOrAncestorOf(*mSelected)) mSelected = nullptr;
|
||||
mEventBreakpoints.erase(&widget);
|
||||
mLayoutBreakpoints.erase(&widget);
|
||||
}
|
||||
|
||||
void DebugInspector::drawPanelWidthControl() {
|
||||
ImGui::TextUnformatted("Widget Debugger");
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Close")) mVisible = false;
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
|
||||
float percent = mPanelFraction * 100;
|
||||
if (
|
||||
ImGui::SliderFloat(
|
||||
"##panel width", &percent, kMinimumPanelFraction * 100, kMaximumPanelFraction * 100, "width %.0f%%"
|
||||
)
|
||||
) {
|
||||
mPanelFraction = percent / 100;
|
||||
}
|
||||
}
|
||||
|
||||
void DebugInspector::drawToolbar() {
|
||||
ImGui::Checkbox("Freeze [K]", &mFreezeProcessing);
|
||||
ImGui::SameLine();
|
||||
ImGui::Checkbox("Force redraw", &mForceRedraw);
|
||||
ImGui::SameLine();
|
||||
ImGui::Checkbox("Outlines", &mShowOutlines);
|
||||
|
||||
ImGui::TextDisabled("Hover a widget: [S] select [P] break on event [L] break on layout");
|
||||
|
||||
const char* state = mUi.isLayoutPending() ? "layout pending" : (mUi.needsFrame() ? "animating" : "idle");
|
||||
ImGui::Text(
|
||||
"frame %llu %s popups %llu", (unsigned long long) mFrame, state, (unsigned long long) mUi.openPopupCount()
|
||||
);
|
||||
ImGui::Separator();
|
||||
}
|
||||
|
||||
void DebugInspector::drawPerformanceTab() {
|
||||
ImGui::Text("Processed %.0f fps Drawn %.0f fps", mFramesProcessedPerSecond, mFramesDrawnPerSecond);
|
||||
ImGui::Text(
|
||||
"Input %llu received, %llu delivered",
|
||||
(unsigned long long) mWindowCounters.inputReceived,
|
||||
(unsigned long long) mWindowCounters.inputDelivered
|
||||
);
|
||||
ImGui::Text(
|
||||
"Resize %llu received, %llu applied Swapchain rebuilds %llu",
|
||||
(unsigned long long) mWindowCounters.configuresReceived,
|
||||
(unsigned long long) mWindowCounters.configuresApplied,
|
||||
(unsigned long long) mWindowCounters.swapchainRecreations
|
||||
);
|
||||
|
||||
if (ImPlot::BeginPlot("Frame time (ms)", ImVec2(-1, 200))) {
|
||||
ImPlot::SetupAxes("frame", "ms", ImPlotAxisFlags_NoTickLabels, ImPlotAxisFlags_AutoFit);
|
||||
plotSeries("process", mProcessMs);
|
||||
plotSeries("draw", mDrawMs);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
|
||||
if (ImPlot::BeginPlot("Ui phases (ms)", ImVec2(-1, 200))) {
|
||||
ImPlot::SetupAxes("frame", "ms", ImPlotAxisFlags_NoTickLabels, ImPlotAxisFlags_AutoFit);
|
||||
plotSeries("input", mInputMs);
|
||||
plotSeries("animation", mAnimationMs);
|
||||
plotSeries("layout", mLayoutMs);
|
||||
plotSeries("paint", mPaintMs);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
|
||||
if (ImPlot::BeginPlot("Layout work (widgets)", ImVec2(-1, 160))) {
|
||||
ImPlot::SetupAxes("frame", "widgets", ImPlotAxisFlags_NoTickLabels, ImPlotAxisFlags_AutoFit);
|
||||
plotSeries("arranged", mArranged);
|
||||
plotSeries("measured", mMeasured);
|
||||
ImPlot::EndPlot();
|
||||
}
|
||||
|
||||
if (ImGui::BeginTable("phase table", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV)) {
|
||||
ImGui::TableSetupColumn("phase");
|
||||
ImGui::TableSetupColumn("last");
|
||||
ImGui::TableSetupColumn("avg");
|
||||
ImGui::TableSetupColumn("max");
|
||||
ImGui::TableHeadersRow();
|
||||
statisticsRow("process", mProcessMs, "ms");
|
||||
statisticsRow("draw", mDrawMs, "ms");
|
||||
statisticsRow("input", mInputMs, "ms");
|
||||
statisticsRow("animation", mAnimationMs, "ms");
|
||||
statisticsRow("layout", mLayoutMs, "ms");
|
||||
statisticsRow("paint", mPaintMs, "ms");
|
||||
statisticsRow("arranged", mArranged, "");
|
||||
statisticsRow("measured", mMeasured, "");
|
||||
ImGui::EndTable();
|
||||
}
|
||||
|
||||
const FrameStats& stats = mUi.stats();
|
||||
ImGui::Text(
|
||||
"Totals: %llu passes, %llu arranged, %llu skipped, %llu events, %llu painted",
|
||||
(unsigned long long) stats.layoutPasses,
|
||||
(unsigned long long) stats.arrangedWidgets,
|
||||
(unsigned long long) stats.skippedWidgets,
|
||||
(unsigned long long) stats.deliveredEvents,
|
||||
(unsigned long long) stats.paintedWidgets
|
||||
);
|
||||
if (ImGui::Button("Reset totals")) {
|
||||
mUi.resetStats();
|
||||
mPreviousStats = {};
|
||||
}
|
||||
|
||||
ImGui::SeparatorText("Animating");
|
||||
for (Widget* widget : mUi.animatingWidgets())
|
||||
drawWidgetLink(*widget);
|
||||
}
|
||||
|
||||
void DebugInspector::drawHoverStackTab() {
|
||||
Widget* hovered = mUi.hoveredWidget();
|
||||
const Vec2F pointer = mUi.pointerPosition();
|
||||
ImGui::Text("pointer %.0f, %.0f", pointer.x, pointer.y);
|
||||
|
||||
if (Widget* capture = mUi.pointerCapture()) ImGui::Text("pointer captured by %s", nameOf(*capture).c_str());
|
||||
if (Widget* focus = mUi.keyboardFocus()) ImGui::Text("keyboard focus: %s", nameOf(*focus).c_str());
|
||||
|
||||
if (!hovered) {
|
||||
ImGui::TextDisabled("Nothing under the cursor");
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::SeparatorText("Stack, innermost first");
|
||||
if (ImGui::BeginChild("hover stack", ImVec2(0, 260), ImGuiChildFlags_Border)) {
|
||||
for (Widget* widget = hovered; widget; widget = widget->parent())
|
||||
drawWidgetLink(*widget);
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
drawSelectedWidget();
|
||||
}
|
||||
|
||||
void DebugInspector::drawWidgetTreeTab() {
|
||||
if (ImGui::BeginChild("widget tree", ImVec2(0, ImGui::GetContentRegionAvail().y * 0.55f), ImGuiChildFlags_Border)) {
|
||||
if (mUi.root()) drawWidgetTreeNode(*mUi.root());
|
||||
for (Widget* popup : mUi.openPopups()) {
|
||||
ImGui::TextDisabled("popup");
|
||||
drawWidgetTreeNode(*popup);
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
drawSelectedWidget();
|
||||
}
|
||||
|
||||
void DebugInspector::drawWidgetTreeNode(Widget& widget) {
|
||||
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanAvailWidth;
|
||||
if (widget.children().empty()) flags |= ImGuiTreeNodeFlags_Leaf;
|
||||
if (&widget == mSelected) flags |= ImGuiTreeNodeFlags_Selected;
|
||||
if (mUi.hoveredWidget() && widget.isSelfOrAncestorOf(*mUi.hoveredWidget())) flags |= ImGuiTreeNodeFlags_DefaultOpen;
|
||||
|
||||
const RectF& rect = widget.rect();
|
||||
const bool open = ImGui::TreeNodeEx(
|
||||
&widget,
|
||||
flags,
|
||||
"%s%s [%.0f x %.0f]",
|
||||
nameOf(widget).c_str(),
|
||||
widget.isVisible() ? "" : " (hidden)",
|
||||
rect.size.x,
|
||||
rect.size.y
|
||||
);
|
||||
|
||||
if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) mSelected = &widget;
|
||||
|
||||
if (!open) return;
|
||||
for (Widget* child : widget.children())
|
||||
drawWidgetTreeNode(*child);
|
||||
ImGui::TreePop();
|
||||
}
|
||||
|
||||
void DebugInspector::drawSelectedWidget() {
|
||||
ImGui::SeparatorText("Selected");
|
||||
if (!mSelected) {
|
||||
ImGui::TextDisabled("Click a widget in a list, or hover one and press [S]");
|
||||
return;
|
||||
}
|
||||
|
||||
Widget& widget = *mSelected;
|
||||
ImGui::Text("%s", nameOf(widget).c_str());
|
||||
|
||||
const RectF rect = widget.rect();
|
||||
const RectF window = widget.windowRect();
|
||||
ImGui::Text("rect %.1f, %.1f %.1f x %.1f", rect.pos.x, rect.pos.y, rect.size.x, rect.size.y);
|
||||
ImGui::Text("window %.1f, %.1f", window.pos.x, window.pos.y);
|
||||
if (widget.isGeometryMoving()) {
|
||||
const RectF target = widget.targetRect();
|
||||
ImGui::Text("target %.1f, %.1f %.1f x %.1f", target.pos.x, target.pos.y, target.size.x, target.size.y);
|
||||
}
|
||||
bool animated = widget.isGeometryAnimated();
|
||||
if (ImGui::Checkbox("animate geometry", &animated)) widget.setGeometryAnimated(animated);
|
||||
|
||||
bool visible = widget.isVisible();
|
||||
if (ImGui::Checkbox("visible", &visible)) widget.setVisible(visible);
|
||||
ImGui::SameLine();
|
||||
bool enabled = widget.isEnabled();
|
||||
if (ImGui::Checkbox("enabled", &enabled)) widget.setEnabled(enabled);
|
||||
ImGui::SameLine();
|
||||
ImGui::Text("%s%s", widget.isHovered() ? "hovered " : "", widget.hasKeyboardFocus() ? "focused" : "");
|
||||
|
||||
drawSizingEditor(widget, Axis::Horizontal);
|
||||
drawSizingEditor(widget, Axis::Vertical);
|
||||
|
||||
if (Layout* layout = widget.layout()) {
|
||||
Insets padding = layout->padding();
|
||||
if (ImGui::DragFloat4("padding", &padding.left, 0.5f, 0, 200)) layout->setPadding(padding);
|
||||
}
|
||||
|
||||
if (!widget.tooltip().empty()) ImGui::Text("tooltip: %s", widget.tooltip().c_str());
|
||||
|
||||
if (ImGui::Button("Select parent") && widget.parent()) mSelected = widget.parent();
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Break on event")) mEventBreakpoints.insert(&widget);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Break on layout")) mLayoutBreakpoints.insert(&widget);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Relayout")) widget.requestLayout();
|
||||
}
|
||||
|
||||
void DebugInspector::drawEventsTab() {
|
||||
ImGui::Checkbox("Log pointer moves", &mLogPointerMoves);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Clear")) mEvents.clear();
|
||||
|
||||
const ImGuiTableFlags flags = ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_BordersInnerV;
|
||||
if (!ImGui::BeginTable("events", 5, flags, ImVec2(0, ImGui::GetContentRegionAvail().y))) return;
|
||||
|
||||
ImGui::TableSetupScrollFreeze(0, 1);
|
||||
ImGui::TableSetupColumn("frame");
|
||||
ImGui::TableSetupColumn("event");
|
||||
ImGui::TableSetupColumn("receiver");
|
||||
ImGui::TableSetupColumn("at");
|
||||
ImGui::TableSetupColumn("accepted");
|
||||
ImGui::TableHeadersRow();
|
||||
|
||||
for (const EventRecord& record : mEvents) {
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%llu", (unsigned long long) record.frame);
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextUnformatted(record.type.c_str());
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextUnformatted(record.receiver.c_str());
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::Text("%.0f, %.0f", record.position.x, record.position.y);
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextUnformatted(record.accepted ? "yes" : "");
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
|
||||
void DebugInspector::drawLayoutTab() {
|
||||
ImGui::Text("Last layout pass arranged %llu widgets, in order:", (unsigned long long) mLastLayoutOrder.size());
|
||||
if (ImGui::BeginChild("layout order", ImVec2(0, 0), ImGuiChildFlags_Border)) {
|
||||
for (size_t index = 0; index < mLastLayoutOrder.size(); index++) {
|
||||
ImGui::Text("%3llu %s", (unsigned long long) index, mLastLayoutOrder[index].c_str());
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
void DebugInspector::drawPopupsTab() {
|
||||
const std::vector<Widget*> popups = mUi.openPopups();
|
||||
if (popups.empty()) ImGui::TextDisabled("No popups open");
|
||||
|
||||
for (size_t index = 0; index < popups.size(); index++) {
|
||||
ImGui::Text("%llu.", (unsigned long long) index);
|
||||
ImGui::SameLine();
|
||||
drawWidgetLink(*popups[index]);
|
||||
}
|
||||
|
||||
if (!popups.empty() && ImGui::Button("Close all")) mUi.closeAllPopups();
|
||||
if (Widget* tooltip = mUi.openTooltip()) ImGui::Text("tooltip open: %s", nameOf(*tooltip).c_str());
|
||||
}
|
||||
|
||||
AxisSizing
|
||||
DebugInspector::editedSizing(AxisSizing::Mode mode, halnf length, halnf weight, halnf minimum, halnf maximum) {
|
||||
AxisSizing sizing = AxisSizing::fit();
|
||||
switch (mode) {
|
||||
case AxisSizing::Mode::Fit: sizing = AxisSizing::fit(); break;
|
||||
case AxisSizing::Mode::Fixed: sizing = AxisSizing::fixed(length); break;
|
||||
case AxisSizing::Mode::Preferred: sizing = AxisSizing::preferred(length); break;
|
||||
case AxisSizing::Mode::Fill: sizing = AxisSizing::fill(weight > 0 ? weight : 1); break;
|
||||
}
|
||||
|
||||
sizing = sizing.atLeast(minimum);
|
||||
if (maximum >= 0) sizing = sizing.atMost(maximum);
|
||||
return sizing;
|
||||
}
|
||||
|
||||
void DebugInspector::drawSizingEditor(Widget& widget, Axis axis) {
|
||||
const AxisSizing current = widget.sizing().along(axis);
|
||||
const char* axisName = axis == Axis::Horizontal ? "width" : "height";
|
||||
const halnf placedLength = lengthAlong(widget.rect(), axis);
|
||||
|
||||
ImGui::PushID(axisName);
|
||||
|
||||
int modeIndex = 0;
|
||||
for (int index = 0; index < 4; index++) {
|
||||
if (kSizingModes[index] == current.mode()) modeIndex = index;
|
||||
}
|
||||
|
||||
AxisSizing::Mode mode = current.mode();
|
||||
halnf length = current.fixedLength() > 0 ? current.fixedLength() : placedLength;
|
||||
halnf weight = current.fillWeight();
|
||||
halnf minimum = current.minimum();
|
||||
halnf maximum = current.maximum() >= kUnbounded ? -1.0f : current.maximum();
|
||||
bool changed = false;
|
||||
|
||||
ImGui::SetNextItemWidth(100);
|
||||
if (ImGui::Combo(axisName, &modeIndex, "fit\0fixed\0preferred\0fill\0")) {
|
||||
mode = kSizingModes[modeIndex];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const bool usesLength = mode == AxisSizing::Mode::Fixed || mode == AxisSizing::Mode::Preferred;
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(70);
|
||||
if (usesLength) changed |= ImGui::DragFloat("len", &length, 1.0f, 0.0f, 10000.0f, "%.0f");
|
||||
else if (mode == AxisSizing::Mode::Fill) changed |= ImGui::DragFloat("weight", &weight, 0.05f, 0.05f, 20.0f, "%.2f");
|
||||
else ImGui::TextDisabled("len %.0f", placedLength);
|
||||
|
||||
ImGui::SetNextItemWidth(70);
|
||||
changed |= ImGui::DragFloat("min", &minimum, 1.0f, 0.0f, 10000.0f, "%.0f");
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(70);
|
||||
changed |= ImGui::DragFloat("max (-1 none)", &maximum, 1.0f, -1.0f, 10000.0f, "%.0f");
|
||||
|
||||
if (changed) widget.setSizing(axis, DebugInspector::editedSizing(mode, length, weight, minimum, maximum));
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
void DebugInspector::drawWidgetLink(Widget& widget) {
|
||||
ImGui::PushID(&widget);
|
||||
const RectF rect = widget.windowRect();
|
||||
char label[256];
|
||||
std::snprintf(
|
||||
label,
|
||||
sizeof(label),
|
||||
"%s [%.0f, %.0f %.0f x %.0f]",
|
||||
nameOf(widget).c_str(),
|
||||
rect.pos.x,
|
||||
rect.pos.y,
|
||||
rect.size.x,
|
||||
rect.size.y
|
||||
);
|
||||
if (ImGui::Selectable(label, &widget == mSelected)) mSelected = &widget;
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
void DebugInspector::paintOutlines(Painter& painter, const Widget& widget, int depth) {
|
||||
if (!widget.isVisible()) return;
|
||||
|
||||
painter.strokeRect(widget.windowRect(), kOutlineColor);
|
||||
for (const Widget* child : widget.children())
|
||||
paintOutlines(painter, *child, depth + 1);
|
||||
}
|
||||
|
||||
std::string DebugInspector::nameOf(const Widget& widget) {
|
||||
return widget.debugName().empty() ? std::string("widget") : widget.debugName();
|
||||
}
|
||||
|
||||
const char* DebugInspector::eventTypeName(const Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerMove: return "move";
|
||||
case Event::Type::PointerDown: return "down";
|
||||
case Event::Type::PointerUp: return "up";
|
||||
case Event::Type::Click: return "click";
|
||||
case Event::Type::LongPress: return "long press";
|
||||
case Event::Type::Wheel: return "wheel";
|
||||
case Event::Type::KeyDown: return "key down";
|
||||
case Event::Type::KeyUp: return "key up";
|
||||
case Event::Type::TextInput: return "text";
|
||||
case Event::Type::Gesture: return "gesture";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
42
Widgets/src/components/debug/private/DebugTools.cpp
Normal file
42
Widgets/src/components/debug/private/DebugTools.cpp
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#include "DebugTools.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
DebugTools::DebugTools(Application& application, DebugInspector& inspector, Widget& anchor) :
|
||||
mInspector(inspector),
|
||||
mRecorder(application) {
|
||||
mMenu.setDebugName("debug menu");
|
||||
mRecording.setAnchor(&anchor);
|
||||
addMenuItems(mMenu);
|
||||
}
|
||||
|
||||
DebugTools::~DebugTools() {
|
||||
detach();
|
||||
mRecorder.cancel();
|
||||
}
|
||||
|
||||
void DebugTools::addMenuItems(Menu& menu) {
|
||||
menu.addToggle(
|
||||
"Debug UI",
|
||||
[this]() { return mInspector.isVisible(); },
|
||||
[this]() { mInspector.setVisible(!mInspector.isVisible()); }
|
||||
);
|
||||
menu.addSeparator();
|
||||
mRecording.addMenuItems(menu);
|
||||
}
|
||||
|
||||
void DebugTools::attachTo(MenuBar& bar) {
|
||||
if (&bar == mBar) return;
|
||||
detach();
|
||||
mBar = &bar;
|
||||
bar.addTrailingMenu("Debug", mMenu);
|
||||
bar.setTrailingWidget(&mRecording.badge);
|
||||
}
|
||||
|
||||
void DebugTools::detach() {
|
||||
if (!mBar) return;
|
||||
mBar->removeMenu(mMenu);
|
||||
if (mBar->trailingWidget() == &mRecording.badge) mBar->setTrailingWidget(nullptr);
|
||||
mBar = nullptr;
|
||||
}
|
||||
142
Widgets/src/components/debug/public/DebugInspector.hpp
Normal file
142
Widgets/src/components/debug/public/DebugInspector.hpp
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
#pragma once
|
||||
|
||||
#include "Diagnostics.hpp"
|
||||
#include "Sizing.hpp"
|
||||
#include "InputState.hpp"
|
||||
#include "InputRouting.hpp"
|
||||
#include "Painter.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
#include <deque>
|
||||
#include <set>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class TimeSeries {
|
||||
public:
|
||||
static constexpr int kCapacity = 240;
|
||||
|
||||
void add(float value);
|
||||
[[nodiscard]] const float* data() const { return mValues; }
|
||||
[[nodiscard]] int count() const { return kCapacity; }
|
||||
[[nodiscard]] int offset() const { return mNext; }
|
||||
[[nodiscard]] float latest() const;
|
||||
[[nodiscard]] float average() const;
|
||||
[[nodiscard]] float maximum() const;
|
||||
|
||||
private:
|
||||
float mValues[kCapacity] = {};
|
||||
int mNext = 0;
|
||||
};
|
||||
|
||||
class DebugInspector : public UiObserver {
|
||||
public:
|
||||
explicit DebugInspector(Ui& ui);
|
||||
~DebugInspector() override;
|
||||
|
||||
DebugInspector(const DebugInspector&) = delete;
|
||||
DebugInspector& operator=(const DebugInspector&) = delete;
|
||||
|
||||
[[nodiscard]] static AxisSizing
|
||||
editedSizing(AxisSizing::Mode mode, halnf length, halnf weight, halnf minimum, halnf maximum);
|
||||
|
||||
void handleShortcuts(const InputView& input);
|
||||
void setToggleKey(std::optional<Key> key) { mToggleKey = key; }
|
||||
[[nodiscard]] std::optional<Key> toggleKey() const { return mToggleKey; }
|
||||
|
||||
struct WindowSplit {
|
||||
RectF content;
|
||||
RectF panel;
|
||||
};
|
||||
|
||||
[[nodiscard]] WindowSplit splitWindow(Vec2F windowSize) const;
|
||||
[[nodiscard]] InputRouting inputRouting(Vec2F pointer, Vec2F windowSize) const;
|
||||
|
||||
void setVisible(bool visible) { mVisible = visible; }
|
||||
[[nodiscard]] bool isVisible() const { return mVisible; }
|
||||
[[nodiscard]] bool isProcessingFrozen() const { return mVisible && mFreezeProcessing; }
|
||||
[[nodiscard]] bool forcesRedraw() const { return mVisible && mForceRedraw; }
|
||||
|
||||
void recordFrame(halnf processMs, halnf drawMs, halnf framesProcessedPerSecond, halnf framesDrawnPerSecond);
|
||||
|
||||
struct WindowCounters {
|
||||
uint64_t inputReceived = 0;
|
||||
uint64_t inputDelivered = 0;
|
||||
uint64_t configuresReceived = 0;
|
||||
uint64_t configuresApplied = 0;
|
||||
uint64_t swapchainRecreations = 0;
|
||||
};
|
||||
void recordWindowCounters(const WindowCounters& counters) { mWindowCounters = counters; }
|
||||
[[nodiscard]] const WindowCounters& windowCounters() const { return mWindowCounters; }
|
||||
void draw();
|
||||
void paintOverlay(Painter& painter);
|
||||
|
||||
private:
|
||||
std::optional<Key> mToggleKey;
|
||||
WindowCounters mWindowCounters;
|
||||
struct EventRecord {
|
||||
ualni frame;
|
||||
std::string type;
|
||||
std::string receiver;
|
||||
Vec2F position;
|
||||
bool accepted;
|
||||
};
|
||||
|
||||
void onEventDelivering(const Widget& receiver, const Event& event) override;
|
||||
void onEventDelivered(const Widget& receiver, const Event& event) override;
|
||||
void onLayoutPassStarted() override;
|
||||
void onWidgetArranged(const Widget& widget) override;
|
||||
void onWidgetDetached(const Widget& widget) override;
|
||||
|
||||
void drawPanelWidthControl();
|
||||
void drawToolbar();
|
||||
void drawPerformanceTab();
|
||||
void drawHoverStackTab();
|
||||
void drawWidgetTreeTab();
|
||||
void drawWidgetTreeNode(Widget& widget);
|
||||
void drawSelectedWidget();
|
||||
void drawEventsTab();
|
||||
void drawLayoutTab();
|
||||
void drawPopupsTab();
|
||||
|
||||
void drawSizingEditor(Widget& widget, Axis axis);
|
||||
void drawWidgetLink(Widget& widget);
|
||||
void paintOutlines(Painter& painter, const Widget& widget, int depth);
|
||||
|
||||
[[nodiscard]] static std::string nameOf(const Widget& widget);
|
||||
[[nodiscard]] static const char* eventTypeName(const Event& event);
|
||||
|
||||
Ui& mUi;
|
||||
|
||||
bool mVisible = false;
|
||||
float mPanelFraction = 0.32f;
|
||||
bool mFreezeProcessing = false;
|
||||
bool mForceRedraw = false;
|
||||
bool mShowOutlines = false;
|
||||
bool mLogPointerMoves = false;
|
||||
|
||||
Widget* mSelected = nullptr;
|
||||
std::set<const Widget*> mEventBreakpoints;
|
||||
std::set<const Widget*> mLayoutBreakpoints;
|
||||
|
||||
ualni mFrame = 0;
|
||||
std::deque<EventRecord> mEvents;
|
||||
std::vector<std::string> mLastLayoutOrder;
|
||||
std::vector<std::string> mCurrentLayoutOrder;
|
||||
|
||||
FrameStats mPreviousStats;
|
||||
TimeSeries mProcessMs;
|
||||
TimeSeries mDrawMs;
|
||||
TimeSeries mInputMs;
|
||||
TimeSeries mAnimationMs;
|
||||
TimeSeries mLayoutMs;
|
||||
TimeSeries mPaintMs;
|
||||
TimeSeries mArranged;
|
||||
TimeSeries mMeasured;
|
||||
halnf mFramesProcessedPerSecond = 0;
|
||||
halnf mFramesDrawnPerSecond = 0;
|
||||
};
|
||||
}
|
||||
35
Widgets/src/components/debug/public/DebugTools.hpp
Normal file
35
Widgets/src/components/debug/public/DebugTools.hpp
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#pragma once
|
||||
|
||||
#include "DebugInspector.hpp"
|
||||
#include "GraphicApplication.hpp"
|
||||
#include "Menus.hpp"
|
||||
#include "SessionRecordingControl.hpp"
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class DebugTools {
|
||||
public:
|
||||
DebugTools(Application& application, DebugInspector& inspector, Widget& anchor);
|
||||
~DebugTools();
|
||||
|
||||
DebugTools(const DebugTools&) = delete;
|
||||
DebugTools& operator=(const DebugTools&) = delete;
|
||||
|
||||
void attachTo(MenuBar& bar);
|
||||
void detach();
|
||||
[[nodiscard]] MenuBar* attachedBar() const { return mBar; }
|
||||
|
||||
void addMenuItems(Menu& menu);
|
||||
|
||||
[[nodiscard]] Menu& menu() { return mMenu; }
|
||||
[[nodiscard]] SessionRecorder& recorder() { return mRecorder; }
|
||||
[[nodiscard]] SessionRecordingControl& recording() { return mRecording; }
|
||||
|
||||
private:
|
||||
DebugInspector& mInspector;
|
||||
SessionRecorder mRecorder;
|
||||
SessionRecordingControl mRecording{ mRecorder };
|
||||
Menu mMenu;
|
||||
MenuBar* mBar = nullptr;
|
||||
};
|
||||
}
|
||||
942
Widgets/src/components/docking/private/Docking.cpp
Normal file
942
Widgets/src/components/docking/private/Docking.cpp
Normal file
|
|
@ -0,0 +1,942 @@
|
|||
#include "Docking.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Event.hpp"
|
||||
#include "Layout.hpp"
|
||||
#include "StackLayout.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kDragThreshold = 4;
|
||||
constexpr halnf kEdgeGrip = 6;
|
||||
constexpr halnf kCornerGrip = 16;
|
||||
constexpr halnf kMinimumDockedLength = 80;
|
||||
constexpr halnf kMaximumDockedFraction = 0.85f;
|
||||
constexpr halnf kDropZoneFraction = 0.3f;
|
||||
constexpr halnf kDropTargetFraction = 0.1f;
|
||||
constexpr halnf kMinimumDropTarget = 36;
|
||||
constexpr halnf kDropZoneInset = 8;
|
||||
constexpr halnf kVisibleWhenOffscreen = 40;
|
||||
constexpr halnf kFloatOpeningScale = 0.85f;
|
||||
constexpr halnf kStripeLabelPadding = 10;
|
||||
constexpr halnf kStripeIconGap = 4;
|
||||
constexpr halnf kStripeInset = 3;
|
||||
constexpr halnf kStripeButtonGap = 4;
|
||||
|
||||
const Vec2F kMinimumFloatingSize = { 160, 120 };
|
||||
const Vec2F kMaximumDraggedSize = { 360, 420 };
|
||||
|
||||
int indexOf(DockSide side) { return (int) side; }
|
||||
|
||||
halnf clampValue(halnf value, halnf minimum, halnf maximum) {
|
||||
if (value > maximum) value = maximum;
|
||||
if (value < minimum) value = minimum;
|
||||
return value;
|
||||
}
|
||||
|
||||
RGBA withAlpha(const RGBA& color, halnf alpha) { return { color.r, color.g, color.b, alpha }; }
|
||||
}
|
||||
|
||||
StripeButton::StripeButton(std::string title, ImageId icon) :
|
||||
Button(std::move(title)) {
|
||||
setStyle(Style::Flat);
|
||||
setDebugName("stripe: " + text());
|
||||
setTooltip(text());
|
||||
if (icon.isValid()) setIcon(icon);
|
||||
}
|
||||
|
||||
halnf StripeButton::iconLength() const { return hasIcon() ? theme().metrics.lineHeight + kStripeIconGap : 0; }
|
||||
|
||||
halnf StripeButton::stripeLength() const {
|
||||
const Metrics& metrics = theme().metrics;
|
||||
return textWidth(text(), metrics.smallFontSize) + iconLength() + kStripeLabelPadding * 2;
|
||||
}
|
||||
|
||||
Extent StripeButton::measureContent(Axis axis, halnf) const {
|
||||
if (axis == Axis::Horizontal) return Extent::rigid(theme().metrics.controlHeight);
|
||||
return Extent::rigid(stripeLength());
|
||||
}
|
||||
|
||||
RGBA StripeButton::backgroundTarget() const {
|
||||
const Palette& palette = theme().palette;
|
||||
if (isPressed()) return palette.controlPressed;
|
||||
if (isSelected()) return isHovered() ? palette.controlHovered : palette.neutralSelection;
|
||||
if (isHovered()) return palette.controlHovered;
|
||||
return palette.control;
|
||||
}
|
||||
|
||||
RGBA StripeButton::labelColor() const {
|
||||
const Palette& palette = theme().palette;
|
||||
if (!isEffectivelyEnabled()) return palette.disabledText;
|
||||
return isSelected() || isHovered() ? palette.text : palette.mutedText;
|
||||
}
|
||||
|
||||
void StripeButton::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const RectF box = localRect();
|
||||
const halnf radius = style.metrics.cornerRadius;
|
||||
|
||||
painter.fillRect(box, background(), radius);
|
||||
painter.strokeRect(box, isSelected() ? style.palette.controlBorder : style.palette.border, radius);
|
||||
if (showsFocusRing()) painter.strokeRect(insetRect(box, -2), style.palette.focusRing, radius + 2, 2);
|
||||
|
||||
RectF label = makeRect(box.pos.x, box.pos.y + kStripeLabelPadding, box.size.x, box.size.y - kStripeLabelPadding * 2);
|
||||
if (hasIcon()) {
|
||||
const halnf iconSize = style.metrics.lineHeight;
|
||||
const RectF iconBox =
|
||||
makeRect(box.center().x - iconSize / 2, label.pos.y + label.size.y - iconSize, iconSize, iconSize);
|
||||
painter.image(iconBox, icon());
|
||||
label.size.y -= iconLength();
|
||||
}
|
||||
|
||||
painter.verticalText(text(), label, style.metrics.smallFontSize, TextDirection::BottomToTop, labelColor());
|
||||
}
|
||||
|
||||
OptionsButton::OptionsButton() {
|
||||
setStyle(Style::Flat);
|
||||
setDebugName("options button");
|
||||
}
|
||||
|
||||
Extent OptionsButton::measureContent(Axis, halnf) const { return Extent::rigid(theme().metrics.iconButtonSize); }
|
||||
|
||||
void OptionsButton::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const RGBA color = isHovered() || isPopupOpen() ? style.palette.text : style.palette.mutedText;
|
||||
const Vec2F center = localRect().center();
|
||||
|
||||
painter.fillRect(localRect(), background(), style.metrics.cornerRadius);
|
||||
for (halnf offset : { -5.0f, 0.0f, 5.0f })
|
||||
painter.fillCircle(center + Vec2F(0.0f, offset), 1.5f, color);
|
||||
}
|
||||
|
||||
RGBA OptionsButton::backgroundTarget() const {
|
||||
const Palette& palette = theme().palette;
|
||||
if (isPopupOpen() || isPressed()) return palette.controlPressed;
|
||||
if (isHovered()) return palette.controlHovered;
|
||||
return withAlpha(palette.controlHovered, 0);
|
||||
}
|
||||
|
||||
class DockPanel::PanelLayout : public Layout {
|
||||
public:
|
||||
explicit PanelLayout(DockPanel& panel) :
|
||||
mPanel(panel) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext& context, Axis axis, halnf crossLength) const override {
|
||||
Extent header = context.measure(mPanel.mHeader, axis, crossLength);
|
||||
Extent body = context.measure(mPanel.mBody, axis, crossLength);
|
||||
if (axis == Axis::Horizontal)
|
||||
return { std::max(header.minimum, body.minimum), std::max(header.preferred, body.preferred) };
|
||||
return { header.minimum + body.minimum, header.preferred + body.preferred };
|
||||
}
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& contentArea) override {
|
||||
const halnf headerHeight = context.measure(mPanel.mHeader, Axis::Vertical, contentArea.size.x).preferred;
|
||||
context.place(mPanel.mHeader, makeRect(contentArea.pos, Vec2F(contentArea.size.x, headerHeight)));
|
||||
context.place(
|
||||
mPanel.mBody,
|
||||
makeRect(
|
||||
contentArea.pos.x, contentArea.pos.y + headerHeight, contentArea.size.x, contentArea.size.y - headerHeight
|
||||
)
|
||||
);
|
||||
|
||||
if (!mPanel.mCornerGrip.isVisible()) return;
|
||||
const Vec2F corner = contentArea.pos + contentArea.size;
|
||||
context.place(
|
||||
mPanel.mCornerGrip, makeRect(corner - Vec2F(kCornerGrip, kCornerGrip), Vec2F(kCornerGrip, kCornerGrip))
|
||||
);
|
||||
}
|
||||
|
||||
private:
|
||||
DockPanel& mPanel;
|
||||
};
|
||||
|
||||
DockPanel::DockPanel(std::string title, DockSide side) :
|
||||
Panel(Surface::Base),
|
||||
mSide(side),
|
||||
mTitle(std::move(title)) {
|
||||
setDebugName("dock panel: " + mTitle.text());
|
||||
setVisible(false);
|
||||
setLayout(std::make_unique<PanelLayout>(*this));
|
||||
|
||||
BoxLayout& headerLayout = mHeader.emplaceLayout<RowLayout>(2);
|
||||
headerLayout.setPadding(Insets::symmetric(8, 1));
|
||||
headerLayout.setCrossAlign(CrossAlign::Center);
|
||||
mHeader.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mTitle.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
|
||||
mHideButton.setTooltip("Hide");
|
||||
mHideButton.setOnActivate([this]() {
|
||||
if (mArea) mArea->hidePanel(*this);
|
||||
});
|
||||
buildOptionsMenu();
|
||||
|
||||
mHeader.addChild(mTitle);
|
||||
mHeader.addChild(mOptionsButton);
|
||||
mHeader.addChild(mHideButton);
|
||||
|
||||
mBody.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mBody.emplaceLayout<StackLayout>().setPadding(Insets::uniform(6));
|
||||
|
||||
addChild(mHeader);
|
||||
addChild(mBody);
|
||||
addChild(mCornerGrip);
|
||||
refreshChrome();
|
||||
}
|
||||
|
||||
DockPanel::~DockPanel() {
|
||||
if (mArea) mArea->removePanel(*this);
|
||||
}
|
||||
|
||||
void DockPanel::setContent(Widget* content) {
|
||||
if (mContent) mBody.removeChild(*mContent);
|
||||
|
||||
mContent = content;
|
||||
if (mContent) mBody.addChild(*mContent);
|
||||
}
|
||||
|
||||
void DockPanel::setDockedLength(halnf length) {
|
||||
mDockedLength = length > kMinimumDockedLength ? length : kMinimumDockedLength;
|
||||
if (parent()) parent()->requestLayout();
|
||||
}
|
||||
|
||||
void DockPanel::setFloatingRect(const RectF& rect) {
|
||||
mFloatingRect = makeRect(
|
||||
rect.pos, { std::max(rect.size.x, kMinimumFloatingSize.x), std::max(rect.size.y, kMinimumFloatingSize.y) }
|
||||
);
|
||||
if (parent()) parent()->requestLayout();
|
||||
}
|
||||
|
||||
RectF DockPanel::resizeGripRect() const { return mCornerGrip.rect(); }
|
||||
|
||||
void DockPanel::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
{
|
||||
if (event.button() != PointerButton::Primary || !mArea) return;
|
||||
|
||||
if (!contains(headerRect(), event.position())) return;
|
||||
beginInteraction(Interaction::PendingDrag, event);
|
||||
break;
|
||||
}
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (mInteraction == Interaction::None) return;
|
||||
updateInteraction(event);
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (mInteraction == Interaction::None) return;
|
||||
endInteraction(event);
|
||||
break;
|
||||
|
||||
default: return;
|
||||
}
|
||||
|
||||
event.accept();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void DockPanel::paint(Painter& painter) { Panel::paint(painter); }
|
||||
|
||||
void DockPanel::paintOverlay(Painter&) {}
|
||||
|
||||
void DockPanel::onActiveWithinChanged() {
|
||||
if (!containsActiveWidget() && mArea) mArea->panelLostActivity(*this);
|
||||
}
|
||||
|
||||
DockPanel::CornerGrip::CornerGrip(DockPanel& panel) :
|
||||
mPanel(panel) {
|
||||
setDebugName("corner grip");
|
||||
setGeometryAnimated(false);
|
||||
setSizing(AxisSizing::fixed(kCornerGrip), AxisSizing::fixed(kCornerGrip));
|
||||
}
|
||||
|
||||
void DockPanel::CornerGrip::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
if (event.button() != PointerButton::Primary || !mPanel.mArea) return;
|
||||
mDragging = true;
|
||||
mGrabOffset = event.position() - size();
|
||||
ui()->capturePointer(*this);
|
||||
mPanel.mArea->beginDirectResize(mPanel);
|
||||
break;
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
if (!mDragging) return;
|
||||
mPanel.mArea->floatResized(mPanel, mPanel.mArea->toLocal(event.windowPosition() - mGrabOffset));
|
||||
break;
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (!mDragging || event.button() != PointerButton::Primary) return;
|
||||
mDragging = false;
|
||||
ui()->releasePointer(*this);
|
||||
mPanel.mArea->endDirectResize(mPanel);
|
||||
break;
|
||||
|
||||
default: return;
|
||||
}
|
||||
|
||||
event.accept();
|
||||
refreshReveal();
|
||||
}
|
||||
|
||||
void DockPanel::CornerGrip::onHoverChanged() { refreshReveal(); }
|
||||
|
||||
void DockPanel::CornerGrip::refreshReveal() {
|
||||
mReveal.animateTo(isHovered() || mDragging ? 1.0f : 0.0f);
|
||||
startTicking();
|
||||
}
|
||||
|
||||
void DockPanel::CornerGrip::onTick(halnf deltaMs) {
|
||||
if (!mReveal.advance(deltaMs)) stopTicking();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void DockPanel::CornerGrip::paint(Painter& painter) {
|
||||
const halnf reveal = mReveal.value();
|
||||
if (reveal <= 0.01f) return;
|
||||
|
||||
const Palette& palette = theme().palette;
|
||||
const RGBA base = mDragging ? palette.text : palette.mutedText;
|
||||
const Vec2F corner = size() - Vec2F(5.0f, 5.0f);
|
||||
painter.polyline(
|
||||
{ corner - Vec2F(8.0f, 0.0f), corner, corner - Vec2F(0.0f, 8.0f) }, withAlpha(base, base.a * reveal), 1.5f
|
||||
);
|
||||
}
|
||||
|
||||
void DockPanel::buildOptionsMenu() {
|
||||
const struct {
|
||||
const char* text;
|
||||
ViewMode mode;
|
||||
} modes[] = { { "Dock Pinned", ViewMode::Docked },
|
||||
{ "Dock Unpinned", ViewMode::Undocked },
|
||||
{ "Float", ViewMode::Float } };
|
||||
|
||||
for (const auto& option : modes) {
|
||||
const ViewMode mode = option.mode;
|
||||
mViewModeMenu.addToggle(
|
||||
option.text,
|
||||
[this, mode]() { return mViewMode == mode; },
|
||||
[this, mode]() {
|
||||
if (mArea) mArea->setViewMode(*this, mode);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const struct {
|
||||
const char* text;
|
||||
DockSide side;
|
||||
} sides[] = { { "Left", DockSide::Left }, { "Right", DockSide::Right }, { "Bottom", DockSide::Bottom } };
|
||||
|
||||
for (const auto& option : sides) {
|
||||
const DockSide side = option.side;
|
||||
mMoveToMenu.addToggle(
|
||||
option.text,
|
||||
[this, side]() { return mSide == side && mViewMode != ViewMode::Float; },
|
||||
[this, side]() {
|
||||
if (mArea) mArea->dockPanel(*this, side);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
mOptionsMenu.addSubmenu("View Mode", mViewModeMenu);
|
||||
mOptionsMenu.addSubmenu("Move To", mMoveToMenu);
|
||||
mOptionsMenu.addSeparator();
|
||||
mOptionsMenu.addItem("Hide", [this]() {
|
||||
if (mArea) mArea->hidePanel(*this);
|
||||
});
|
||||
|
||||
mOptionsButton.setTooltip("Options");
|
||||
mOptionsButton.setPopupContent(&mOptionsMenu);
|
||||
mOptionsButton.popupOptions().align = PlacementAlign::End;
|
||||
}
|
||||
|
||||
void DockPanel::refreshChrome() {
|
||||
const bool detached = mViewMode != ViewMode::Docked || mDragging;
|
||||
|
||||
const Insets padding = detached ? Insets::uniform(1) : Insets();
|
||||
layout()->setPadding(padding);
|
||||
mCornerGrip.setVisible(mViewMode == ViewMode::Float && !mDragging);
|
||||
|
||||
setSurface(detached ? Surface::Raised : Surface::Base);
|
||||
setBorder(detached);
|
||||
setCornerRadius(detached ? theme().metrics.popupCornerRadius : theme().metrics.surfaceCornerRadius);
|
||||
}
|
||||
|
||||
void DockPanel::beginInteraction(Interaction interaction, const Event& event) {
|
||||
mInteraction = interaction;
|
||||
mPressPosition = event.windowPosition();
|
||||
mGrabOffset = event.position();
|
||||
ui()->capturePointer(*this);
|
||||
}
|
||||
|
||||
void DockPanel::updateInteraction(const Event& event) {
|
||||
if (mInteraction == Interaction::PendingDrag) {
|
||||
if ((event.windowPosition() - mPressPosition).length() < kDragThreshold) return;
|
||||
|
||||
mInteraction = Interaction::Dragging;
|
||||
mDragging = true;
|
||||
refreshChrome();
|
||||
mArea->panelDragStarted(*this);
|
||||
}
|
||||
|
||||
mArea->panelDragged(*this, event.windowPosition());
|
||||
}
|
||||
|
||||
void DockPanel::endInteraction(const Event& event) {
|
||||
const Interaction finished = mInteraction;
|
||||
|
||||
mInteraction = Interaction::None;
|
||||
mDragging = false;
|
||||
refreshChrome();
|
||||
ui()->releasePointer(*this);
|
||||
|
||||
if (finished == Interaction::Dragging) mArea->panelDragEnded(*this, event.windowPosition());
|
||||
}
|
||||
|
||||
class DockArea::DockLayout : public Layout {
|
||||
public:
|
||||
explicit DockLayout(DockArea& area) :
|
||||
mArea(area) {}
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureChildren(const LayoutContext&, Axis, halnf) const override { return {}; }
|
||||
|
||||
void arrangeChildren(LayoutContext& context, const RectF& contentArea) override {
|
||||
const Geometry geometry = mArea.computeGeometry(contentArea);
|
||||
|
||||
if (mArea.mCenter && mArea.mCenter->isVisible()) context.place(*mArea.mCenter, geometry.center);
|
||||
|
||||
for (DockPanel* panel : mArea.mPanels) {
|
||||
if (panel->isVisible()) context.place(*panel, mArea.panelRect(*panel, geometry, contentArea));
|
||||
}
|
||||
|
||||
for (DockSide side : { DockSide::Left, DockSide::Right, DockSide::Bottom }) {
|
||||
ResizeHandle& handle = *mArea.mEdgeHandles[indexOf(side)];
|
||||
if (handle.isVisible()) context.place(handle, geometry.gaps[indexOf(side)]);
|
||||
}
|
||||
|
||||
placeStripeButtons(context, geometry);
|
||||
}
|
||||
|
||||
private:
|
||||
void placeStripeButtons(LayoutContext& context, const Geometry& geometry) const {
|
||||
const RectF& leftRail = geometry.stripes[indexOf(DockSide::Left)];
|
||||
const RectF& rightRail = geometry.stripes[indexOf(DockSide::Right)];
|
||||
|
||||
halnf leftTop = leftRail.pos.y + kStripeInset;
|
||||
halnf leftBottom = leftRail.pos.y + leftRail.size.y - kStripeInset;
|
||||
halnf rightTop = rightRail.pos.y + kStripeInset;
|
||||
|
||||
for (const Stripe& stripe : mArea.mStripes) {
|
||||
const DockSide side = stripe.panel->side();
|
||||
const RectF& rail = side == DockSide::Right ? rightRail : leftRail;
|
||||
const halnf width = rail.size.x - kStripeInset * 2;
|
||||
const halnf length = stripe.button->stripeLength();
|
||||
|
||||
halnf top = 0;
|
||||
if (side == DockSide::Left) {
|
||||
top = leftTop;
|
||||
leftTop += length + kStripeButtonGap;
|
||||
} else if (side == DockSide::Bottom) {
|
||||
leftBottom -= length;
|
||||
top = leftBottom;
|
||||
leftBottom -= kStripeButtonGap;
|
||||
} else {
|
||||
top = rightTop;
|
||||
rightTop += length + kStripeButtonGap;
|
||||
}
|
||||
|
||||
context.place(*stripe.button, makeRect(rail.pos.x + kStripeInset, top, width, length));
|
||||
}
|
||||
}
|
||||
|
||||
DockArea& mArea;
|
||||
};
|
||||
|
||||
DockArea::DockArea() {
|
||||
setDebugName("dock area");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
setLayout(std::make_unique<DockLayout>(*this));
|
||||
|
||||
for (DockSide side : { DockSide::Left, DockSide::Right, DockSide::Bottom }) {
|
||||
const Axis axis = side == DockSide::Bottom ? Axis::Vertical : Axis::Horizontal;
|
||||
auto handle = std::make_unique<ResizeHandle>(axis);
|
||||
handle->setDebugName("dock edge handle");
|
||||
handle->setVisible(false);
|
||||
handle->setOnDragStarted([this, side]() {
|
||||
if (DockPanel* panel = openPanelOn(side)) beginDirectResize(*panel);
|
||||
});
|
||||
handle->setOnDragged([this, side](halnf coordinate) { edgeDragged(side, coordinate); });
|
||||
handle->setOnDragFinished([this, side]() {
|
||||
if (DockPanel* panel = openPanelOn(side)) endDirectResize(*panel);
|
||||
});
|
||||
addChild(*handle);
|
||||
mEdgeHandles[indexOf(side)] = std::move(handle);
|
||||
}
|
||||
}
|
||||
|
||||
DockArea::~DockArea() {
|
||||
for (DockPanel* panel : mPanels)
|
||||
panel->mArea = nullptr;
|
||||
removeAllChildren();
|
||||
}
|
||||
|
||||
void DockArea::setCenter(Widget* center) {
|
||||
if (mCenter) removeChild(*mCenter);
|
||||
|
||||
mCenter = center;
|
||||
if (mCenter) insertChild(0, *mCenter);
|
||||
}
|
||||
|
||||
void DockArea::addPanel(DockPanel& panel) {
|
||||
if (panel.mArea == this) return;
|
||||
if (panel.mArea) panel.mArea->removePanel(panel);
|
||||
|
||||
panel.mArea = this;
|
||||
mPanels.push_back(&panel);
|
||||
addChild(panel);
|
||||
panel.setVisible(panel.mOpen);
|
||||
rebuildStripes();
|
||||
}
|
||||
|
||||
void DockArea::removePanel(DockPanel& panel) {
|
||||
const auto position = std::find(mPanels.begin(), mPanels.end(), &panel);
|
||||
if (position == mPanels.end()) return;
|
||||
|
||||
mPanels.erase(position);
|
||||
panel.mArea = nullptr;
|
||||
removeChild(panel);
|
||||
rebuildStripes();
|
||||
}
|
||||
|
||||
void DockArea::openPanel(DockPanel& panel) {
|
||||
const bool wasOpen = panel.mOpen;
|
||||
|
||||
panel.mOpen = true;
|
||||
panel.setVisible(true);
|
||||
closeOthersOnSide(panel);
|
||||
if (!wasOpen) panel.appearFrom(openingRect(panel));
|
||||
|
||||
if (panel.mViewMode != DockPanel::ViewMode::Docked) panel.bringToFront();
|
||||
refreshStripeSelection();
|
||||
refreshEdgeHandles();
|
||||
requestLayout();
|
||||
|
||||
if (panel.mViewMode == DockPanel::ViewMode::Undocked && ui()) ui()->setActiveWidget(&panel);
|
||||
if (!wasOpen && panel.mOnOpenChanged) panel.mOnOpenChanged(true);
|
||||
}
|
||||
|
||||
void DockArea::hidePanel(DockPanel& panel) {
|
||||
if (!panel.mOpen) return;
|
||||
|
||||
panel.mOpen = false;
|
||||
panel.setVisible(false);
|
||||
refreshStripeSelection();
|
||||
refreshEdgeHandles();
|
||||
requestLayout();
|
||||
if (panel.mOnOpenChanged) panel.mOnOpenChanged(false);
|
||||
}
|
||||
|
||||
void DockArea::togglePanel(DockPanel& panel) {
|
||||
if (panel.mOpen) hidePanel(panel);
|
||||
else openPanel(panel);
|
||||
}
|
||||
|
||||
void DockArea::dockPanel(DockPanel& panel, DockSide side) {
|
||||
const bool movedToAnotherSide = panel.mSide != side;
|
||||
|
||||
panel.mSide = side;
|
||||
if (panel.mViewMode == DockPanel::ViewMode::Float) panel.mViewMode = DockPanel::ViewMode::Docked;
|
||||
panel.refreshChrome();
|
||||
|
||||
if (movedToAnotherSide) rebuildStripes();
|
||||
openPanel(panel);
|
||||
}
|
||||
|
||||
void DockArea::setViewMode(DockPanel& panel, DockPanel::ViewMode mode) {
|
||||
const bool startsFloating = mode == DockPanel::ViewMode::Float && panel.mViewMode != DockPanel::ViewMode::Float;
|
||||
if (startsFloating && panel.mOpen) {
|
||||
const Vec2F current = panel.size();
|
||||
const Vec2F floating = { std::min(current.x, kMaximumDraggedSize.x), std::min(current.y, kMaximumDraggedSize.y) };
|
||||
panel.setFloatingRect(makeRect(panel.rect().pos + Vec2F(24.0f, 24.0f), floating));
|
||||
}
|
||||
|
||||
panel.mViewMode = mode;
|
||||
panel.refreshChrome();
|
||||
openPanel(panel);
|
||||
}
|
||||
|
||||
DockPanel* DockArea::openPanelOn(DockSide side) const {
|
||||
for (DockPanel* panel : mPanels) {
|
||||
const bool takesZone = panel->mOpen && !panel->mDragging && panel->mViewMode != DockPanel::ViewMode::Float;
|
||||
if (takesZone && panel->mSide == side) return panel;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
StripeButton* DockArea::stripeButton(const DockPanel& panel) const {
|
||||
for (const Stripe& stripe : mStripes) {
|
||||
if (stripe.panel == &panel) return stripe.button.get();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
RectF DockArea::stripeRect(DockSide side) const {
|
||||
return computeGeometry(localRect()).stripes[side == DockSide::Right ? 1 : 0];
|
||||
}
|
||||
|
||||
RectF DockArea::centerRect() const { return computeGeometry(localRect()).center; }
|
||||
|
||||
void DockArea::paint(Painter&) {}
|
||||
|
||||
void DockArea::paintOverlay(Painter& painter) {
|
||||
if (!mDragged) return;
|
||||
|
||||
const Palette& palette = theme().palette;
|
||||
const halnf radius = theme().metrics.popupCornerRadius;
|
||||
|
||||
for (DockSide side : { DockSide::Left, DockSide::Right, DockSide::Bottom }) {
|
||||
if (indexOf(side) == mDropSide) continue;
|
||||
const RectF target = dropTarget(side);
|
||||
painter.fillRect(target, withAlpha(palette.text, 0.08f), radius);
|
||||
painter.strokeRect(target, withAlpha(palette.mutedText, 0.6f), radius);
|
||||
}
|
||||
|
||||
const halnf opacity = mDropPreviewOpacity.value();
|
||||
if (opacity <= 0.01f) return;
|
||||
|
||||
const RectF preview = mDropPreview.value();
|
||||
painter.fillRect(preview, withAlpha(palette.text, 0.10f * opacity), radius);
|
||||
painter.strokeRect(preview, withAlpha(palette.mutedText, 0.9f * opacity), radius, 1.5f);
|
||||
}
|
||||
|
||||
void DockArea::onTick(halnf deltaMs) {
|
||||
const bool previewMoving = mDropPreview.advance(deltaMs);
|
||||
const bool opacityMoving = mDropPreviewOpacity.advance(deltaMs);
|
||||
|
||||
requestPaint();
|
||||
if (!previewMoving && !opacityMoving && !mDragged) stopTicking();
|
||||
}
|
||||
|
||||
bool DockArea::hitTest(Vec2F localPoint) const {
|
||||
const Geometry geometry = computeGeometry(localRect());
|
||||
return contains(geometry.stripes[0], localPoint) || contains(geometry.stripes[1], localPoint);
|
||||
}
|
||||
|
||||
DockArea::Geometry DockArea::computeGeometry(const RectF& area) const {
|
||||
Geometry geometry;
|
||||
const halnf thickness = stripeThickness();
|
||||
|
||||
RectF inner = area;
|
||||
geometry.stripes[0] = makeRect(area.pos.x, area.pos.y, 0, area.size.y);
|
||||
geometry.stripes[1] = makeRect(area.pos.x + area.size.x, area.pos.y, 0, area.size.y);
|
||||
|
||||
if (hasStripe(DockSide::Left)) {
|
||||
geometry.stripes[0].size.x = thickness;
|
||||
inner.pos.x += thickness;
|
||||
inner.size.x -= thickness;
|
||||
}
|
||||
if (hasStripe(DockSide::Right)) {
|
||||
geometry.stripes[1].pos.x -= thickness;
|
||||
geometry.stripes[1].size.x = thickness;
|
||||
inner.size.x -= thickness;
|
||||
}
|
||||
geometry.inner = inner;
|
||||
|
||||
const DockPanel* left = openPanelOn(DockSide::Left);
|
||||
const DockPanel* right = openPanelOn(DockSide::Right);
|
||||
const DockPanel* bottom = openPanelOn(DockSide::Bottom);
|
||||
|
||||
const halnf leftWidth = left ? clampDockedLength(left->mDockedLength, inner.size.x) : 0;
|
||||
const halnf rightWidth = right ? clampDockedLength(right->mDockedLength, inner.size.x) : 0;
|
||||
|
||||
geometry.zones[indexOf(DockSide::Left)] = makeRect(inner.pos.x, inner.pos.y, leftWidth, inner.size.y);
|
||||
geometry.zones[indexOf(DockSide::Right)] =
|
||||
makeRect(inner.pos.x + inner.size.x - rightWidth, inner.pos.y, rightWidth, inner.size.y);
|
||||
|
||||
RectF center = inner;
|
||||
const halnf gap = theme().metrics.surfaceGap;
|
||||
for (RectF& unused : geometry.gaps)
|
||||
unused = makeRect(0, 0, 0, 0);
|
||||
|
||||
if (left && left->mViewMode != DockPanel::ViewMode::Float) {
|
||||
geometry.gaps[indexOf(DockSide::Left)] = makeRect(inner.pos.x + leftWidth, inner.pos.y, gap, inner.size.y);
|
||||
}
|
||||
if (left && left->mViewMode == DockPanel::ViewMode::Docked) {
|
||||
center.pos.x += leftWidth + gap;
|
||||
center.size.x -= leftWidth + gap;
|
||||
}
|
||||
if (right && right->mViewMode != DockPanel::ViewMode::Float) {
|
||||
geometry.gaps[indexOf(DockSide::Right)] =
|
||||
makeRect(inner.pos.x + inner.size.x - rightWidth - gap, inner.pos.y, gap, inner.size.y);
|
||||
}
|
||||
if (right && right->mViewMode == DockPanel::ViewMode::Docked) center.size.x -= rightWidth + gap;
|
||||
|
||||
const halnf bottomHeight = bottom ? clampDockedLength(bottom->mDockedLength, inner.size.y) : 0;
|
||||
geometry.zones[indexOf(DockSide::Bottom)] =
|
||||
makeRect(center.pos.x, inner.pos.y + inner.size.y - bottomHeight, center.size.x, bottomHeight);
|
||||
if (bottom && bottom->mViewMode != DockPanel::ViewMode::Float) {
|
||||
geometry.gaps[indexOf(DockSide::Bottom)] =
|
||||
makeRect(center.pos.x, inner.pos.y + inner.size.y - bottomHeight - gap, center.size.x, gap);
|
||||
}
|
||||
if (bottom && bottom->mViewMode == DockPanel::ViewMode::Docked) center.size.y -= bottomHeight + gap;
|
||||
|
||||
geometry.center = center;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
RectF DockArea::panelRect(const DockPanel& panel, const Geometry& geometry, const RectF& area) const {
|
||||
if (!panel.mDragging && panel.mViewMode != DockPanel::ViewMode::Float) return geometry.zones[indexOf(panel.mSide)];
|
||||
|
||||
RectF rect = panel.mFloatingRect;
|
||||
rect.pos.x = clampValue(
|
||||
rect.pos.x, area.pos.x - rect.size.x + kVisibleWhenOffscreen, area.pos.x + area.size.x - kVisibleWhenOffscreen
|
||||
);
|
||||
rect.pos.y = clampValue(rect.pos.y, area.pos.y, area.pos.y + area.size.y - kVisibleWhenOffscreen);
|
||||
return rect;
|
||||
}
|
||||
|
||||
bool DockArea::hasStripe(DockSide side) const {
|
||||
for (const DockPanel* panel : mPanels) {
|
||||
const bool onLeftRail = panel->mSide == DockSide::Left || panel->mSide == DockSide::Bottom;
|
||||
if (side == DockSide::Right ? panel->mSide == DockSide::Right : onLeftRail) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
halnf DockArea::stripeThickness() const { return theme().metrics.controlHeight + kStripeInset * 2; }
|
||||
|
||||
halnf DockArea::clampDockedLength(halnf length, halnf extent) const {
|
||||
const halnf maximum = extent * kMaximumDockedFraction;
|
||||
return clampValue(length, std::min(kMinimumDockedLength, maximum), maximum);
|
||||
}
|
||||
|
||||
RectF DockArea::dropTarget(DockSide side) const {
|
||||
const RectF center = computeGeometry(localRect()).inner;
|
||||
const halnf size = std::max(kMinimumDropTarget, std::min(center.size.x, center.size.y) * kDropTargetFraction);
|
||||
const RectF zone = insetRect(
|
||||
[&]() {
|
||||
switch (side) {
|
||||
case DockSide::Left:
|
||||
return makeRect(center.pos.x, center.pos.y, center.size.x * kDropZoneFraction, center.size.y);
|
||||
case DockSide::Right:
|
||||
return makeRect(
|
||||
center.pos.x + center.size.x * (1 - kDropZoneFraction),
|
||||
center.pos.y,
|
||||
center.size.x * kDropZoneFraction,
|
||||
center.size.y
|
||||
);
|
||||
case DockSide::Bottom:
|
||||
return makeRect(
|
||||
center.pos.x,
|
||||
center.pos.y + center.size.y * (1 - kDropZoneFraction),
|
||||
center.size.x,
|
||||
center.size.y * kDropZoneFraction
|
||||
);
|
||||
}
|
||||
return center;
|
||||
}(),
|
||||
kDropZoneInset
|
||||
);
|
||||
|
||||
return makeRect(zone.center() - Vec2F(size, size) / 2.0f, Vec2F(size, size));
|
||||
}
|
||||
|
||||
long DockArea::dropSideAt(Vec2F localPoint) const {
|
||||
for (DockSide side : { DockSide::Left, DockSide::Right, DockSide::Bottom }) {
|
||||
if (contains(dropTarget(side), localPoint)) return indexOf(side);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
RectF DockArea::zonePreview(const DockPanel& panel, DockSide side) const {
|
||||
const Geometry geometry = computeGeometry(localRect());
|
||||
const RectF& inner = geometry.inner;
|
||||
RectF zone;
|
||||
|
||||
if (side == DockSide::Bottom) {
|
||||
const halnf height = clampDockedLength(panel.mDockedLength, inner.size.y);
|
||||
zone = makeRect(geometry.center.pos.x, inner.pos.y + inner.size.y - height, geometry.center.size.x, height);
|
||||
} else {
|
||||
const halnf width = clampDockedLength(panel.mDockedLength, inner.size.x);
|
||||
const halnf left = side == DockSide::Left ? inner.pos.x : inner.pos.x + inner.size.x - width;
|
||||
zone = makeRect(left, inner.pos.y, width, inner.size.y);
|
||||
}
|
||||
|
||||
return insetRect(zone, kDropZoneInset / 2);
|
||||
}
|
||||
|
||||
void DockArea::updateDropPreview(const DockPanel& panel, Vec2F localPointer) {
|
||||
const long side = dropSideAt(localPointer);
|
||||
|
||||
if (side >= 0) {
|
||||
const RectF zone = zonePreview(panel, (DockSide) side);
|
||||
if (mDropSide < 0) mDropPreview.jumpTo(dropTarget((DockSide) side));
|
||||
mDropPreview.animateTo(zone);
|
||||
mDropPreviewOpacity.animateTo(1);
|
||||
} else {
|
||||
if (mDropSide >= 0) mDropPreview.animateTo(dropTarget((DockSide) mDropSide));
|
||||
mDropPreviewOpacity.animateTo(0);
|
||||
}
|
||||
|
||||
mDropSide = side;
|
||||
startTicking();
|
||||
}
|
||||
|
||||
void DockArea::closeOthersOnSide(const DockPanel& panel) {
|
||||
if (panel.mViewMode == DockPanel::ViewMode::Float) return;
|
||||
|
||||
for (DockPanel* other : mPanels) {
|
||||
const bool sharesZone =
|
||||
other != &panel && other->mSide == panel.mSide && other->mViewMode != DockPanel::ViewMode::Float;
|
||||
if (sharesZone && other->mOpen) hidePanel(*other);
|
||||
}
|
||||
}
|
||||
|
||||
void DockArea::rebuildStripes() {
|
||||
for (Stripe& stripe : mStripes)
|
||||
removeChild(*stripe.button);
|
||||
mStripes.clear();
|
||||
|
||||
for (DockPanel* panel : mPanels) {
|
||||
auto button = std::make_unique<StripeButton>(panel->title(), panel->mIcon);
|
||||
button->setOnActivate([this, panel]() { togglePanel(*panel); });
|
||||
|
||||
addChild(*button);
|
||||
mStripes.push_back({ std::move(button), panel });
|
||||
}
|
||||
|
||||
refreshStripeSelection();
|
||||
refreshEdgeHandles();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void DockArea::refreshEdgeHandles() {
|
||||
for (DockSide side : { DockSide::Left, DockSide::Right, DockSide::Bottom }) {
|
||||
const DockPanel* panel = openPanelOn(side);
|
||||
ResizeHandle& handle = *mEdgeHandles[indexOf(side)];
|
||||
handle.setVisible(panel && panel->mViewMode != DockPanel::ViewMode::Float);
|
||||
handle.bringToFront();
|
||||
}
|
||||
}
|
||||
|
||||
void DockArea::refreshStripeSelection() {
|
||||
for (Stripe& stripe : mStripes)
|
||||
stripe.button->setSelected(stripe.panel->mOpen);
|
||||
}
|
||||
|
||||
void DockArea::panelDragStarted(DockPanel& panel) {
|
||||
const RectF current = panel.rect();
|
||||
const Vec2F size =
|
||||
panel.mViewMode == DockPanel::ViewMode::Float
|
||||
? panel.mFloatingRect.size
|
||||
: Vec2F(std::min(current.size.x, kMaximumDraggedSize.x), std::min(current.size.y, kMaximumDraggedSize.y));
|
||||
|
||||
panel.mGrabOffset.x = std::min(panel.mGrabOffset.x, size.x - kVisibleWhenOffscreen);
|
||||
panel.mFloatingRect = makeRect(current.pos, size);
|
||||
panel.bringToFront();
|
||||
|
||||
mDragged = &panel;
|
||||
mDropSide = -1;
|
||||
mDropPreviewOpacity.jumpTo(0);
|
||||
refreshEdgeHandles();
|
||||
startTicking();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void DockArea::panelDragged(DockPanel& panel, Vec2F windowPointer) {
|
||||
const Vec2F local = toLocal(windowPointer);
|
||||
|
||||
panel.mFloatingRect.pos = local - panel.mGrabOffset;
|
||||
updateDropPreview(panel, local);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void DockArea::panelDragEnded(DockPanel& panel, Vec2F windowPointer) {
|
||||
panelDragged(panel, windowPointer);
|
||||
|
||||
const long side = mDropSide;
|
||||
mDragged = nullptr;
|
||||
mDropSide = -1;
|
||||
mDropPreviewOpacity.animateTo(0);
|
||||
|
||||
if (side >= 0) {
|
||||
dockPanel(panel, (DockSide) side);
|
||||
return;
|
||||
}
|
||||
|
||||
panel.mViewMode = DockPanel::ViewMode::Float;
|
||||
panel.refreshChrome();
|
||||
openPanel(panel);
|
||||
}
|
||||
|
||||
RectF DockArea::openingRect(const DockPanel& panel) const {
|
||||
const RectF target = panelRect(panel, computeGeometry(localRect()), localRect());
|
||||
|
||||
if (panel.mViewMode == DockPanel::ViewMode::Float) {
|
||||
const Vec2F size = target.size * kFloatOpeningScale;
|
||||
return makeRect(target.center() - size / 2.0f, size);
|
||||
}
|
||||
|
||||
switch (panel.mSide) {
|
||||
case DockSide::Left: return makeRect(target.pos.x, target.pos.y, 0, target.size.y);
|
||||
case DockSide::Right: return makeRect(target.pos.x + target.size.x, target.pos.y, 0, target.size.y);
|
||||
case DockSide::Bottom: return makeRect(target.pos.x, target.pos.y + target.size.y, target.size.x, 0);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
void DockArea::beginDirectResize(DockPanel& panel) {
|
||||
panel.setGeometryAnimated(false);
|
||||
if (mCenter && panel.mViewMode == DockPanel::ViewMode::Docked) mCenter->setGeometryAnimated(false);
|
||||
}
|
||||
|
||||
void DockArea::endDirectResize(DockPanel& panel) {
|
||||
panel.setGeometryAnimated(true);
|
||||
if (mCenter) mCenter->setGeometryAnimated(true);
|
||||
if (panel.mViewMode == DockPanel::ViewMode::Undocked && panel.mOpen && ui()) ui()->setActiveWidget(&panel);
|
||||
}
|
||||
|
||||
void DockArea::floatResized(DockPanel& panel, Vec2F localBottomRight) {
|
||||
panel.setFloatingRect(makeRect(panel.mFloatingRect.pos, localBottomRight - panel.mFloatingRect.pos));
|
||||
}
|
||||
|
||||
void DockArea::edgeDragged(DockSide side, halnf windowCoordinate) {
|
||||
DockPanel* panel = openPanelOn(side);
|
||||
if (!panel) return;
|
||||
|
||||
const RectF inner = computeGeometry(localRect()).inner;
|
||||
const halnf halfGap = theme().metrics.surfaceGap / 2;
|
||||
const Vec2F origin = windowPosition();
|
||||
|
||||
switch (side) {
|
||||
case DockSide::Left: panel->setDockedLength(windowCoordinate - origin.x - inner.pos.x - halfGap); break;
|
||||
case DockSide::Right:
|
||||
panel->setDockedLength(inner.pos.x + inner.size.x - (windowCoordinate - origin.x) - halfGap);
|
||||
break;
|
||||
case DockSide::Bottom:
|
||||
panel->setDockedLength(inner.pos.y + inner.size.y - (windowCoordinate - origin.y) - halfGap);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void DockArea::panelLostActivity(DockPanel& panel) {
|
||||
if (panel.mViewMode != DockPanel::ViewMode::Undocked || !panel.mOpen || panel.mDragging) return;
|
||||
|
||||
Widget* active = ui() ? ui()->activeWidget() : nullptr;
|
||||
const Button* button = stripeButton(panel);
|
||||
if (active && button && button->isSelfOrAncestorOf(*active)) return;
|
||||
const ResizeHandle& handle = *mEdgeHandles[indexOf(panel.mSide)];
|
||||
if (active && handle.isSelfOrAncestorOf(*active)) return;
|
||||
|
||||
hidePanel(panel);
|
||||
}
|
||||
106
Widgets/src/components/docking/private/FloatingPanel.cpp
Normal file
106
Widgets/src/components/docking/private/FloatingPanel.cpp
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#include "FloatingPanel.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Event.hpp"
|
||||
#include "StackLayout.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kGripSize = 14;
|
||||
}
|
||||
|
||||
FloatingPanel::FloatingPanel(std::string title) :
|
||||
Panel(Surface::Raised),
|
||||
mTitle(std::move(title)) {
|
||||
setDebugName("floating panel");
|
||||
setBorder(true);
|
||||
|
||||
emplaceLayout<ColumnLayout>(0);
|
||||
|
||||
mTitleBar.setSizing(AxisSizing::fill(), AxisSizing::fixed(Theme::standard().metrics.controlHeight));
|
||||
mTitleBar.emplaceLayout<RowLayout>().setPadding(Insets::symmetric(8, 0));
|
||||
mTitle.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mTitleBar.addChild(mTitle);
|
||||
|
||||
mBody.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mBody.emplaceLayout<StackLayout>().setPadding(Insets::uniform(6));
|
||||
|
||||
addChild(mTitleBar);
|
||||
addChild(mBody);
|
||||
applySize();
|
||||
}
|
||||
|
||||
void FloatingPanel::setContent(Widget* content) {
|
||||
if (mContent) mBody.removeChild(*mContent);
|
||||
|
||||
mContent = content;
|
||||
if (mContent) mBody.addChild(*mContent);
|
||||
}
|
||||
|
||||
void FloatingPanel::setPanelSize(Vec2F size) {
|
||||
mPanelSize = { size.x > mMinimumSize.x ? size.x : mMinimumSize.x, size.y > mMinimumSize.y ? size.y : mMinimumSize.y };
|
||||
applySize();
|
||||
}
|
||||
|
||||
RectF FloatingPanel::titleBarRect() const { return mTitleBar.rect(); }
|
||||
|
||||
RectF FloatingPanel::resizeGripRect() const {
|
||||
return makeRect(size().x - kGripSize, size().y - kGripSize, kGripSize, kGripSize);
|
||||
}
|
||||
|
||||
void FloatingPanel::onEvent(Event& event) {
|
||||
switch (event.type()) {
|
||||
case Event::Type::PointerDown:
|
||||
{
|
||||
if (event.button() != PointerButton::Primary) return;
|
||||
|
||||
const Vec2F local = event.position();
|
||||
if (contains(resizeGripRect(), local)) mInteraction = Interaction::Resizing;
|
||||
else if (contains(titleBarRect(), local)) mInteraction = Interaction::Moving;
|
||||
else return;
|
||||
|
||||
mGrabOffset = mInteraction == Interaction::Moving ? local : local - size();
|
||||
bringToFront();
|
||||
ui()->capturePointer(*this);
|
||||
event.accept();
|
||||
break;
|
||||
}
|
||||
|
||||
case Event::Type::PointerMove:
|
||||
{
|
||||
if (mInteraction == Interaction::None || !parent()) return;
|
||||
|
||||
const Vec2F inParent = event.windowPosition() - parent()->windowPosition();
|
||||
if (mInteraction == Interaction::Moving) requestPosition(inParent - mGrabOffset);
|
||||
else setPanelSize(event.position() - mGrabOffset);
|
||||
event.accept();
|
||||
break;
|
||||
}
|
||||
|
||||
case Event::Type::PointerUp:
|
||||
if (mInteraction == Interaction::None) return;
|
||||
mInteraction = Interaction::None;
|
||||
ui()->releasePointer(*this);
|
||||
event.accept();
|
||||
break;
|
||||
|
||||
default: return;
|
||||
}
|
||||
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void FloatingPanel::paintOverlay(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const RectF grip = resizeGripRect();
|
||||
const RGBA color = isResizing() ? style.palette.accent : style.palette.mutedText;
|
||||
|
||||
painter.line(grip.pos + Vec2F(grip.size.x, 4.0f), grip.pos + Vec2F(4.0f, grip.size.y), color, 1);
|
||||
painter.line(grip.pos + Vec2F(grip.size.x, 9.0f), grip.pos + Vec2F(9.0f, grip.size.y), color, 1);
|
||||
}
|
||||
|
||||
void FloatingPanel::applySize() { setSizing(AxisSizing::preferred(mPanelSize.x), AxisSizing::preferred(mPanelSize.y)); }
|
||||
123
Widgets/src/components/docking/private/FullViewFrame.cpp
Normal file
123
Widgets/src/components/docking/private/FullViewFrame.cpp
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
#include "FullViewFrame.hpp"
|
||||
|
||||
#include "Event.hpp"
|
||||
#include "Painter.hpp"
|
||||
#include "StackLayout.hpp"
|
||||
#include "Theme.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
|
||||
Vec2F mix(Vec2F from, Vec2F to, halnf t) { return from + (to - from) * t; }
|
||||
}
|
||||
|
||||
FullViewFrame::FullViewFrame() {
|
||||
setDebugName("full view frame");
|
||||
setClipsChildren(true);
|
||||
setGeometryAnimated(false);
|
||||
setVisible(false);
|
||||
emplaceLayout<StackLayout>().setPadding(
|
||||
{ kAccentInset + kAccentWidth + kContentPadding, kContentPadding, kContentPadding, kContentPadding }
|
||||
);
|
||||
|
||||
mContentSlot.setDebugName("full view content");
|
||||
mContentSlot.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mContentSlot.emplaceLayout<StackLayout>();
|
||||
mContentSlot.setVisible(false);
|
||||
addChild(mContentSlot);
|
||||
|
||||
mPreviewSlot.setDebugName("full view preview");
|
||||
mPreviewSlot.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mPreviewSlot.setClipsChildren(true);
|
||||
mPreviewSlot.emplaceLayout<StackLayout>();
|
||||
addChild(mPreviewSlot);
|
||||
}
|
||||
|
||||
FullViewFrame::~FullViewFrame() {
|
||||
setPreview(nullptr);
|
||||
setContent(nullptr);
|
||||
}
|
||||
|
||||
void FullViewFrame::setPreview(Widget* preview) {
|
||||
if (preview == mPreview) return;
|
||||
if (mPreview && mPreview->parent() == &mPreviewSlot) mPreviewSlot.removeChild(*mPreview);
|
||||
mPreview = preview;
|
||||
if (mPreview) {
|
||||
mPreview->setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mPreviewSlot.addChild(*mPreview);
|
||||
}
|
||||
showStage();
|
||||
}
|
||||
|
||||
void FullViewFrame::showStage() {
|
||||
const bool full = isContentShown() && mContent;
|
||||
mPreviewSlot.setVisible(!full && mPreview);
|
||||
mContentSlot.setVisible(full);
|
||||
}
|
||||
|
||||
void FullViewFrame::setContent(Widget* content) {
|
||||
if (content == mContent && (!content || content->parent() == &mContentSlot)) return;
|
||||
if (mContent && mContent->parent() == &mContentSlot) mContentSlot.removeChild(*mContent);
|
||||
mContent = content;
|
||||
if (mContent) {
|
||||
mContent->setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
mContentSlot.addChild(*mContent);
|
||||
}
|
||||
showStage();
|
||||
}
|
||||
|
||||
void FullViewFrame::requestBack() {
|
||||
if (mOnBack) mOnBack();
|
||||
}
|
||||
|
||||
void FullViewFrame::setAccent(std::optional<RGBA> accent) {
|
||||
mAccent = accent;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void FullViewFrame::showProgress(halnf progress, bool animated) {
|
||||
if (animated) mProgress.animateTo(progress);
|
||||
else mProgress.jumpTo(progress);
|
||||
startTicking();
|
||||
progressChanged();
|
||||
}
|
||||
|
||||
bool FullViewFrame::isContentShown() const { return progress() >= kContentShownProgress; }
|
||||
|
||||
RectF FullViewFrame::rectFor(const RectF& item, const RectF& area) const {
|
||||
const RectF full = insetRect(area, kMargin);
|
||||
const halnf t = std::clamp(progress(), 0.0f, 1.0f);
|
||||
return makeRect(mix(item.pos, full.pos, t), mix(item.size, full.size, t));
|
||||
}
|
||||
|
||||
void FullViewFrame::onEvent(Event& event) {
|
||||
if (event.type() != Event::Type::KeyDown || event.keyCode() != Key::Escape) return;
|
||||
if (event.target() != this || !mOnBack) return;
|
||||
event.accept();
|
||||
requestBack();
|
||||
}
|
||||
|
||||
void FullViewFrame::onTick(halnf deltaMs) {
|
||||
if (!mProgress.advance(deltaMs)) stopTicking();
|
||||
progressChanged();
|
||||
}
|
||||
|
||||
void FullViewFrame::progressChanged() {
|
||||
setVisible(progress() > kHiddenProgress || targetProgress() > 0);
|
||||
showStage();
|
||||
if (parent()) parent()->requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
void FullViewFrame::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const halnf radius = style.metrics.cornerRadius;
|
||||
painter.fillRect(localRect(), style.palette.raisedSurface, radius);
|
||||
if (!mAccent) return;
|
||||
const RectF strip = makeRect(kAccentInset, kAccentInset, kAccentWidth, std::max(0.0f, size().y - kAccentInset * 2));
|
||||
painter.fillRect(strip, *mAccent, kAccentWidth / 2);
|
||||
}
|
||||
232
Widgets/src/components/docking/public/Docking.hpp
Normal file
232
Widgets/src/components/docking/public/Docking.hpp
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
#pragma once
|
||||
|
||||
#include "Animation.hpp"
|
||||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "Menus.hpp"
|
||||
#include "ResizeHandle.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
enum class DockSide { Left = 0, Right = 1, Bottom = 2 };
|
||||
|
||||
class DockArea;
|
||||
|
||||
class StripeButton : public Button {
|
||||
public:
|
||||
explicit StripeButton(std::string title, ImageId icon = {});
|
||||
|
||||
[[nodiscard]] halnf stripeLength() const;
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Painter& painter) override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
|
||||
private:
|
||||
[[nodiscard]] RGBA labelColor() const;
|
||||
[[nodiscard]] halnf iconLength() const;
|
||||
};
|
||||
|
||||
class OptionsButton : public PopupButton {
|
||||
public:
|
||||
OptionsButton();
|
||||
|
||||
protected:
|
||||
[[nodiscard]] Extent measureContent(Axis axis, halnf crossLength) const override;
|
||||
void paint(Painter& painter) override;
|
||||
[[nodiscard]] RGBA backgroundTarget() const override;
|
||||
};
|
||||
|
||||
class DockPanel : public Panel {
|
||||
public:
|
||||
enum class ViewMode { Docked, Undocked, Float };
|
||||
|
||||
explicit DockPanel(std::string title, DockSide side = DockSide::Left);
|
||||
~DockPanel() override;
|
||||
|
||||
void setContent(Widget* content);
|
||||
[[nodiscard]] Widget* content() const { return mContent; }
|
||||
|
||||
void setIcon(ImageId icon) { mIcon = icon; }
|
||||
[[nodiscard]] ImageId icon() const { return mIcon; }
|
||||
|
||||
[[nodiscard]] const std::string& title() const { return mTitle.text(); }
|
||||
[[nodiscard]] DockSide side() const { return mSide; }
|
||||
[[nodiscard]] ViewMode viewMode() const { return mViewMode; }
|
||||
[[nodiscard]] bool isOpen() const { return mOpen; }
|
||||
void setOnOpenChanged(std::function<void(bool open)> callback) { mOnOpenChanged = std::move(callback); }
|
||||
[[nodiscard]] bool isDragging() const { return mDragging; }
|
||||
|
||||
void setDockedLength(halnf length);
|
||||
[[nodiscard]] halnf dockedLength() const { return mDockedLength; }
|
||||
void setFloatingRect(const RectF& rect);
|
||||
[[nodiscard]] const RectF& floatingRect() const { return mFloatingRect; }
|
||||
|
||||
[[nodiscard]] PopupButton& optionsButton() { return mOptionsButton; }
|
||||
[[nodiscard]] Menu& optionsMenu() { return mOptionsMenu; }
|
||||
[[nodiscard]] Button& hideButton() { return mHideButton; }
|
||||
[[nodiscard]] RectF headerRect() const { return mHeader.rect(); }
|
||||
[[nodiscard]] RectF resizeGripRect() const;
|
||||
[[nodiscard]] Widget& cornerGrip() { return mCornerGrip; }
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
void paint(Painter& painter) override;
|
||||
void paintOverlay(Painter& painter) override;
|
||||
void onActiveWithinChanged() override;
|
||||
|
||||
private:
|
||||
class PanelLayout;
|
||||
|
||||
class CornerGrip : public Widget {
|
||||
public:
|
||||
explicit CornerGrip(DockPanel& panel);
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
void onHoverChanged() override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void paint(Painter& painter) override;
|
||||
|
||||
private:
|
||||
void refreshReveal();
|
||||
|
||||
DockPanel& mPanel;
|
||||
bool mDragging = false;
|
||||
Vec2F mGrabOffset = { 0, 0 };
|
||||
AnimatedFloat mReveal{ 0, Motion::quick() };
|
||||
};
|
||||
|
||||
friend class DockArea;
|
||||
|
||||
enum class Interaction { None, PendingDrag, Dragging };
|
||||
|
||||
void buildOptionsMenu();
|
||||
void refreshChrome();
|
||||
void beginInteraction(Interaction interaction, const Event& event);
|
||||
void updateInteraction(const Event& event);
|
||||
void endInteraction(const Event& event);
|
||||
|
||||
DockArea* mArea = nullptr;
|
||||
DockSide mSide;
|
||||
ViewMode mViewMode = ViewMode::Docked;
|
||||
bool mOpen = false;
|
||||
bool mDragging = false;
|
||||
ImageId mIcon;
|
||||
|
||||
halnf mDockedLength = 260;
|
||||
RectF mFloatingRect = makeRect(120, 120, 280, 320);
|
||||
|
||||
Interaction mInteraction = Interaction::None;
|
||||
Vec2F mGrabOffset = { 0, 0 };
|
||||
Vec2F mPressPosition = { 0, 0 };
|
||||
|
||||
Widget mHeader;
|
||||
Label mTitle;
|
||||
OptionsButton mOptionsButton;
|
||||
GlyphButton mHideButton{ GlyphButton::Glyph::Hide };
|
||||
Menu mOptionsMenu;
|
||||
Menu mViewModeMenu;
|
||||
Menu mMoveToMenu;
|
||||
Widget mBody;
|
||||
Widget* mContent = nullptr;
|
||||
CornerGrip mCornerGrip{ *this };
|
||||
std::function<void(bool)> mOnOpenChanged;
|
||||
};
|
||||
|
||||
class DockArea : public Widget {
|
||||
public:
|
||||
DockArea();
|
||||
~DockArea() override;
|
||||
|
||||
void setCenter(Widget* center);
|
||||
[[nodiscard]] Widget* center() const { return mCenter; }
|
||||
|
||||
void addPanel(DockPanel& panel);
|
||||
void removePanel(DockPanel& panel);
|
||||
[[nodiscard]] const std::vector<DockPanel*>& panels() const { return mPanels; }
|
||||
|
||||
void openPanel(DockPanel& panel);
|
||||
void hidePanel(DockPanel& panel);
|
||||
void togglePanel(DockPanel& panel);
|
||||
void dockPanel(DockPanel& panel, DockSide side);
|
||||
void setViewMode(DockPanel& panel, DockPanel::ViewMode mode);
|
||||
|
||||
[[nodiscard]] DockPanel* openPanelOn(DockSide side) const;
|
||||
[[nodiscard]] StripeButton* stripeButton(const DockPanel& panel) const;
|
||||
[[nodiscard]] RectF stripeRect(DockSide side) const;
|
||||
[[nodiscard]] ResizeHandle& edgeHandle(DockSide side) { return *mEdgeHandles[(int) side]; }
|
||||
[[nodiscard]] RectF centerRect() const;
|
||||
[[nodiscard]] bool isDraggingPanel() const { return mDragged != nullptr; }
|
||||
[[nodiscard]] bool isPreviewingDrop() const { return mDropSide >= 0; }
|
||||
[[nodiscard]] RectF dropPreview() const { return mDropPreview.value(); }
|
||||
[[nodiscard]] RectF dropTarget(DockSide side) const;
|
||||
|
||||
protected:
|
||||
void paint(Painter& painter) override;
|
||||
void paintOverlay(Painter& painter) override;
|
||||
[[nodiscard]] bool hitTest(Vec2F localPoint) const override;
|
||||
|
||||
private:
|
||||
class DockLayout;
|
||||
friend class DockPanel;
|
||||
|
||||
struct Stripe {
|
||||
std::unique_ptr<StripeButton> button;
|
||||
DockPanel* panel;
|
||||
};
|
||||
|
||||
struct Geometry {
|
||||
RectF stripes[2];
|
||||
RectF inner;
|
||||
RectF zones[3];
|
||||
RectF gaps[3];
|
||||
RectF center;
|
||||
};
|
||||
|
||||
[[nodiscard]] Geometry computeGeometry(const RectF& area) const;
|
||||
[[nodiscard]] RectF panelRect(const DockPanel& panel, const Geometry& geometry, const RectF& area) const;
|
||||
[[nodiscard]] bool hasStripe(DockSide side) const;
|
||||
[[nodiscard]] halnf stripeThickness() const;
|
||||
[[nodiscard]] halnf clampDockedLength(halnf length, halnf extent) const;
|
||||
[[nodiscard]] long dropSideAt(Vec2F localPoint) const;
|
||||
[[nodiscard]] RectF zonePreview(const DockPanel& panel, DockSide side) const;
|
||||
[[nodiscard]] RectF openingRect(const DockPanel& panel) const;
|
||||
|
||||
void beginDirectResize(DockPanel& panel);
|
||||
void endDirectResize(DockPanel& panel);
|
||||
void edgeDragged(DockSide side, halnf windowCoordinate);
|
||||
void updateDropPreview(const DockPanel& panel, Vec2F localPointer);
|
||||
|
||||
void closeOthersOnSide(const DockPanel& panel);
|
||||
void rebuildStripes();
|
||||
void refreshStripeSelection();
|
||||
void refreshEdgeHandles();
|
||||
|
||||
void panelDragStarted(DockPanel& panel);
|
||||
void panelDragged(DockPanel& panel, Vec2F windowPointer);
|
||||
void panelDragEnded(DockPanel& panel, Vec2F windowPointer);
|
||||
void floatResized(DockPanel& panel, Vec2F localBottomRight);
|
||||
void panelLostActivity(DockPanel& panel);
|
||||
|
||||
Widget* mCenter = nullptr;
|
||||
std::vector<DockPanel*> mPanels;
|
||||
std::vector<Stripe> mStripes;
|
||||
std::unique_ptr<ResizeHandle> mEdgeHandles[3];
|
||||
|
||||
protected:
|
||||
void onTick(halnf deltaMs) override;
|
||||
|
||||
private:
|
||||
DockPanel* mDragged = nullptr;
|
||||
long mDropSide = -1;
|
||||
AnimatedRect mDropPreview{ makeRect(0, 0, 0, 0), Motion::fluid(), AnimatedRect::kGeometryTolerance };
|
||||
AnimatedFloat mDropPreviewOpacity{ 0, Motion::quick() };
|
||||
};
|
||||
}
|
||||
50
Widgets/src/components/docking/public/FloatingPanel.hpp
Normal file
50
Widgets/src/components/docking/public/FloatingPanel.hpp
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#pragma once
|
||||
|
||||
#include "Containers.hpp"
|
||||
#include "Label.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
class FloatingPanel : public Panel {
|
||||
public:
|
||||
explicit FloatingPanel(std::string title = {});
|
||||
|
||||
void setTitle(std::string title) { mTitle.setText(std::move(title)); }
|
||||
[[nodiscard]] const std::string& title() const { return mTitle.text(); }
|
||||
|
||||
void setContent(Widget* content);
|
||||
[[nodiscard]] Widget* content() const { return mContent; }
|
||||
|
||||
void setPanelSize(Vec2F size);
|
||||
[[nodiscard]] Vec2F panelSize() const { return mPanelSize; }
|
||||
void setMinimumPanelSize(Vec2F size) { mMinimumSize = size; }
|
||||
|
||||
[[nodiscard]] bool isMoving() const { return mInteraction == Interaction::Moving; }
|
||||
[[nodiscard]] bool isResizing() const { return mInteraction == Interaction::Resizing; }
|
||||
|
||||
[[nodiscard]] RectF titleBarRect() const;
|
||||
[[nodiscard]] RectF resizeGripRect() const;
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
void paintOverlay(Painter& painter) override;
|
||||
|
||||
private:
|
||||
enum class Interaction { None, Moving, Resizing };
|
||||
|
||||
void applySize();
|
||||
|
||||
Label mTitle;
|
||||
Widget mTitleBar;
|
||||
Widget mBody;
|
||||
Widget* mContent = nullptr;
|
||||
|
||||
Vec2F mPanelSize = { 240, 200 };
|
||||
Vec2F mMinimumSize = { 120, 80 };
|
||||
|
||||
Interaction mInteraction = Interaction::None;
|
||||
Vec2F mGrabOffset = { 0, 0 };
|
||||
};
|
||||
}
|
||||
67
Widgets/src/components/docking/public/FullViewFrame.hpp
Normal file
67
Widgets/src/components/docking/public/FullViewFrame.hpp
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
#pragma once
|
||||
|
||||
#include "Animation.hpp"
|
||||
#include "Widget.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
// The most detailed level of an item in a collection view (a tree card, a kanban card): the card itself grown to
|
||||
// fill the host. It paints the card surface with its accent strip; it has no header of its own, so the content
|
||||
// supplies any Back affordance and calls requestBack(). The host places it with rectFor(), which grows from the
|
||||
// item's rect at progress 0 to the host's whole area at progress 1, and springs between progress targets on its
|
||||
// own. Until the midpoint the frame shows the preview (the card's own face, so the card visibly grows); past it,
|
||||
// the full content. The frame takes keyboard
|
||||
// focus itself (hosts give it focus when it opens, a click on its background does too); Escape asks to go back only
|
||||
// while the frame itself has focus, so a focused child such as a text editor keeps its Escape.
|
||||
class FullViewFrame : public Widget {
|
||||
public:
|
||||
FullViewFrame();
|
||||
~FullViewFrame() override;
|
||||
|
||||
void setContent(Widget* content);
|
||||
[[nodiscard]] Widget* content() const { return mContent; }
|
||||
void setPreview(Widget* preview);
|
||||
[[nodiscard]] Widget* preview() const { return mPreview; }
|
||||
void setAccent(std::optional<RGBA> accent);
|
||||
[[nodiscard]] const std::optional<RGBA>& accent() const { return mAccent; }
|
||||
void setOnBack(std::function<void()> callback) { mOnBack = std::move(callback); }
|
||||
void requestBack();
|
||||
|
||||
void showProgress(halnf progress, bool animated = true);
|
||||
[[nodiscard]] halnf progress() const { return mProgress.value(); }
|
||||
[[nodiscard]] halnf targetProgress() const { return mProgress.target(); }
|
||||
[[nodiscard]] bool isAnimating() const { return mProgress.isAnimating(); }
|
||||
[[nodiscard]] bool isShown() const { return progress() > kHiddenProgress || targetProgress() > 0; }
|
||||
[[nodiscard]] bool isContentShown() const;
|
||||
|
||||
[[nodiscard]] RectF rectFor(const RectF& item, const RectF& area) const;
|
||||
|
||||
static constexpr halnf kHiddenProgress = 0.001f;
|
||||
static constexpr halnf kContentShownProgress = 0.5f;
|
||||
static constexpr halnf kContentPadding = 8;
|
||||
static constexpr halnf kMargin = 8;
|
||||
static constexpr halnf kAccentWidth = 4;
|
||||
static constexpr halnf kAccentInset = 5;
|
||||
|
||||
protected:
|
||||
void paint(Painter& painter) override;
|
||||
void onTick(halnf deltaMs) override;
|
||||
void onEvent(Event& event) override;
|
||||
[[nodiscard]] bool acceptsKeyboardFocus() const override { return isShown(); }
|
||||
|
||||
private:
|
||||
void progressChanged();
|
||||
void showStage();
|
||||
|
||||
Widget* mContent = nullptr;
|
||||
Widget mContentSlot;
|
||||
Widget mPreviewSlot;
|
||||
Widget* mPreview = nullptr;
|
||||
std::optional<RGBA> mAccent;
|
||||
AnimatedFloat mProgress{ 0, Motion::smooth(), 0.0005f };
|
||||
std::function<void()> mOnBack;
|
||||
};
|
||||
}
|
||||
555
Widgets/src/components/files/private/FileBrowser.cpp
Normal file
555
Widgets/src/components/files/private/FileBrowser.cpp
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
#include "FileBrowser.hpp"
|
||||
|
||||
#include "BoxLayout.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Ui.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
using namespace tp;
|
||||
using namespace tp::ui;
|
||||
|
||||
namespace {
|
||||
constexpr halnf kBrowserHeight = 300;
|
||||
constexpr halnf kFileDialogWidth = 520;
|
||||
constexpr halnf kRowHeight = 26;
|
||||
constexpr halnf kKindWidth = 18;
|
||||
|
||||
class LocalFileSystem : public FileSystem {
|
||||
public:
|
||||
[[nodiscard]] std::optional<std::vector<FileEntry>> list(const std::string& directory) const override {
|
||||
std::error_code error;
|
||||
std::filesystem::directory_iterator iterator(
|
||||
directory, std::filesystem::directory_options::skip_permission_denied, error
|
||||
);
|
||||
if (error) return std::nullopt;
|
||||
|
||||
std::vector<FileEntry> entries;
|
||||
for (const std::filesystem::directory_entry& item : iterator) {
|
||||
FileEntry entry;
|
||||
entry.name = item.path().filename().string();
|
||||
entry.isDirectory = item.is_directory(error);
|
||||
if (!entry.isDirectory) {
|
||||
const std::uintmax_t size = item.file_size(error);
|
||||
entry.size = error ? 0 : (uint64_t) size;
|
||||
}
|
||||
entries.push_back(std::move(entry));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool isDirectory(const std::string& path) const override {
|
||||
std::error_code error;
|
||||
return std::filesystem::is_directory(path, error);
|
||||
}
|
||||
|
||||
[[nodiscard]] bool exists(const std::string& path) const override {
|
||||
std::error_code error;
|
||||
return std::filesystem::exists(path, error);
|
||||
}
|
||||
|
||||
bool createDirectory(const std::string& path) override {
|
||||
std::error_code error;
|
||||
return std::filesystem::create_directories(path, error) && !error;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<std::string> readText(const std::string& path) const override {
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file) return std::nullopt;
|
||||
|
||||
std::ostringstream content;
|
||||
content << file.rdbuf();
|
||||
return content.str();
|
||||
}
|
||||
|
||||
bool writeText(const std::string& path, const std::string& text) override {
|
||||
std::ofstream file(path, std::ios::binary | std::ios::trunc);
|
||||
return file && (file << text) && file.flush();
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string home() const override {
|
||||
const char* home = std::getenv("HOME");
|
||||
return home && *home ? home : "/";
|
||||
}
|
||||
};
|
||||
|
||||
std::string lowered(std::string text) {
|
||||
for (char& character : text)
|
||||
character = (char) std::tolower((unsigned char) character);
|
||||
return text;
|
||||
}
|
||||
|
||||
bool sortsBefore(const FileEntry& left, const FileEntry& right) {
|
||||
if (left.isDirectory != right.isDirectory) return left.isDirectory;
|
||||
const std::string a = lowered(left.name);
|
||||
const std::string b = lowered(right.name);
|
||||
return a == b ? left.name < right.name : a < b;
|
||||
}
|
||||
|
||||
bool isValidName(const std::string& name) {
|
||||
return !name.empty() && name != "." && name != ".." && name.find('/') == std::string::npos;
|
||||
}
|
||||
}
|
||||
|
||||
FileSystem& FileSystem::local() {
|
||||
static LocalFileSystem fileSystem;
|
||||
return fileSystem;
|
||||
}
|
||||
|
||||
std::string tp::ui::joinPath(const std::string& directory, const std::string& name) {
|
||||
if (directory.empty()) return name;
|
||||
if (directory.back() == '/') return directory + name;
|
||||
return directory + "/" + name;
|
||||
}
|
||||
|
||||
std::string tp::ui::parentPath(const std::string& path) {
|
||||
if (path.empty() || path == "/") return "/";
|
||||
std::string trimmed = path;
|
||||
while (trimmed.size() > 1 && trimmed.back() == '/')
|
||||
trimmed.pop_back();
|
||||
const size_t slash = trimmed.find_last_of('/');
|
||||
if (slash == std::string::npos) return ".";
|
||||
if (slash == 0) return "/";
|
||||
return trimmed.substr(0, slash);
|
||||
}
|
||||
|
||||
bool tp::ui::createDirectories(FileSystem& files, const std::string& directory) {
|
||||
if (files.isDirectory(directory)) return true;
|
||||
const std::string parent = parentPath(directory);
|
||||
if (parent == directory || !createDirectories(files, parent)) return false;
|
||||
return files.createDirectory(directory) || files.isDirectory(directory);
|
||||
}
|
||||
|
||||
std::string tp::ui::fileName(const std::string& path) {
|
||||
std::string trimmed = path;
|
||||
while (trimmed.size() > 1 && trimmed.back() == '/')
|
||||
trimmed.pop_back();
|
||||
const size_t slash = trimmed.find_last_of('/');
|
||||
return slash == std::string::npos ? trimmed : trimmed.substr(slash + 1);
|
||||
}
|
||||
|
||||
std::string tp::ui::describeSize(uint64_t bytes) {
|
||||
const char* const units[] = { "B", "KB", "MB", "GB", "TB" };
|
||||
double value = (double) bytes;
|
||||
size_t unit = 0;
|
||||
while (value >= 1024.0 && unit + 1 < std::size(units)) {
|
||||
value /= 1024.0;
|
||||
unit++;
|
||||
}
|
||||
char text[32];
|
||||
if (unit == 0) std::snprintf(text, sizeof(text), "%llu B", (unsigned long long) bytes);
|
||||
else std::snprintf(text, sizeof(text), value < 10 ? "%.1f %s" : "%.0f %s", value, units[unit]);
|
||||
return text;
|
||||
}
|
||||
|
||||
FileRow::FileRow(const FileEntry& entry, std::function<void(FileRow&)> onSelect, std::function<void(FileRow&)> onOpen) :
|
||||
Button(entry.name),
|
||||
mEntry(entry),
|
||||
mOnSelect(std::move(onSelect)),
|
||||
mOnOpen(std::move(onOpen)) {
|
||||
setDebugName("file row: " + entry.name);
|
||||
setStyle(Style::Flat);
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fixed(kRowHeight));
|
||||
}
|
||||
|
||||
void FileRow::onEvent(Event& event) {
|
||||
if (event.type() == Event::Type::Click && event.button() == PointerButton::Primary) {
|
||||
event.accept();
|
||||
if (mOnSelect) mOnSelect(*this);
|
||||
if (event.clickCount() >= 2 && mOnOpen) mOnOpen(*this);
|
||||
return;
|
||||
}
|
||||
if (event.type() == Event::Type::KeyDown && event.keyCode() == Key::Enter) {
|
||||
event.accept();
|
||||
if (mOnOpen) mOnOpen(*this);
|
||||
return;
|
||||
}
|
||||
Button::onEvent(event);
|
||||
}
|
||||
|
||||
void FileRow::paint(Painter& painter) {
|
||||
const Theme& style = theme();
|
||||
const RectF area = localRect();
|
||||
painter.fillRect(area, background(), style.metrics.cornerRadius);
|
||||
if (showsFocusRing()) painter.strokeRect(area, style.palette.focusRing, style.metrics.cornerRadius, 2);
|
||||
|
||||
const halnf fontSize = style.metrics.fontSize;
|
||||
const RGBA muted = style.palette.mutedText;
|
||||
const halnf pad = style.metrics.controlPadding.left;
|
||||
|
||||
const RectF kind = makeRect(pad, 0, kKindWidth, area.size.y);
|
||||
if (mEntry.isDirectory) {
|
||||
const halnf top = area.size.y * 0.5f - 5;
|
||||
painter.fillRect(makeRect(kind.pos.x, top - 2, 7, 3), muted, 1);
|
||||
painter.strokeRect(makeRect(kind.pos.x, top, 14, 10), muted, 2, 1.25f);
|
||||
} else {
|
||||
const halnf top = area.size.y * 0.5f - 7;
|
||||
painter.strokeRect(makeRect(kind.pos.x + 2, top, 10, 14), muted, 1.5f, 1.25f);
|
||||
}
|
||||
|
||||
const std::string size = mEntry.isDirectory ? std::string() : describeSize(mEntry.size);
|
||||
const halnf sizeWidth = size.empty() ? 0 : textWidth(size, fontSize) + pad;
|
||||
const halnf nameLeft = kind.pos.x + kKindWidth + pad * 0.5f;
|
||||
painter.text(
|
||||
mEntry.name,
|
||||
makeRect(nameLeft, 0, area.size.x - nameLeft - sizeWidth - pad, area.size.y),
|
||||
fontSize,
|
||||
TextAlign::Start,
|
||||
isSelected() ? style.palette.text : (mEntry.isHidden() ? muted : style.palette.text)
|
||||
);
|
||||
if (!size.empty())
|
||||
painter.text(
|
||||
size, makeRect(area.size.x - sizeWidth - pad, 0, sizeWidth, area.size.y), fontSize, TextAlign::End, muted
|
||||
);
|
||||
}
|
||||
|
||||
FileBrowser::FileBrowser(FileSystem& fileSystem) :
|
||||
mFileSystem(fileSystem) {
|
||||
setDebugName("file browser");
|
||||
setSizing(AxisSizing::fill(), AxisSizing::fixed(kBrowserHeight));
|
||||
emplaceLayout<ColumnLayout>(6);
|
||||
|
||||
mHeader.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mHeader.emplaceLayout<RowLayout>(6).setCrossAlign(CrossAlign::Center);
|
||||
up.setTooltip("Parent folder (Backspace)");
|
||||
up.setOnActivate([this]() { goUp(); });
|
||||
path.setOnPartChosen([this](size_t index) {
|
||||
const std::vector<std::string> directories = pathDirectories();
|
||||
if (index < directories.size()) setDirectory(directories[index]);
|
||||
});
|
||||
mHeader.addChild(up);
|
||||
mHeader.addChild(path);
|
||||
|
||||
mList.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mList.emplaceLayout<ColumnLayout>(1);
|
||||
mEmpty.setTone(Label::Tone::Muted);
|
||||
scroll.setSizing(AxisSizing::fill(), AxisSizing::fill());
|
||||
scroll.setContent(&mList);
|
||||
|
||||
addChild(mHeader);
|
||||
addChild(scroll);
|
||||
}
|
||||
|
||||
bool FileBrowser::setDirectory(const std::string& directory) {
|
||||
std::optional<std::vector<FileEntry>> listed = mFileSystem.list(directory);
|
||||
if (!listed) {
|
||||
mError = "Can't open " + directory;
|
||||
return false;
|
||||
}
|
||||
|
||||
mError.clear();
|
||||
const bool changed = directory != mDirectory;
|
||||
mDirectory = directory;
|
||||
mEntries = std::move(*listed);
|
||||
std::sort(mEntries.begin(), mEntries.end(), sortsBefore);
|
||||
if (changed) mSelected.clear();
|
||||
refreshPath();
|
||||
up.setEnabled(!isAtRoot());
|
||||
rebuildRows();
|
||||
if (changed) scroll.jumpTo(0);
|
||||
if (changed && onDirectoryChanged) onDirectoryChanged(mDirectory);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FileBrowser::isAtRoot() const { return mDirectory.empty() || mDirectory == "/" || mDirectory == mRootDirectory; }
|
||||
|
||||
void FileBrowser::setRootDirectory(std::string root) {
|
||||
mRootDirectory = root.empty() ? std::string("/") : std::move(root);
|
||||
up.setEnabled(!isAtRoot());
|
||||
refreshPath();
|
||||
}
|
||||
|
||||
bool FileBrowser::isInsideRoot(const std::string& directory) const {
|
||||
if (mRootDirectory == "/" || directory == mRootDirectory) return true;
|
||||
return directory.rfind(mRootDirectory + "/", 0) == 0;
|
||||
}
|
||||
|
||||
std::vector<std::string> FileBrowser::pathDirectories() const {
|
||||
std::vector<std::string> directories;
|
||||
if (mDirectory.empty()) return directories;
|
||||
|
||||
const std::string top = isInsideRoot(mDirectory) ? mRootDirectory : std::string("/");
|
||||
std::string directory = mDirectory;
|
||||
while (directory != top) {
|
||||
directories.push_back(directory);
|
||||
const std::string parent = parentPath(directory);
|
||||
if (parent == directory) break;
|
||||
directory = parent;
|
||||
}
|
||||
if (directory == top) directories.push_back(top);
|
||||
std::reverse(directories.begin(), directories.end());
|
||||
return directories;
|
||||
}
|
||||
|
||||
void FileBrowser::refreshPath() {
|
||||
const std::vector<std::string> directories = pathDirectories();
|
||||
std::vector<std::string> names;
|
||||
for (const std::string& directory : directories) {
|
||||
const std::string name = fileName(directory);
|
||||
names.push_back(name.empty() ? directory : name);
|
||||
}
|
||||
path.setParts(names);
|
||||
for (size_t index = 0; index < directories.size(); index++)
|
||||
path.part(index).setTooltip(directories[index]);
|
||||
}
|
||||
|
||||
bool FileBrowser::goUp() {
|
||||
if (isAtRoot()) return false;
|
||||
const std::string child = fileName(mDirectory);
|
||||
if (!setDirectory(parentPath(mDirectory))) return false;
|
||||
select(child);
|
||||
return true;
|
||||
}
|
||||
|
||||
void FileBrowser::refresh() {
|
||||
if (!mDirectory.empty()) setDirectory(mDirectory);
|
||||
}
|
||||
|
||||
void FileBrowser::setExtensions(std::vector<std::string> extensions) {
|
||||
mExtensions.clear();
|
||||
for (std::string& extension : extensions)
|
||||
mExtensions.push_back(lowered(extension));
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
void FileBrowser::setShowsFiles(bool showsFiles) {
|
||||
mShowsFiles = showsFiles;
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
void FileBrowser::setShowsHidden(bool showsHidden) {
|
||||
mShowsHidden = showsHidden;
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
bool FileBrowser::isListed(const FileEntry& entry) const {
|
||||
if (entry.isHidden() && !mShowsHidden) return false;
|
||||
if (entry.isDirectory) return true;
|
||||
if (!mShowsFiles) return false;
|
||||
if (mExtensions.empty()) return true;
|
||||
const std::string name = lowered(entry.name);
|
||||
return std::any_of(mExtensions.begin(), mExtensions.end(), [&](const std::string& extension) {
|
||||
return name.size() >= extension.size() &&
|
||||
name.compare(name.size() - extension.size(), extension.size(), extension) == 0;
|
||||
});
|
||||
}
|
||||
|
||||
void FileBrowser::rebuildRows() {
|
||||
mList.removeAllChildren();
|
||||
mRows.clear();
|
||||
for (const FileEntry& entry : mEntries) {
|
||||
if (!isListed(entry)) continue;
|
||||
auto row = std::make_unique<FileRow>(
|
||||
entry, [this](FileRow& chosen) { select(chosen.entry().name); }, [this](FileRow& chosen) { open(chosen.entry()); }
|
||||
);
|
||||
row->setSelected(entry.name == mSelected);
|
||||
mList.addChild(*row);
|
||||
mRows.push_back(std::move(row));
|
||||
}
|
||||
if (mRows.empty()) mList.addChild(mEmpty);
|
||||
}
|
||||
|
||||
FileRow* FileBrowser::row(const std::string& name) const {
|
||||
for (const auto& row : mRows) {
|
||||
if (row->entry().name == name) return row.get();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const FileEntry* FileBrowser::selected() const {
|
||||
const FileRow* found = row(mSelected);
|
||||
return found ? &found->entry() : nullptr;
|
||||
}
|
||||
|
||||
void FileBrowser::select(const std::string& name) {
|
||||
mSelected = name;
|
||||
for (const auto& row : mRows)
|
||||
row->setSelected(row->entry().name == name);
|
||||
const FileEntry* entry = selected();
|
||||
if (entry && onSelected) onSelected(*entry);
|
||||
}
|
||||
|
||||
void FileBrowser::open(const FileEntry& entry) {
|
||||
const std::string target = joinPath(mDirectory, entry.name);
|
||||
if (entry.isDirectory) {
|
||||
setDirectory(target);
|
||||
return;
|
||||
}
|
||||
if (onFileOpened) onFileOpened(target);
|
||||
}
|
||||
|
||||
void FileBrowser::onEvent(Event& event) {
|
||||
if (event.type() != Event::Type::KeyDown || event.keyCode() != Key::Backspace) return;
|
||||
if (ui() && dynamic_cast<TextField*>(ui()->keyboardFocus())) return;
|
||||
event.accept();
|
||||
goUp();
|
||||
}
|
||||
|
||||
FileDialog::FileDialog(FileSystem& fileSystem) :
|
||||
Dialog("Choose file"),
|
||||
browser(fileSystem) {
|
||||
setDebugName("file dialog");
|
||||
setSizing(AxisSizing::fixed(kFileDialogWidth), AxisSizing::fit());
|
||||
|
||||
name.setPlaceholder("Name");
|
||||
name.setOnCommitted([this](const std::string&) {
|
||||
if (isOpen() && mCommitFromEnter) confirm();
|
||||
});
|
||||
problem.setTone(Label::Tone::Muted);
|
||||
problem.setVisible(false);
|
||||
|
||||
browser.onSelected = [this](const FileEntry& entry) { syncNameFromSelection(entry); };
|
||||
browser.onFileOpened = [this](const std::string& path) {
|
||||
if (mMode == Mode::Open) finish(path);
|
||||
if (mMode == Mode::Save) {
|
||||
name.setText(fileName(path));
|
||||
confirm();
|
||||
}
|
||||
};
|
||||
browser.onDirectoryChanged = [this](const std::string&) { problem.setVisible(false); };
|
||||
|
||||
hidden.setOnChanged([this](bool checked) { browser.setShowsHidden(checked); });
|
||||
newFolder.setOnActivate([this]() { createFolder(); });
|
||||
|
||||
mOptions.setSizing(AxisSizing::fill(), AxisSizing::fit());
|
||||
mOptions.emplaceLayout<RowLayout>(8);
|
||||
mOptions.addChild(newFolder);
|
||||
mOptions.addChild(hidden);
|
||||
|
||||
body().addChild(browser);
|
||||
body().addChild(mOptions);
|
||||
body().addChild(name);
|
||||
body().addChild(problem);
|
||||
|
||||
mConfirm = &addButton("Open", {}, false);
|
||||
mConfirm->setStyle(Button::Style::Accent);
|
||||
mConfirm->setOnActivate([this]() { confirm(); });
|
||||
addCancelButton();
|
||||
}
|
||||
|
||||
void FileDialog::filterDescendantEvent(Event& event) {
|
||||
const bool enter =
|
||||
event.type() == Event::Type::KeyDown && (event.keyCode() == Key::Enter || event.keyCode() == Key::KeypadEnter);
|
||||
mCommitFromEnter = enter && event.target() == &name;
|
||||
}
|
||||
|
||||
void FileDialog::onEvent(Event& event) {
|
||||
if (event.type() != Event::Type::KeyDown) return;
|
||||
if (event.keyCode() != Key::Enter && event.keyCode() != Key::KeypadEnter) return;
|
||||
if (ui() && dynamic_cast<FileRow*>(ui()->keyboardFocus())) return;
|
||||
event.accept();
|
||||
confirm();
|
||||
}
|
||||
|
||||
void FileDialog::show(
|
||||
Ui& ui,
|
||||
Mode mode,
|
||||
std::string title,
|
||||
const std::string& directory,
|
||||
const std::string& suggestedName,
|
||||
std::function<void(const std::string&)> onChosen
|
||||
) {
|
||||
mMode = mode;
|
||||
mOnChosen = std::move(onChosen);
|
||||
setTitle(std::move(title));
|
||||
problem.setVisible(false);
|
||||
name.setText(suggestedName);
|
||||
name.setVisible(mode != Mode::ChooseDirectory);
|
||||
browser.setShowsFiles(mode != Mode::ChooseDirectory);
|
||||
mConfirm->setText(mode == Mode::Save ? "Save" : (mode == Mode::Open ? "Open" : "Choose"));
|
||||
|
||||
FileSystem& fileSystem = browser.fileSystem();
|
||||
std::string start = directory;
|
||||
while (!start.empty() && start != "/" && !fileSystem.isDirectory(start))
|
||||
start = parentPath(start);
|
||||
if (start.empty() || !browser.setDirectory(start)) browser.setDirectory(fileSystem.home());
|
||||
|
||||
open(ui);
|
||||
if (mode == Mode::Save) {
|
||||
ui.setKeyboardFocus(&name);
|
||||
name.selectAll();
|
||||
}
|
||||
}
|
||||
|
||||
std::string FileDialog::chosenPath() const {
|
||||
if (mMode == Mode::ChooseDirectory) {
|
||||
const FileEntry* entry = browser.selected();
|
||||
return entry && entry->isDirectory ? joinPath(browser.directory(), entry->name) : browser.directory();
|
||||
}
|
||||
const std::string& typed = name.text();
|
||||
if (typed.empty()) return {};
|
||||
if (typed.front() == '/') return typed;
|
||||
return joinPath(browser.directory(), typed);
|
||||
}
|
||||
|
||||
void FileDialog::confirm() {
|
||||
const std::string path = chosenPath();
|
||||
FileSystem& fileSystem = browser.fileSystem();
|
||||
|
||||
if (mMode != Mode::ChooseDirectory) {
|
||||
const std::string typed = name.text();
|
||||
if (typed.empty() || typed.back() == '/') {
|
||||
problem.setText("Enter a name");
|
||||
problem.setVisible(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (mMode != Mode::ChooseDirectory && fileSystem.isDirectory(path)) {
|
||||
browser.setDirectory(path);
|
||||
name.setText({});
|
||||
return;
|
||||
}
|
||||
if (mMode == Mode::Open && !fileSystem.exists(path)) {
|
||||
problem.setText(fileName(path) + " does not exist");
|
||||
problem.setVisible(true);
|
||||
return;
|
||||
}
|
||||
if (mMode == Mode::Save && fileSystem.exists(path) && ui()) {
|
||||
Ui& host = *ui();
|
||||
overwrite.ask(
|
||||
host,
|
||||
"Replace " + fileName(path) + "?",
|
||||
"It already exists in " + parentPath(path) + ".",
|
||||
"Replace",
|
||||
[this, path]() { finish(path); }
|
||||
);
|
||||
return;
|
||||
}
|
||||
finish(path);
|
||||
}
|
||||
|
||||
void FileDialog::finish(const std::string& path) {
|
||||
close();
|
||||
auto chosen = std::move(mOnChosen);
|
||||
mOnChosen = {};
|
||||
if (chosen) chosen(path);
|
||||
}
|
||||
|
||||
void FileDialog::createFolder() {
|
||||
FileSystem& fileSystem = browser.fileSystem();
|
||||
std::string folder = "New folder";
|
||||
for (int attempt = 2; fileSystem.exists(joinPath(browser.directory(), folder)); attempt++)
|
||||
folder = "New folder " + std::to_string(attempt);
|
||||
|
||||
const std::string typed = name.text();
|
||||
if (mMode == Mode::ChooseDirectory && isValidName(typed)) folder = typed;
|
||||
if (!fileSystem.createDirectory(joinPath(browser.directory(), folder))) {
|
||||
problem.setText("Can't create " + folder);
|
||||
problem.setVisible(true);
|
||||
return;
|
||||
}
|
||||
browser.refresh();
|
||||
browser.select(folder);
|
||||
}
|
||||
|
||||
void FileDialog::syncNameFromSelection(const FileEntry& entry) {
|
||||
problem.setVisible(false);
|
||||
if (entry.isDirectory || mMode == Mode::ChooseDirectory) return;
|
||||
name.setText(entry.name);
|
||||
}
|
||||
169
Widgets/src/components/files/public/FileBrowser.hpp
Normal file
169
Widgets/src/components/files/public/FileBrowser.hpp
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#pragma once
|
||||
|
||||
#include "Breadcrumb.hpp"
|
||||
#include "Button.hpp"
|
||||
#include "Containers.hpp"
|
||||
#include "Dialog.hpp"
|
||||
#include "Label.hpp"
|
||||
#include "TextField.hpp"
|
||||
#include "Toggles.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tp::ui {
|
||||
|
||||
struct FileEntry {
|
||||
std::string name;
|
||||
bool isDirectory = false;
|
||||
uint64_t size = 0;
|
||||
|
||||
[[nodiscard]] bool isHidden() const { return !name.empty() && name.front() == '.'; }
|
||||
[[nodiscard]] bool operator==(const FileEntry& other) const = default;
|
||||
};
|
||||
|
||||
class FileSystem {
|
||||
public:
|
||||
virtual ~FileSystem() = default;
|
||||
|
||||
[[nodiscard]] virtual std::optional<std::vector<FileEntry>> list(const std::string& directory) const = 0;
|
||||
[[nodiscard]] virtual bool isDirectory(const std::string& path) const = 0;
|
||||
[[nodiscard]] virtual bool exists(const std::string& path) const = 0;
|
||||
virtual bool createDirectory(const std::string& path) = 0;
|
||||
[[nodiscard]] virtual std::optional<std::string> readText(const std::string& path) const = 0;
|
||||
virtual bool writeText(const std::string& path, const std::string& text) = 0;
|
||||
[[nodiscard]] virtual std::string home() const = 0;
|
||||
|
||||
[[nodiscard]] static FileSystem& local();
|
||||
};
|
||||
|
||||
[[nodiscard]] std::string joinPath(const std::string& directory, const std::string& name);
|
||||
[[nodiscard]] std::string parentPath(const std::string& path);
|
||||
bool createDirectories(FileSystem& files, const std::string& directory);
|
||||
[[nodiscard]] std::string fileName(const std::string& path);
|
||||
[[nodiscard]] std::string describeSize(uint64_t bytes);
|
||||
|
||||
class FileRow : public Button {
|
||||
public:
|
||||
FileRow(const FileEntry& entry, std::function<void(FileRow&)> onSelect, std::function<void(FileRow&)> onOpen);
|
||||
|
||||
[[nodiscard]] const FileEntry& entry() const { return mEntry; }
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
void paint(Painter& painter) override;
|
||||
|
||||
private:
|
||||
FileEntry mEntry;
|
||||
std::function<void(FileRow&)> mOnSelect;
|
||||
std::function<void(FileRow&)> mOnOpen;
|
||||
};
|
||||
|
||||
class FileBrowser : public Widget {
|
||||
public:
|
||||
explicit FileBrowser(FileSystem& fileSystem = FileSystem::local());
|
||||
|
||||
bool setDirectory(const std::string& directory);
|
||||
[[nodiscard]] const std::string& directory() const { return mDirectory; }
|
||||
bool goUp();
|
||||
void refresh();
|
||||
|
||||
void setRootDirectory(std::string root);
|
||||
[[nodiscard]] const std::string& rootDirectory() const { return mRootDirectory; }
|
||||
|
||||
void setExtensions(std::vector<std::string> extensions);
|
||||
void setShowsFiles(bool showsFiles);
|
||||
void setShowsHidden(bool showsHidden);
|
||||
[[nodiscard]] bool showsHidden() const { return mShowsHidden; }
|
||||
|
||||
[[nodiscard]] const std::vector<FileEntry>& entries() const { return mEntries; }
|
||||
[[nodiscard]] const std::vector<std::unique_ptr<FileRow>>& rows() const { return mRows; }
|
||||
[[nodiscard]] FileRow* row(const std::string& name) const;
|
||||
[[nodiscard]] const FileEntry* selected() const;
|
||||
void select(const std::string& name);
|
||||
|
||||
[[nodiscard]] FileSystem& fileSystem() const { return mFileSystem; }
|
||||
[[nodiscard]] const std::string& error() const { return mError; }
|
||||
|
||||
[[nodiscard]] std::vector<std::string> pathDirectories() const;
|
||||
|
||||
GlyphButton up{ GlyphButton::Glyph::Up };
|
||||
Breadcrumb path;
|
||||
ScrollView scroll;
|
||||
|
||||
std::function<void(const FileEntry&)> onSelected;
|
||||
std::function<void(const std::string& path)> onFileOpened;
|
||||
std::function<void(const std::string& directory)> onDirectoryChanged;
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
|
||||
private:
|
||||
[[nodiscard]] bool isListed(const FileEntry& entry) const;
|
||||
[[nodiscard]] bool isAtRoot() const;
|
||||
[[nodiscard]] bool isInsideRoot(const std::string& directory) const;
|
||||
void rebuildRows();
|
||||
void refreshPath();
|
||||
void open(const FileEntry& entry);
|
||||
|
||||
FileSystem& mFileSystem;
|
||||
std::string mDirectory;
|
||||
std::string mRootDirectory = "/";
|
||||
std::string mError;
|
||||
std::vector<std::string> mExtensions;
|
||||
bool mShowsFiles = true;
|
||||
bool mShowsHidden = false;
|
||||
std::vector<FileEntry> mEntries;
|
||||
std::vector<std::unique_ptr<FileRow>> mRows;
|
||||
std::string mSelected;
|
||||
Widget mHeader;
|
||||
Widget mList;
|
||||
Label mEmpty{ "This folder is empty" };
|
||||
};
|
||||
|
||||
class FileDialog : public Dialog {
|
||||
public:
|
||||
enum class Mode { Open, Save, ChooseDirectory };
|
||||
|
||||
explicit FileDialog(FileSystem& fileSystem = FileSystem::local());
|
||||
|
||||
void show(
|
||||
Ui& ui,
|
||||
Mode mode,
|
||||
std::string title,
|
||||
const std::string& directory,
|
||||
const std::string& suggestedName,
|
||||
std::function<void(const std::string& path)> onChosen
|
||||
);
|
||||
|
||||
[[nodiscard]] Mode mode() const { return mMode; }
|
||||
[[nodiscard]] std::string chosenPath() const;
|
||||
void confirm();
|
||||
|
||||
FileBrowser browser;
|
||||
TextField name;
|
||||
Button newFolder{ "New folder" };
|
||||
Checkbox hidden{ "Show hidden" };
|
||||
Label problem;
|
||||
ConfirmDialog overwrite;
|
||||
|
||||
protected:
|
||||
void onEvent(Event& event) override;
|
||||
void filterDescendantEvent(Event& event) override;
|
||||
|
||||
private:
|
||||
void finish(const std::string& path);
|
||||
void createFolder();
|
||||
void syncNameFromSelection(const FileEntry& entry);
|
||||
|
||||
Mode mMode = Mode::Open;
|
||||
std::function<void(const std::string&)> mOnChosen;
|
||||
Widget mOptions;
|
||||
Button* mConfirm = nullptr;
|
||||
bool mCommitFromEnter = false;
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue