slint-ui / slint

Slint is a declarative GUI toolkit to build native user interfaces for Rust, C++, or JavaScript apps.
https://slint.dev
Other
17.52k stars 598 forks source link

Add support for popup menus and menubar #38

Open tronical opened 4 years ago

tronical commented 4 years ago

This ticket tracks the ability to declare popup menus in .60 markup as well as the ability to integrate them into a menu bar.

This needs further refinement/triaging regarding a more actionable acceptance criteria.

ogoffart commented 3 years ago

We probably need a few things here. Which we still need to design API for.

1. Some kind of built-in Action type.

maybe a struct { text: string, icon: image, tool-tip: string, enabled: bool, checkable : bool, sub-menu: [Action], shortcut: string }
maybe we even need an icon type instead of re-using image?

For convenience, we might want to have implicit conversion from string to Menu

It is also quite similar to the StandardListViewItem we already have.

What about sub_menus? currently we can't have a recursive struct. but maybe we can make an exception for the action type.

2. How to represent a Menu Bar

Maybe something like this

MainWindow := Window {
    MenuBar {
        model: [
           { 
              text: "File",
              sub-menu: [ { text: "Open", icon: @image-url(...) }, { text: "Close", ... }, ... ]
           },
           { 
              text: "Edit",
              sub-menu: [ { text: "Copy", icon: @image-url(...) }, { text: "Paste", ... }, ... ]
           },
        ];
        trigerred(item) => {
           if (item == "File")  { // re-using the text is a very bad idea because of translation, we need something better than data
               ... 
           } else ...
        }
    }
   ...
}

An alternative would be NOT using an Action type, but using something more looking like our syntax like this:

MainWindow := Window {
    property <[Action]> recent_model;
    MenuBar {
        Menu {
            name: "File";
            MenuItem { name: "Open"; icon: "..."; triggered => { ... } }
            MenuItem { name: "Close"; icon: "..."; triggered => { ... }  }
            Menu {
                name: "Open Recent";
                model: recent_model; // provided by the logic
            }
        }
    }
    ...

}

Another way would be to have a collection of action with another special syntax

global MyActions := ActionCollections {
   file-open := Action { 
       text: "Open"; 
       icon: @image-url(...);  
       triggered => {...} 
       shortcut: "Ctrl+O";
    }
   file-close := Action { 
        text: "Close"; 
        icon: @image-url(...); 
        triggered => {...} 
   }
}

