Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/spec/client/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ route the history holds at start, a move back or forward) signs it in the same w
shell's own fact, not read from the source: a local source's signed-out principal is its `--user` name and its
signed-out catalog lists the screens, so neither tells signed in from signed out. The navigator lists the catalog's
screens either way. The top bar's title and the navigator's current entry follow the active tab: each change of the
workspace sets the title to the tab's caption and `Navigator::setCurrent` to its screen.
workspace sets the title to the tab's caption and `Navigator::setCurrent` to its screen. The top bar's head slot
follows the same way, but only re-mounts on a genuine active-tab change (compared by the model's own tab id, not the
bar's index), since the mounted row is itself reactive to its own content.

## Sign-in mapping

Expand Down
11 changes: 7 additions & 4 deletions docs/spec/ui/backend_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,13 @@ reorders children, and only through this.
value with `spacingStep`, so a backend never sees another. Each backend maps a step to its own distance: the theme's
`space.<step>` in px on Qt Quick, cells by axis on the terminal.

**Button emphasis.** `ButtonWidget::setEmphasis(ButtonEmphasis)` takes `Normal`, `Primary` or `Danger`; the mount
calls it only for another value than `Normal`, so a document without the `emphasis` key (`normal`, `primary`,
`danger`) records nothing new. Qt Quick fills a primary button in the accent and a danger one in the error colour;
the terminal draws both bold, a danger one in the error colour.
**Button emphasis.** `ButtonWidget::setEmphasis(ButtonEmphasis)` takes `Normal`, `Primary`, `Danger` or `Ghost`; the
mount calls it only for another value than `Normal`, so a document without the `emphasis` key (`normal`, `primary`,
`danger`, `ghost`) records nothing new. Qt Quick fills a primary button in the accent and a danger one in the error
colour, and draws a ghost button with no border or fill, minimal padding, and a faint neutral tint on hover or press
— for an action embedded in other content (a table row's remove, a disclosure chevron) where full button chrome would
read as a second control beside it rather than part of it. The terminal draws `Primary`/`Danger` bold, a danger one
in the error colour, and falls back to `Normal`'s plain style for `Ghost`.

**Button style and selection.** `ButtonWidget::setStyle(ButtonStyle)` makes a button an entry of a list (`Item`): it
behaves as a push button (activation, enablement, tooltip, hover) and is drawn as a line, as wide as its line, its
Expand Down
4 changes: 2 additions & 2 deletions docs/spec/ui/document.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ An `actionButton` part is one button of a list:
| `id` | name, required | unique within its list |
| `label` | expression, required | |
| `icon` | expression | an icon name |
| `emphasis` | text | `normal`, `primary`, `danger`, as a `button`'s |
| `emphasis` | text | `normal`, `primary`, `danger`, `ghost`, as a `button`'s |
| `enabled`, `visible` | conditions | default `true` |
| `onClick` | commands | run in the scope the list belongs to |

Expand Down Expand Up @@ -162,7 +162,7 @@ left out being content: `"sizing": {"lit": {"width": {"stretch": 1}, "height": {
| Kind | Keys (a ⓘ key is in the node's own scope) | Element values beyond `visible`, `enabled`, `valid`, `errors`, `shownErrors` |
|---|---|---|
| `text` | `text`, `role` (`normal`, the default, `muted`, `heading` (`title` is accepted as its alias), `section`, `error`, `success`) | |
| `button` | `label`, `icon`, `emphasis` (`normal`, `primary`, `danger`), `style` (`push`, the default, or `item`: an entry of a list), `selected` (a condition, only with `style: item`: the entry is the current one), `onClick`, `onActivate` | |
| `button` | `label`, `icon`, `emphasis` (`normal`, `primary`, `danger`, `ghost`), `style` (`push`, the default, or `item`: an entry of a list), `selected` (a condition, only with `style: item`: the entry is the current one), `onClick`, `onActivate` | |
| `textInput` | `value`, `placeholder`, `mode`, `icon` (an icon of the theme's icon set, drawn at the leading edge; refused on a multiline input), `commitOn` (`change`, `blur`, `enter`), `label`, `showLabel` (a literal `bool`; default `false`), `onChange`, `onCommit`, `onSubmit` | `value`, `draft`, `dirty`, `focused`, `touched` |
| `checkbox` | `label`, `checked`, `onToggle`, `onChange` | `checked` |
| `select` | `options`, `selected`, `style`, `label`, `showLabel`, `onStale` (`clear`, `keep`), `onChoose`, `onChange` | `selected`, `selectedOption` |
Expand Down
7 changes: 6 additions & 1 deletion docs/spec/ui/shell_frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ and the page the workspace's tabs sit on. It is built from backend widgets, so e
| a row, stretch × stretch | `shell` | the whole frame |
| a row, surface `sidebar`, `fixed(kShellSidebarWidth)` (28) × stretch, gap 3, its content padded by spacers | `shell_sidebar` | the application's name (`shell_application`, a heading) and a `ui::Navigator` over `ShellFrameOptions::navigator`, whose group headings are sections and whose entries are body text, so the scale runs name, section, entry |
| a column, stretch × stretch | — | the main column |
| a row, surface `toolbar`, gap 3, `align: center`, padded by spacers at both ends | `shell_topbar` | the shown screen's title (`shell_title`, a heading that stretches), the principal (`shell_principal`, muted) and a sign-out button (`shell_signOut`) |
| a row, surface `toolbar`, gap 3, `align: center`, padded by spacers at both ends | `shell_topbar` | the shown screen's title (`shell_title`, a heading that stretches), a slot for its active tab's head row (`shell_head`, its own independent stretch), the principal (`shell_principal`, muted) and a sign-out button (`shell_signOut`) |
| a column, surface `page`, stretch × stretch | `shell_page` | the page: zero-sized spacers above and either side of the content, each a gap of step 4 away, so the content is padded by step 4 |

- `page()` is the content container, where the client places the workspace.
- `navigator()` is the navigator, for its pins and search.
- `headSlot()` is the container a client mounts the active tab's own head row into — `Workspace::
headRowForActive()` builds it — beside the title. Its own `stretch` is independent of whether the mounted row is
currently visible (a screen with no head mounts an invisible row), so the slot keeps claiming its share of the
bar's width either way; `_title` keeps its own separate stretch, so the two split the bar's spare width, and the
mounted row renders at its own natural width within the slot rather than reaching the slot's own trailing edge.
- `setTitle` and `setPrincipal` set the top bar's texts. The bar centres its children across it, so the principal's
line sits level with the sign-out button's label rather than on the bar's top edge.
- The sign-out button calls `ShellFrameOptions::onSignOut`; with none, it is hidden.
Expand Down
7 changes: 7 additions & 0 deletions docs/spec/ui/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ mounted after the screen, since it reads the handle, and moved above it. It read
tracked read of `tabs()`, so a row mounted while the model is still opening the tab reads the head once the model
holds it, and one whose handle has gone reads nothing.

`Workspace::headRowForActive()` builds the same row for whichever tab is active, as a public `Node` a host can mount
somewhere else entirely (a client's own shell chrome, outside the page the in-body row above already sits in) —
`morph::client::Shell` mounts it into `ShellFrame::headSlot()`, beside the title in the top bar. It reads
`WorkspaceModel::active()` directly, not the public `Workspace::active()` index, since the index is a position in
the tab bar, not an identity, and is unsafe to compare across a close. With no tab open it returns a defined,
invisible row — never a null `Node` — so a caller mounting it need not handle a null case itself.

## Captions

A tab's caption is its screen's `title`, followed by ` •` while `dirty` is true. Each tab of the model has an effect
Expand Down
31 changes: 31 additions & 0 deletions include/morph/ui/author.hpp
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// SPDX-License-Identifier: Apache-2.0

#pragma once
Expand Down Expand Up @@ -2463,6 +2463,8 @@
detail::put(json, "emphasis", std::string{"primary"});
} else if (action.emphasis == ButtonEmphasis::Danger) {
detail::put(json, "emphasis", std::string{"danger"});
} else if (action.emphasis == ButtonEmphasis::Ghost) {
detail::put(json, "emphasis", std::string{"ghost"});
}
putPresent(json, "enabled", action.enabled);
putPresent(json, "visible", action.visible);
Expand Down Expand Up @@ -3157,6 +3159,8 @@
writer.put("emphasis", std::string{"primary"});
} else if (options.emphasis == ButtonEmphasis::Danger) {
writer.put("emphasis", std::string{"danger"});
} else if (options.emphasis == ButtonEmphasis::Ghost) {
writer.put("emphasis", std::string{"ghost"});
}
if (options.style) {
writer.text("style", *options.style == ButtonStyle::Item ? "item" : "push");
Expand Down Expand Up @@ -3866,6 +3870,33 @@
return detail::NodeWriter{"dateTimeInput"}.put("value", value).put("label", label).put("onCommit", commit).done();
}

/// @brief Options of `dateTimeInput`.
struct DateTimeInputOptions {
/// @brief The value.
AnyHandle value;
/// @brief The label: the accessible name.
AnyHandle label;
/// @brief The input mode, such as `date` or `time`.
AnyHandle mode;
/// @brief Commands run on every edit.
Commands onChange;
/// @brief Commands run on a commit.
Commands onCommit;
};

/// @brief A `dateTimeInput` node.
/// @param options The input.
/// @return The node.
[[nodiscard]] inline Node dateTimeInput(DateTimeInputOptions const& options) {
return detail::NodeWriter{"dateTimeInput"}
.put("value", options.value)
.put("label", options.label)
.put("mode", options.mode)
.put("onChange", options.onChange)
.put("onCommit", options.onCommit)
.done();
}

/// @brief Options of `slider`.
struct SliderOptions {
/// @brief The value.
Expand Down
1 change: 1 addition & 0 deletions include/morph/ui/button_emphasis.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ enum class ButtonEmphasis : std::uint8_t {
Normal, ///< An ordinary action.
Primary, ///< The screen's main action.
Danger, ///< An action that destroys or cannot be undone.
Ghost, ///< An action embedded in other content, with no chrome of its own.
};

} // namespace morph::ui
33 changes: 31 additions & 2 deletions include/morph/ui/document.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2975,7 +2975,7 @@ class DocumentValidator {
// The keys of a kind that take one of a closed set of values, and the pairs of keys that need each other.
static void checkKindValues(DocNode const& node) {
if (node.kind == "button") {
static constexpr std::array<std::string_view, 3> kEmphasis = {"normal", "primary", "danger"};
static constexpr std::array<std::string_view, 4> kEmphasis = {"normal", "primary", "danger", "ghost"};
checkChoice(node, "emphasis", kEmphasis);
static constexpr std::array<std::string_view, 2> kStyles = {"push", "item"};
checkChoice(node, "style", kStyles);
Expand Down Expand Up @@ -3062,7 +3062,7 @@ class DocumentValidator {

// A list of `actionButton` parts: each id once, and each emphasis one of a button's.
static void checkActionButtons(std::span<DocNodePtr const> buttons, std::string const& path) {
static constexpr std::array<std::string_view, 3> kEmphasis = {"normal", "primary", "danger"};
static constexpr std::array<std::string_view, 4> kEmphasis = {"normal", "primary", "danger", "ghost"};
std::set<std::string_view> ids;
for (DocNodePtr const& button : buttons) {
if (!ids.insert(button->id).second) {
Expand Down Expand Up @@ -3138,12 +3138,41 @@ class DocumentValidator {
return bounds;
}

// Whether a literal sizing dimension's value is `{"stretch": w}` (as opposed to `"content"` or `{"fixed": n}`,
// which both `sizingDimension` reports the same way -- as "no fixed amount"). An overlay is sized by the
// frontend, not by its container, so stretching it would silently change how every *other* sibling in its own
// parent layout is justified; checked ahead of `sizingDimension` itself, which does not distinguish the two.
static bool isStretch(Value const& value) {
ValueRecord const* const record = value.asRecord();
return record != nullptr && record->size() == 1 && record->begin()->first == "stretch";
}

// An overlay (`dialog`/`drawer`) refuses a literal `sizing` that stretches either dimension, single-value or
// record form: an overlay is sized by the frontend, not by its container, so stretching it would silently change
// how every *other* sibling in its own parent layout is justified.
static void checkOverlaySizing(DocNode const& node, ExprPtr const& sizing) {
if (node.kind != "dialog" && node.kind != "drawer") {
return;
}
ValueRecord const* const record = sizing->literal.asRecord();
bool const stretches =
isStretch(sizing->literal) ||
(record != nullptr && (record->contains("width") || record->contains("height")) &&
std::ranges::any_of(*record, [](auto const& entry) { return isStretch(entry.second); }));
if (stretches) {
refuse(sizing->path,
"an overlay is sized by the frontend, not by its container; give it `{\"fixed\": n}` instead "
"of `{\"stretch\": w}`");
}
}

