diff --git a/examples/gallery/view.ml b/examples/gallery/view.ml index 3b8b842d..a8df568f 100644 --- a/examples/gallery/view.ml +++ b/examples/gallery/view.ml @@ -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" [] @@ -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 = @@ -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 diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index d7a4e902..2693c35b 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -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 { @@ -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 { @@ -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) diff --git a/src/lui_element_combine.ml b/src/lui_element_combine.ml index e2a2c633..08c7c68d 100644 --- a/src/lui_element_combine.ml +++ b/src/lui_element_combine.ml @@ -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" ;; diff --git a/src/lui_element_combine.mli b/src/lui_element_combine.mli index 9dff5a43..54666d55 100644 --- a/src/lui_element_combine.mli +++ b/src/lui_element_combine.mli @@ -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} *) diff --git a/test/test_lui.ml b/test/test_lui.ml index 230f64d1..1cb50bb2 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -505,7 +505,8 @@ let test_glass_button_actions () = let single_node = ref 0 in let group_node = ref 0 in let action label icon presses : Lui_element_combine.action = - { label; icon; text = None; on_press = (fun _ -> incr presses) } + Lui_element_combine.Press + { label; icon; text = None; on_press = (fun _ -> incr presses) } in let app = Lui_app.create (recording_backend ()) () @@ -558,7 +559,12 @@ let test_glass_button_text_is_optional () = let text_node = ref 0 in let group_node = ref 0 in let action : Lui_element_combine.action = - { label = "New note"; icon = `plus; text = None; on_press = (fun _ -> ()) } + Lui_element_combine.Press + { label = "New note"; icon = `plus; text = None; on_press = (fun _ -> ()) } + in + let with_press ~label ~icon ~text : Lui_element_combine.action = + Lui_element_combine.Press + { label; icon; text; on_press = (fun _ -> ()) } in let app = Lui_app.create (recording_backend ()) () @@ -569,13 +575,15 @@ let test_glass_button_text_is_optional () = (Lui_element_combine.buttons ~actions:[ action ]); capture_node text_node (Lui_element_combine.buttons - ~actions:[ { action with text = Some "New note" } ]); + ~actions: + [ with_press ~label:"New note" ~icon:`plus + ~text:(Some "New note") ]); capture_node group_node (Lui_element_combine.buttons ~actions: - [ { action with label = "Information"; icon = `info - ; text = Some "Info" } - ; { action with label = "Settings"; icon = `settings } + [ with_press ~label:"Information" ~icon:`info + ~text:(Some "Info") + ; with_press ~label:"Settings" ~icon:`settings ~text:None ]); ]) in @@ -611,6 +619,103 @@ let test_glass_button_text_is_optional () = (has_property (List.nth group_children 1) Lui_protocol.TextValue "Settings"); ignore (Lui_app.dispose app) +let test_glass_button_menu_action () = + let picked = ref 0 in + let single_node = ref 0 in + let group_node = ref 0 in + let menu_entries () = + [ Lui_elements.menu_item ~text:"Duplicate" + ~on_press:(fun _ -> incr picked) [] ] + in + let menu_action : Lui_element_combine.action = + Lui_element_combine.Menu + { label = "More actions"; icon = `ellipsis; text = None + ; menu = menu_entries (); on_dismiss = None } + in + let app = + Lui_app.create (recording_backend ()) () + (fun model _action -> model) + (fun _context _model_source _send -> + Lui_elements.column + [ capture_node single_node + (Lui_element_combine.buttons ~actions:[ menu_action ]); + capture_node group_node + (Lui_element_combine.buttons + ~actions: + [ Lui_element_combine.Press + { label = "New note"; icon = `plus; text = None + ; on_press = (fun _ -> ()) } + ; menu_action + ]); + ]) + in + ignore (Lui_app.start app); + flush_app app; + let ops = all_ops () in + let children_of parent = + ops + |> List.filter_map (function + | Lui_protocol.InsertChild (p, child, index) + when p = parent -> Some (index, child) + | _ -> None) + |> List.sort (fun (left, _) (right, _) -> Int.compare left right) + |> List.map snd + in + let kind_of node = + List.find_map + (function + | Lui_protocol.CreateNode (id, kind) when id = node -> Some kind + | _ -> None) + ops + in + let has_property node property value = + List.exists + (function + | Lui_protocol.SetProp (id, key, Lui_protocol.StringValue text) -> + id = node && key = property && text = value + | _ -> false) + ops + in + (* A lone menu action still shares the glass capsule shape: a one-member + button group wraps the sizing cell (box is not a legal toolbar child). *) + Alcotest.(check bool) "single menu action capsule is a button group" true + (kind_of !single_node = Some Lui_protocol.ButtonGroup); + Alcotest.(check bool) "single menu action capsule is glass" true + (has_property !single_node Lui_protocol.BackgroundValue "glass"); + let cells = children_of !single_node in + Alcotest.(check int) "single capsule wraps one cell" 1 + (List.length cells); + let single_cell = children_of (List.hd cells) in + Alcotest.(check int) "single cell wraps one trigger" 1 + (List.length single_cell); + let trigger = List.hd single_cell in + Alcotest.(check bool) "menu action mounts a menu-trigger" true + (kind_of trigger = Some Lui_protocol.MenuTrigger); + let menu_children = children_of trigger in + Alcotest.(check int) "trigger hosts one dropdown-menu" 1 + (List.length menu_children); + Alcotest.(check bool) "trigger child is a dropdown-menu" true + (kind_of (List.hd menu_children) = Some Lui_protocol.DropdownMenu); + let items = children_of (List.hd menu_children) in + Alcotest.(check int) "menu has its items" 1 (List.length items); + Alcotest.(check bool) "menu item is a menu-item node" true + (kind_of (List.hd items) = Some Lui_protocol.MenuItem); + (* Mixed press + menu actions share one group capsule *) + let group_children = children_of !group_node in + Alcotest.(check int) "group has two cells" 2 (List.length group_children); + Alcotest.(check bool) "first cell is the press button" true + (kind_of (List.hd group_children) = Some Lui_protocol.Button); + Alcotest.(check bool) "second cell wraps the trigger" true + (kind_of (List.nth group_children 1) = Some Lui_protocol.Box); + (* Menu items dispatch their own presses *) + List.iter + (fun node -> + ignore (Lui_app.dispatch_event app (Lui_protocol.Press node))) + items; + flush_app app; + Alcotest.(check int) "menu item press fires its handler" 1 !picked; + ignore (Lui_app.dispose app) + let test_dispatch_drops_value_echoes () = let inputs = ref 0 in let toggles = ref 0 in @@ -1417,6 +1522,8 @@ let () = test_glass_buttons_require_an_action; Alcotest.test_case "optional visible text" `Quick test_glass_button_text_is_optional; + Alcotest.test_case "menu action" `Quick + test_glass_button_menu_action; ] ); ( "json_view", [