diff --git a/app/journal_header.ml b/app/journal_header.ml index 222d653..5167c80 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -69,93 +69,113 @@ let view ~capture_expanded ~body = - let selected = - match context with - | Context.Favorites -> true - | Journals -> false - in let heading value = V.text ~style:(Ui.Style.Text_style.create ~font_weight:Semi_bold ()) value in - let navigation label symbol selected on_press = - V.button - ~on_press - ~child:(V.label ~title:(V.text label) ~icon:(Journal_symbols.create symbol) ()) - () - |> V.semantics ~properties:(Ui.Semantics.create ~label ~selected ()) - in - let account = - match on_account_action with - | None -> V.empty () - | Some dispatch -> - let actions = - [ ( 5L - , "Attachment settings" - , "slider.horizontal.3" - , "open-asset-settings" - , V.Button_role.Normal ) - ; 1L, "Diagnostics", "stethoscope", "open-diagnostics", V.Button_role.Normal - ; ( 2L - , "Switch graph" - , "arrow.triangle.2.circlepath" - , "switch-graph" - , V.Button_role.Normal ) - ] - @ (if local_deletion_available - then - [ ( 3L - , "Delete local graph copy" - , "trash" - , "request-local-cache-reset" - , V.Button_role.Destructive ) + let account_action = + Option.map + (fun dispatch -> + let actions = + [ ( 5L + , "Attachment settings" + , "slider.horizontal.3" + , "open-asset-settings" + , V.Button_role.Normal ) + ; 1L, "Diagnostics", "stethoscope", "open-diagnostics", V.Button_role.Normal + ; ( 2L + , "Switch graph" + , "arrow.triangle.2.circlepath" + , "switch-graph" + , V.Button_role.Normal ) + ] + @ (if local_deletion_available + then + [ ( 3L + , "Delete local graph copy" + , "trash" + , "request-local-cache-reset" + , V.Button_role.Destructive ) + ] + else []) + @ [ ( 4L + , "Sign out" + , "rectangle.portrait.and.arrow.right" + , "sign-out" + , V.Button_role.Normal ) ] - else []) - @ [ ( 4L - , "Sign out" - , "rectangle.portrait.and.arrow.right" - , "sign-out" - , V.Button_role.Normal ) - ] + in + V.buttons_menu_action + ~label:"Account menu" + ~icon:(Journal_symbols.name Journal_symbols.Account) + ~on_select: + (Ui.Event.Handler.create (function + | Ui.Event.Payload.Int64 id -> + List.find_opt (fun (candidate, _, _, _, _) -> candidate = id) actions + |> Option.iter (fun (_, _, _, action, _) -> + Ui.Event.Handler.Private.invoke + dispatch + (Ui.Event.Payload.Text action)) + | _ -> ())) + (List.map + (fun (id, title, symbol, _, role) -> + V.Menu.action ~id ~role ~title ~icon:symbol ()) + actions)) + on_account_action + in + let error_action = + Option.map + (fun on_press -> + V.buttons_action + ~label:"Error info" + ~icon:(Journal_symbols.name Journal_symbols.Error) + ~on_press + ()) + on_error_info + in + (* The error and account chrome controls fuse into one capsule: error leads + so the account menu stays the trailing glyph. *) + let cluster = + match Option.to_list error_action @ Option.to_list account_action with + | [] -> None + | actions -> + let view = V.buttons ~actions () in + let view = + if Option.is_some error_action + then + view + |> V.help ~message:"Inspect application errors" + |> test_id "journal-error-info-button" + else view + in + let view = + if Option.is_some account_action + then + view + |> V.semantics + ~properties: + (Ui.Semantics.create + ~label:"Account menu" + ~hint:"Switch graphs, delete the local copy, or sign out" + ~role:Button + ()) + |> test_id "journal-account-menu-button" + else view in - V.Menu.create - ~on_select: - (Ui.Event.Handler.create (function - | Ui.Event.Payload.Int64 id -> - List.find_opt (fun (candidate, _, _, _, _) -> candidate = id) actions - |> Option.iter (fun (_, _, _, action, _) -> - Ui.Event.Handler.Private.invoke dispatch (Ui.Event.Payload.Text action)) - | _ -> ())) - ~title:"" - ~icon:(Journal_symbols.name Journal_symbols.Account) - ~label:"Account menu" - (List.map - (fun (id, title, symbol, _, role) -> - V.Menu.action ~id ~role ~title ~icon:symbol ()) - actions) - |> V.semantics - ~properties: - (Ui.Semantics.create - ~label:"Account menu" - ~hint:"Switch graphs, delete the local copy, or sign out" - ~role:Button - ()) - |> test_id "journal-account-menu-button" + Some view in - let error = - match on_error_info with - | None -> V.empty () - | Some on_press -> - V.buttons - ~actions: - [ V.buttons_action - ~label:"Error info" - ~icon:(Journal_symbols.name Journal_symbols.Error) - ~on_press - () - ] - () - |> V.help ~message:"Inspect application errors" - |> test_id "journal-error-info-button" + (* The chrome's error slot is only used when there is no account action to + fuse with; otherwise the error button rides inside the account capsule. *) + let account_slot_used = Option.is_some on_account_action in + let error_slot_used = + Option.is_none on_account_action && Option.is_some on_error_info + in + let slot_column ~used = + if platform <> "ios" + then V.column [] + else ( + match cluster with + | Some view when used -> V.column [ view ] + | _ -> V.column []) in let capture = (* buttons has no disabled state — guard the handler instead. *) @@ -224,9 +244,9 @@ let view controls "account" V.Toolbar.Primary_action - (Option.to_list (Option.map (fun _ -> "error", error) on_error_info) - @ Option.to_list (Option.map (fun _ -> "account", account) on_account_action) - )) + (match cluster with + | Some view -> [ "account", view ] + | None -> [])) @ match capture_expanded with | Some _ when platform = "ios" -> @@ -234,14 +254,27 @@ let view application.ml) — every toolbar item yields while it is up. *) [] | _ -> - controls - "destinations" - navigation_placement - [ ( "journals" - , navigation "Journals" Journal_symbols.Journals (not selected) on_journals ) - ; ( "favorites" - , navigation "Favorites" Journal_symbols.Favorites selected on_favorites ) - ] + (* The destination pair fuses into one capsule — [buttons] has no + per-action selected state, so the active destination is conveyed by + the page content and its principal title instead. *) + [ V.Toolbar.item + ~key:(Ui.Key.string "destinations") + ~placement:navigation_placement + (V.buttons + ~actions: + [ V.buttons_action + ~label:"Journals" + ~icon:(Journal_symbols.name Journal_symbols.Journals) + ~on_press:on_journals + () + ; V.buttons_action + ~label:"Favorites" + ~icon:(Journal_symbols.name Journal_symbols.Favorites) + ~on_press:on_favorites + () + ] + ()) + ] @ (if platform = "ios" then [ V.Toolbar.spacer @@ -284,16 +317,16 @@ let view (`Assoc [ "mode", `String "journal" ; "connecting", `Bool (sync_phase = Some Graph_service.Connecting) - ; "account", `Bool (Option.is_some on_account_action) - ; "error", `Bool (Option.is_some on_error_info) + ; "account", `Bool account_slot_used + ; "error", `Bool error_slot_used ]) ~on_event:(fun _ -> ()) (* Chrome slots are positional on the native side — absent slots must still mount a (zero-size) node or the host's index lookup shifts. *) ~children: [ V.Body.Private.to_widget body - ; (if platform = "ios" then V.column [ account ] else V.column []) - ; (if platform = "ios" then V.column [ error ] else V.column []) + ; slot_column ~used:account_slot_used + ; slot_column ~used:error_slot_used ; V.column [ (if sync_phase = Some Graph_service.Connecting then diff --git a/app/journal_view.ml b/app/journal_view.ml index aa34ce6..fb0f432 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -962,18 +962,27 @@ module View = struct ;; type buttons_action = - { buttons_action_label : string - ; buttons_action_icon : string - ; buttons_action_text : string option - ; buttons_action_on_press : Event.handler - } + | Press of + { buttons_action_label : string + ; buttons_action_icon : string + ; buttons_action_text : string option + ; buttons_action_on_press : Event.handler + } + | Menu of + { buttons_action_label : string + ; buttons_action_icon : string + ; buttons_action_text : string option + ; buttons_action_menu : Lui_elements.t list + ; buttons_action_on_dismiss : Event.handler option + } let buttons_action ~label ~icon ?text ~on_press () = - { buttons_action_label = label - ; buttons_action_icon = icon - ; buttons_action_text = text - ; buttons_action_on_press = on_press - } + Press + { buttons_action_label = label + ; buttons_action_icon = icon + ; buttons_action_text = text + ; buttons_action_on_press = on_press + } ;; (* The shared [Lui_element_combine.buttons] capsule: a single action gets @@ -987,12 +996,34 @@ module View = struct ~actions: (List.map (fun action -> - { Lui_element_combine.label = action.buttons_action_label - ; icon = journal_icon action.buttons_action_icon - ; text = action.buttons_action_text - ; on_press = - (fun _ -> invoke action.buttons_action_on_press Event.Payload.Unit) - }) + match action with + | Press + { buttons_action_label = label + ; buttons_action_icon = icon + ; buttons_action_text = text + ; buttons_action_on_press = on_press + } -> + Lui_element_combine.Press + { label + ; icon = journal_icon icon + ; text + ; on_press = (fun _ -> invoke on_press Event.Payload.Unit) + } + | Menu + { buttons_action_label = label + ; buttons_action_icon = icon + ; buttons_action_text = text + ; buttons_action_menu = menu + ; buttons_action_on_dismiss = on_dismiss + } -> + Lui_element_combine.Menu + { label + ; icon = journal_icon icon + ; text + ; menu + ; on_dismiss = + Option.map (fun h _ -> invoke h Event.Payload.Unit) on_dismiss + }) actions)) ;; @@ -2353,54 +2384,61 @@ module View = struct [] ;; + let rec entry_elements ~on_select (entry : entry) : Lui_elements.t list = + match entry with + | Divider id -> + [ Lui_elements.separator ~key:(Int64.to_string id) ~orientation:`horizontal [] ] + | Action { id; label; enabled; role } -> + [ menu_item + ~key:(Int64.to_string id) + ~title:label.title + ~icon:label.icon + ~enabled + ~role + ~selected:None + ~on_press:(fun () -> invoke on_select (Event.Payload.Int64 id)) + () + ] + | Choice { id; label; selected; enabled } -> + [ Lui_element_combine.check_menu_item + ~key:(Int64.to_string id) + ~label:(if String.length label.title = 0 then " " else label.title) + ?icon:(Option.map journal_icon label.icon) + ~checked:selected + ~disabled:(not enabled) + ~on_press:(fun _ -> invoke on_select (Event.Payload.Int64 id)) + () + ] + | Section { label; entries; _ } -> + (match label with + | Some label -> + [ menu_item + ~title:label.title + ~icon:label.icon + ~enabled:false + ~role:Button_role.Normal + ~selected:None + () + ] + | None -> []) + @ List.concat_map (entry_elements ~on_select) entries + | Submenu { id; label; enabled; entries } -> + [ Lui_elements.submenu + ~key:(Int64.to_string id) + ~text:(if String.length label.title = 0 then " " else label.title) + ?icon:(Option.map journal_icon label.icon) + ~disabled:(not enabled) + (List.concat_map (entry_elements ~on_select) entries) + ] + ;; + + (* Menu entries rendered as [dropdown_menu] children — for [buttons] + menu actions, where the capsule composite mounts the trigger. *) + let to_elements ~on_select entries = + List.concat_map (entry_elements ~on_select) entries + ;; + let create ?key ?(enabled = true) ~on_select ~title ?icon ?label entries = - let rec entry_elements (entry : entry) : Lui_elements.t list = - match entry with - | Divider id -> - [ Lui_elements.separator ~key:(Int64.to_string id) ~orientation:`horizontal [] ] - | Action { id; label; enabled; role } -> - [ menu_item - ~key:(Int64.to_string id) - ~title:label.title - ~icon:label.icon - ~enabled - ~role - ~selected:None - ~on_press:(fun () -> invoke on_select (Event.Payload.Int64 id)) - () - ] - | Choice { id; label; selected; enabled } -> - [ Lui_element_combine.check_menu_item - ~key:(Int64.to_string id) - ~label:(if String.length label.title = 0 then " " else label.title) - ?icon:(Option.map journal_icon label.icon) - ~checked:selected - ~disabled:(not enabled) - ~on_press:(fun _ -> invoke on_select (Event.Payload.Int64 id)) - () - ] - | Section { label; entries; _ } -> - (match label with - | Some label -> - [ menu_item - ~title:label.title - ~icon:label.icon - ~enabled:false - ~role:Button_role.Normal - ~selected:None - () - ] - | None -> []) - @ List.concat_map entry_elements entries - | Submenu { id; label; enabled; entries } -> - [ Lui_elements.submenu - ~key:(Int64.to_string id) - ~text:(if String.length label.title = 0 then " " else label.title) - ?icon:(Option.map journal_icon label.icon) - ~disabled:(not enabled) - (List.concat_map entry_elements entries) - ] - in element ?key (Lui_elements.menu @@ -2408,9 +2446,22 @@ module View = struct ?icon:(Option.map journal_icon icon) ?label ~disabled:(not enabled) - (List.concat_map entry_elements entries)) + (to_elements ~on_select entries)) ;; end + + (* A [buttons] action that opens a native dropdown menu — the [Menu.entry] + vocabulary (actions, choices, sections, submenus) carries over, with the + same [Int64] id dispatch as [Menu.create]. *) + let buttons_menu_action ~label ~icon ?text ~on_select ?on_dismiss entries = + Menu + { buttons_action_label = label + ; buttons_action_icon = icon + ; buttons_action_text = text + ; buttons_action_menu = Menu.to_elements ~on_select entries + ; buttons_action_on_dismiss = on_dismiss + } + ;; end module Native_widget = struct diff --git a/app/journal_view.mli b/app/journal_view.mli index eb707e1..0f165bf 100644 --- a/app/journal_view.mli +++ b/app/journal_view.mli @@ -457,13 +457,23 @@ module View : sig -> t (** One capsule action for {!buttons}: [label] is the accessibility name, - [icon] an SF Symbol name, [text] optional visible text. *) + [icon] an SF Symbol name, [text] optional visible text. A [Press] + action fires its handler; a [Menu] action opens a native dropdown menu + of pre-built entries. *) type buttons_action = - { buttons_action_label : string - ; buttons_action_icon : string - ; buttons_action_text : string option - ; buttons_action_on_press : Event.handler - } + | Press of + { buttons_action_label : string + ; buttons_action_icon : string + ; buttons_action_text : string option + ; buttons_action_on_press : Event.handler + } + | Menu of + { buttons_action_label : string + ; buttons_action_icon : string + ; buttons_action_text : string option + ; buttons_action_menu : Lui_elements.t list + ; buttons_action_on_dismiss : Event.handler option + } val buttons_action : label:string @@ -919,6 +929,19 @@ module View : sig -> entry list -> t end + + (** A menu capsule action for {!buttons} built from {!Menu.entry} values: + pressing it opens the native dropdown and selecting an entry invokes + [on_select] with the entry's [Int64] id, same dispatch as + {!Menu.create}. *) + val buttons_menu_action + : label:string + -> icon:string + -> ?text:string + -> on_select:Event.handler + -> ?on_dismiss:Event.handler + -> Menu.entry list + -> buttons_action end module Native_widget : sig diff --git a/logseq_journal.opam b/logseq_journal.opam index 2818bfd..86b0f87 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/logseq/rrbvec.git#main"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#3ea3e5273b31bcce5d575b26ec03c47b728c58f0"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#df9fddb8ad616131d710f69d152abead837e2fd6"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#976b40f1770a65b3464df1ef38d1550f1d8a43dd"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] diff --git a/logseq_journal.opam.locked b/logseq_journal.opam.locked index 622327e..83fee00 100644 --- a/logseq_journal.opam.locked +++ b/logseq_journal.opam.locked @@ -23,7 +23,7 @@ pin-depends: [ ] [ "lui.0.1.0" - "git+https://github.com/logseq/lui.git#3ea3e5273b31bcce5d575b26ec03c47b728c58f0" + "git+https://github.com/logseq/lui.git#df9fddb8ad616131d710f69d152abead837e2fd6" ] [ "melange-edn-core.0.5.0" diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index 72f99f5..0ceffc5 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -31,6 +31,7 @@ import SwiftUI GeometryReader { bounds in content.frame(width: bounds.size.width, height: bounds.size.height) .scrollContentBackground(.hidden) + .toolbarBackground(.hidden, for: .bottomBar) #if os(iOS) .environment(\.journalControlsSize, controlsSize) .toolbar(.hidden, for: .navigationBar) @@ -54,6 +55,9 @@ import SwiftUI } #endif } + #if os(iOS) + .ignoresSafeArea(.container, edges: .bottom) + #endif } } diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index fe23af7..2c382d1 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1170,7 +1170,7 @@ let () = require_occurrences root "logseq_journal.opam" - "git+https://github.com/logseq/lui.git#3ea3e5273b31bcce5d575b26ec03c47b728c58f0" + "git+https://github.com/logseq/lui.git#df9fddb8ad616131d710f69d152abead837e2fd6" 1; require_occurrences root @@ -1324,9 +1324,9 @@ let () = root "app/journal_header.ml" [ "Journal_symbols.Account" - ; "V.button" + ; "V.buttons" ; "V.semantics" - ; "V.Menu.create" + ; "V.buttons_menu_action" ; "journal-root-navigation" ; "switch-graph" ; "sign-out"