MainWindow := Window {
    MenuBar {
        Menu { 
           text: "File";
           model: [MyActions.file-open, MyActions.file-close, ...];
        }
       Menu { ... }
   }

The problem with that last approach is that ActionCollections it is kind of a very special case which somehow re-use the same syntax, but with a completely different semantic. Although we also abuse that for the Path sub-elements and the Row within GridLayout. What i mean here is that Elements are no longer really graphical items. But also that one can access the sub elements of an ActionCollection by their id from a different component.

3. ContextMenu

Similar to Menu bar, we want to be able to set context menu to element.

Do we want each element to get a context-menu property:

Foo := Rectangle {
   ...
   Rectangle {
     context-menu: [ MyActions.file-open, {text: "Another Action" } ];
     ...
   }
}

Or should we get another element

Foo := Rectangle {
   ...
   Rectangle {
      ContextMenu {
          // similar to the MenuBar above
          model: [ ... ];
      }
   }
}
tronical commented 1 year ago

Implementation wise, I see that tauri has created a separate crate for menubar / popup menus that works on macOS, Windows, and Linux - the latter using gtk: https://github.com/tauri-apps/muda

ogoffart commented 1 month ago

Sumary of our discussion in the office:

The ContextMenu pseudo-element contains MenuItem { ... } MenuSeparator { ... } and `MenuEntries { ... }

There exist a struct

struct MenuEntry {
    title: text;
    icon: image; // or icon?
    id: string; // opaque 
    keyboard-shortcut: key-sequence;
    enabled: bool;
}

The ContextMenu can be used like so:

ContextMenu {
    MenuItem {
        text: "Blah";
        // icon, toolip, etc.
        triggered => {}
    }
    MenuSeparator {}
    SubMenu { // is a MenuItem
        text: "Sub Menu Item Text";

        MenuItem { ... }
        ...
    }
    MenuEntries {
        // this is a [MenuEntry] model
        entries:  Globals.recent-files;
        triggered(entry, index) => {
            root.open-recent-file(index);
        }
    }
    MenuItem { ... }

    // or just a [MenuEntry]
    // this is mutually exclusive with MenuItem, etc. inline
    actions: ...;
}

When placed somewhere in the tree, it will automatically be shown on right click or with the menu key. We were also considering a ContexMenuArea

export component AppWindow {
    ...

    // intercepts context menu key press and mouse right click
    // this is an element that has a geometry
    // can have any children, acts like a Empty,
    // must have exactly one ContextMenu
    ContexMenuArea {

        ContextMenu {
            // no geometry properties
        }
    }
}

Not sure if ContexMenuArea is needed or not.

For menu bar:

Introduce a MenuBar widget that can only be used in Window. Its presence changes the layout of the content area. (So that y:0 means just after the MenuBar) On platforms that have native menu bar, we would use the muda crate to do so. On other platforms we would render the menu as a slint widget and using PopupWindow for the menus.

export component AppWindow inherits Window {
    // there can only be one, must be child of `Window`  import from std-widgets.slint
    MenuBar {
        // has no geometry properties, etc.
        MenuItem {
             title: @tr("Menu" => "File")

             MenuItem {
                title: "delete currently selected";
                enabled: SomeGlobal.is-design-mode;
                keyboard-shortcut: @keys(ctrl+d) // lowers to string with our fancy encoded control codes
             }
             MenuEntries {}
             MenuSeparator{}
             MenuItem {
                 blah := MenuItem {
                     // all the same property as MenuEntry + triggered
                 }
             }
             //Action { action: SomeGloba.file-open; }
        }
        MenuItem { ... }
    }

    // Fill the rest: y is after the menu bar.
    Rectangle { y:0; height: 100%; }
}

TODO

Enyium commented 1 month ago
  • PopupWindow placement strategies (anchoring, etc. - see wayland strategy)

Note that Windows provides system settings like which side a context menu should open towards.

ubruhin commented 1 month ago

I'm very interested in menu bars & context menus. So I'd like to bring in my thoughts on this.

keyboard-shortcut: key-sequence;

It would be great to allow having multiple keyboard shortcuts for the same action. QAction allows this by assigning a list of QKeySequence's. It is very handy for example to support both "Ctrl+Y" and "Ctrl+Shift+Z" for the redo action as you never know which application supports which of those shortcuts, so just support both of them. In addition, some day the shortcuts can be extended to support sequence-based shortcuts in addition to combination-shortcuts (e.g. press "e" for edit, then "r" for redo). Then an application can support both shortcut styles at the same time (like Qt does).

The ContextMenu can be used like so: [...]

For simple (static) UIs it's great to declare all the menu items (for both menu bar & context menus) right in the .slint file as you suggest. But for more dynamic UIs (e.g. configurable keyboard shortcuts, menu items enabled/disabled or even visible/invisible depending on app state etc) it's crucial to allow defining (or modifying) menu items from native code. Maybe through a model would be easiest, though other ways might be fine as well. From reading the code examples above I'm not 100% sure if this case is considered.

Introduce a MenuBar widget that can only be used in Window.

Would there be a technical reason for this restriction? If the menu bar would also work in nested UI elements (e.g. tabs) I'd say the user should not be prevented from doing this. Also in PopupWindow it might be nice to have a menu bar. Of course the system integration would only work with the main window's menu bar.

Its presence changes the layout of the content area. (So that y:0 means just after the MenuBar)

Would this prevent the user from placing any other UI elements above this pseudo y:0? Usually menu bars contain a lot of unused space, where it would be nice to allow using it for other things. For example a search box:

image

I'm aware this is not a standard UI paradigm. But should the UI toolkit prevent the user from doing nonstandard things? It is the great flexibility of Slint I really like compared to the very restrictive and unflexible QtWidgets so IMHO it would be sad to implement similar restrictions in Slint too ;)

Actually now I wonder if it would even be feasible to use arbitrary UI elements in menus? For example switches (though checked menu items should probably be a built-in feature):

image

On platforms that have native menu bar, we would use the muda crate to do so.

I agree, but it would be nice to allow disabling it, e.g. for cases as described above where the menu bar is used for other purpose too or contains arbitrary widgets.

So far just my thoughts, up to you to decide what makes sense and what doesn't :slightly_smiling_face:

Enyium commented 1 month ago

Its presence changes the layout of the content area. (So that y:0 means just after the MenuBar)

Would this prevent the user from placing any other UI elements above this pseudo y:0? Usually menu bars contain a lot of unused space, where it would be nice to allow using it for other things. For example a search box:

Under Windows, when making use of the OS-provided menu bar, the window's client area naturally doesn't include the menu bar. Use cases like you described would be a good reason in my opinion to provide custom menu bars and menus. Firefox, e.g., draws its menu bar and menus itself. According to GPT-4o, trying to custom-draw into the non-client area of the menu bar quickly gets complex and inflexible.

I think there could also be theme inconsistencies with OS-provided menu bars and menus (like light-themed menu bars and menus in a dark-themed app).

Actually now I wonder if it would even be feasible to use arbitrary UI elements in menus? For example switches (though checked menu items should probably be a built-in feature):

Some custom menu behavior could IMO indeed be desirable. I think everybody knows how annoying it can be having to repeatedly show a series of menus and submenus, paying attention to find the end spot again, just to quickly test a number of features reachable via menu (e.g., enabling and quickly disabling it again, or toggling multiple check marks). A custom menu implementation could provide a means to keep the menu shown after clicking on an item (like when holding a keyboard modifier when clicking).

dougcooper commented 1 month ago

are there any workaround examples available for this using muda in conjunction with slint?

ogoffart commented 1 month ago

@dougcooper

are there any workaround examples available for this using muda in conjunction with slint?

This is a bit out of topic for this issue, but we do use muda to change the menu of the live preview on macOs. (although this uses private API) https://github.com/slint-ui/slint/blob/master/tools/lsp/preview/native.rs#L273