// A literal `sizing` is one of its forms, and a fixed amount lies inside its dimension's @p bounds.
static void checkSizing(DocNode const& node, std::array<std::optional<std::int64_t>, 4> const& bounds) {
ExprPtr const sizing = node.prop("sizing");
if (sizing == nullptr || sizing->kind != ExprKind::Literal) {
return;
}
checkOverlaySizing(node, sizing);
std::optional<std::int64_t> width;
std::optional<std::int64_t> height;
ValueRecord const* const record = sizing->literal.asRecord();
Expand Down
4 changes: 4 additions & 0 deletions include/morph/ui/interpreter.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5806,6 +5806,8 @@ inline ActionModels ScreenCore::lowerActionParts(std::span<DocNodePtr const> par
model.emphasis = ButtonEmphasis::Primary;
} else if (part->name("emphasis") == "danger") {
model.emphasis = ButtonEmphasis::Danger;
} else if (part->name("emphasis") == "ghost") {
model.emphasis = ButtonEmphasis::Ghost;
}
if (Commands const* const commands = part->event("onClick")) {
model.run = [this, ctx, commands] { runEvent(commands, ctx, Value{}); };
Expand Down Expand Up @@ -5922,6 +5924,8 @@ inline Node ScreenCore::lowerKind(DocNode const& node, LowerCtx const& ctx, Elem
spec.emphasis = ButtonEmphasis::Primary;
} else if (node.name("emphasis") == "danger") {
spec.emphasis = ButtonEmphasis::Danger;
} else if (node.name("emphasis") == "ghost") {
spec.emphasis = ButtonEmphasis::Ghost;
}
if (node.name("style") == "item") {
spec.style = ButtonStyle::Item;
Expand Down
22 changes: 22 additions & 0 deletions include/morph/ui/mount.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,26 @@ class Mounter {
check(hints.height, hints.minHeight, hints.maxHeight, "height");
}

// A dialog or a drawer is sized by the frontend, not by its container: Stretch sizing on one would silently
// change how every *other* sibling in its own parent layout is justified. Covers both the document path's own
// computed (non-literal) `sizing` -- resolved to a Sizing only here, at mount time, since the document-level
// checkSizing validates only a literal one -- and a C++ author calling ui::dialog()/ui::drawer() directly, both
// of which reach this same mountKind overload before either backend ever sees the result.
static void checkOverlayLayout(Common const& common, std::string_view kind) {
LayoutHints const& hints = common.layout;
auto const refuse = [&common, kind](std::string const& axis) {
throw std::logic_error{"morph::ui::mount: a " + std::string{kind} +
" is sized by the frontend, not by its container; its " + axis + " cannot stretch" +
(common.testId.empty() ? std::string{} : " (node `" + common.testId + "`)")};
};
if (hints.width.kind == Sizing::Kind::Stretch) {
refuse("width");
}
if (hints.height.kind == Sizing::Kind::Stretch) {
refuse("height");
}
}

// The loader's rule for `alignSelf`, for a view tree built in C++: only a stack's or a grid's child has a cross
// axis to be placed on.
static void checkPlacement(Common const& common, ContainerWidget const* parent) {
Expand Down Expand Up @@ -1022,12 +1042,14 @@ class Mounter {
// with `open` unchanged re-sends `setOpen`, and mounts content only if none is mounted: once per run while `open`
// stays true over content that failed.
Widget& mountKind(reactive::Scope& scope, Dialog const& spec, ContainerWidget* parent) {
checkOverlayLayout(spec.common, "dialog");
DialogWidget& widget = scope.adopt(_backend->createDialog(parent));
applyCommon(scope, widget, spec.common);
return mountOverlay(scope, spec, widget);
}

Widget& mountKind(reactive::Scope& scope, Drawer const& spec, ContainerWidget* parent) {
checkOverlayLayout(spec.common, "drawer");
DrawerWidget& widget = scope.adopt(_backend->createDrawer(parent, spec.side));
applyCommon(scope, widget, spec.common);
return mountOverlay(scope, spec, widget);
Expand Down
Loading
Loading