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
26 changes: 23 additions & 3 deletions examples/gallery/view.ml
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,17 @@ let button_group_section model_source send : t =
]
]

let glass_buttons_section : t =
let glass_buttons_section model_source send : t =
let action ?text label icon : Lui_element_combine.action =
{ label; icon; text; on_press = noop }
Lui_element_combine.Press
{ label; icon; text; on_press = press send (Model.PerformContextAction label) }
in
let menu_action ?text label icon menu : Lui_element_combine.action =
Lui_element_combine.Menu { label; icon; text; menu; on_dismiss = None }
in
let entry name =
menu_item ~text:name
~on_press:(press send (Model.PerformContextAction name)) []
in
section "Glass Buttons"
[ paragraph ~value:"A single icon-only glass button" []
Expand All @@ -165,6 +173,18 @@ let glass_buttons_section : t =
[ action ~text:"Info" "Information" `info
; action "Settings" `settings
]
; paragraph ~value:"A single icon-only menu action opens a native menu" []
; Lui_element_combine.buttons
~actions:
[ menu_action "More actions" `ellipsis
[ entry "Duplicate"; entry "Rename"; entry "Archive" ] ]
; paragraph ~value:"Press and menu actions share one capsule" []
; Lui_element_combine.buttons
~actions:
[ action "New note" `plus
; menu_action "More actions" `ellipsis
[ entry "Duplicate"; entry "Rename"; entry "Archive" ] ]
; paragraph ~value:(reactive Model.document_action model_source) []
]

let toggle_group_section model_source send : t =
Expand Down Expand Up @@ -1540,7 +1560,7 @@ let view context model_source send : t =
; spacer_section
; toggle_button_section model_source send
; button_group_section model_source send
; glass_buttons_section
; glass_buttons_section model_source send
; toggle_group_section model_source send
; breadcrumb_section send
; pagination_section model_source send
Expand Down
26 changes: 26 additions & 0 deletions platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3487,6 +3487,21 @@ private struct LUIMenuTriggerView: View {
return model.property(.accessibilityLabel)?.stringValue ?? ""
}

/// The `capsule` style class marks the trigger as a cell inside a glass
/// button capsule (`Lui_element_combine.buttons` menu actions): the label
/// carries the cell's frame and hit shape so presses anywhere on the
/// capsule open the menu, not just glyph presses. Icon-only cells fill
/// their pinned wrapper; text cells stay hug-sized with the capsule's
/// 12pt side padding inside the hit region.
private var capsuleCell: Bool {
model.property(.styleClass)?.stringValue?
.split(separator: " ").contains("capsule") == true
}

private var iconOnly: Bool {
!model.buttonIconName.isEmpty && model.text.isEmpty
}

var body: some View {
Menu {
if let menu {
Expand All @@ -3499,6 +3514,11 @@ private struct LUIMenuTriggerView: View {
source: backend.iconSource(for: model.buttonIconName),
bundle: backend.appIconBundle
)
.scaledToFit()
.frame(
width: LUIButtonVisualPolicy.iconExtent(buttonSize: model.buttonSize),
height: LUIButtonVisualPolicy.iconExtent(buttonSize: model.buttonSize)
)
.modifier(LUIMenuItemForegroundModifier(model: model))
}
if !model.text.isEmpty {
Expand All @@ -3509,6 +3529,12 @@ private struct LUIMenuTriggerView: View {
))
}
}
.padding(.horizontal, capsuleCell && !iconOnly ? 12 : 0)
.frame(
maxWidth: capsuleCell && iconOnly ? .infinity : nil,
maxHeight: capsuleCell ? .infinity : nil
)
.contentShape(Rectangle())
}
.disabled(!model.isEnabled)
.accessibilityLabel(spokenLabel)
Expand Down
89 changes: 70 additions & 19 deletions src/lui_element_combine.ml
Original file line number Diff line number Diff line change
Expand Up @@ -17,46 +17,97 @@ type t = Lui_elements.t
(* ------------------------------------------------------------------ *)

type action =
{ label : string
; icon : icon
; text : string option
; on_press : Lui_protocol.event -> unit
}
| Press of
{ label : string
; icon : icon
; text : string option
; on_press : Lui_protocol.event -> unit
}
| Menu of
{ label : string
; icon : icon
; text : string option
; menu : t list
; on_dismiss : (Lui_protocol.event -> unit) option
}

let action_text = function
| Press { text; _ } | Menu { text; _ } ->
(match text with
| Some text when text <> "" -> Some text
| _ -> None)
;;

let glass_action_button ?background ?corner_radius action =
let visible_text =
match action.text with
| Some text when text <> "" -> Some text
| _ -> None
in
let icon_only = Option.is_none visible_text in
let glass_press_button ?background ?corner_radius ~label ~icon ~text ~on_press =
let icon_only = Option.is_none text in
button
~variant:`ghost
~size:(if icon_only then `icon else `default)
~icon:action.icon
?text:visible_text
~label:action.label
~icon
?text
~label
~foreground:"foreground"
?background
?corner_radius
?padding_horizontal:(if icon_only then None else Some 12)
?width:(if icon_only then Some 44 else None)
~height:44
~on_press:action.on_press
~on_press
[]
;;

(* A menu action mounts a native [menu_trigger] (press-to-open [Menu]) inside
a sizing cell; the capsule chrome rides on the enclosing surface since
menu triggers carry no surface properties. The ["capsule"] style class
asks the host to give the trigger label the cell's full frame and hit
shape. *)
let menu_action_cell ?background ?corner_radius ~label ~icon ~text ~menu:entries ~on_dismiss =
box
~height:44
?background
?corner_radius
?width:(if Option.is_none text then Some 44 else None)
[ menu
~label
~icon
?text
~foreground:"foreground"
~style_class:"capsule"
?on_dismiss
entries
]
;;

let glass_action ?background ?corner_radius action =
let text = action_text action in
match action with
| Press { label; icon; text = _; on_press } ->
glass_press_button ?background ?corner_radius ~label ~icon ~text ~on_press
| Menu { label; icon; text = _; menu; on_dismiss } ->
menu_action_cell ?background ?corner_radius ~label ~icon ~text ~menu ~on_dismiss
;;

let buttons ~actions =
match actions with
| [ action ] ->
glass_action_button ~background:"glass" ~corner_radius:999 action
| [ Press _ as action ] ->
glass_action ~background:"glass" ~corner_radius:999 action
| [ Menu { label; icon; text; menu; on_dismiss } ] ->
(* Menu triggers size through a box cell, and box is not a legal toolbar
child — a single-member button group carries the capsule so the menu
remains mountable inside toolbars like the press capsules. *)
button_group
~gap:0
~height:44
~background:"glass"
~corner_radius:999
[ menu_action_cell ~label ~icon ~text ~menu ~on_dismiss ]
| _ :: _ :: _ ->
button_group
~gap:0
~height:44
~background:"glass"
~corner_radius:999
(List.map glass_action_button actions)
(List.map glass_action actions)
| [] -> invalid_arg "buttons requires at least one action"
;;

Expand Down
28 changes: 21 additions & 7 deletions src/lui_element_combine.mli
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,29 @@ open Lui_elements
(** {1 Buttons} *)

type action =
{ label : string
; icon : icon
; text : string option
; on_press : Lui_protocol.event -> unit
}
| Press of
{ label : string
; icon : icon
; text : string option
; on_press : Lui_protocol.event -> unit
}
| Menu of
{ label : string
; icon : icon
; text : string option
; menu : t list
; on_dismiss : (Lui_protocol.event -> unit) option
}

(** Each action may have visible [text]; [label] is its accessibility name.
One action renders a capsule button. Multiple actions share one
capsule. The list must not be empty. *)
[Press] renders a pressable button; [Menu] renders a press-to-open
native dropdown menu ([menu] holds its {!menu_item}/
{!check_menu_item}/{!submenu} entries) — no model round-trip is needed
to open it. One action renders a capsule control; multiple actions
share one capsule. A lone [Menu] mounts its trigger inside a
single-member button group: the trigger's sizing cell is a container
node, which would not be a legal [toolbar] child. The list must not be
empty. *)
val buttons : actions:action list -> t

(** {1 Composer} *)
Expand Down
Loading
Loading