diff --git a/src/AppSystem/App.vala b/src/AppSystem/App.vala index 3945855a..b2983898 100644 --- a/src/AppSystem/App.vala +++ b/src/AppSystem/App.vala @@ -4,7 +4,7 @@ */ public class Dock.App : Object { - private const string ACTION_GROUP_PREFIX = "app-actions"; + public const string ACTION_GROUP_PREFIX = "app-actions"; private const string ACTION_PREFIX = ACTION_GROUP_PREFIX + "."; private const string PINNED_ACTION = "pinned"; private const string SWITCHEROO_ACTION = "switcheroo"; diff --git a/src/AppSystem/AppCache.vala b/src/AppSystem/AppCache.vala new file mode 100644 index 00000000..4657b143 --- /dev/null +++ b/src/AppSystem/AppCache.vala @@ -0,0 +1,99 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + */ + +public class Dock.AppCache : GLib.Object { + public ListStore apps { get; construct; } + + private const int DEFAULT_TIMEOUT_SECONDS = 3; + + private GLib.HashTable id_to_app; + + private GLib.AppInfoMonitor app_info_monitor; + + private uint queued_update_id = 0; + + construct { + apps = new ListStore (typeof (App)); + + id_to_app = new GLib.HashTable (str_hash, str_equal); + + app_info_monitor = GLib.AppInfoMonitor.@get (); + app_info_monitor.changed.connect (queue_cache_update); + + rebuild_cache.begin (); + } + + private void queue_cache_update () { + if (queued_update_id != 0) { + GLib.Source.remove (queued_update_id); + } + + queued_update_id = GLib.Timeout.add_seconds (DEFAULT_TIMEOUT_SECONDS, () => { + rebuild_cache.begin ((obj, res) => { + rebuild_cache.end (res); + queued_update_id = 0; + }); + + return GLib.Source.REMOVE; + }); + } + + private async void rebuild_cache () { + new Thread ("rebuild_cache", () => { + lock (id_to_app) { + var to_remove = id_to_app.get_keys (); + + var app_infos = GLib.AppInfo.get_all (); + + foreach (unowned AppInfo app_info in app_infos) { + if (!(app_info is DesktopAppInfo)) { + continue; + } + + var desktop_app_info = (DesktopAppInfo) app_info; + + if (!desktop_app_info.should_show ()) { + continue; + } + + unowned var id = app_info.get_id (); + if (id in id_to_app) { + to_remove.remove (id); + continue; + } + + id_to_app[id] = new App (desktop_app_info, false); + } + + foreach (var id in to_remove) { + id_to_app.remove (id); + } + } + + Idle.add (rebuild_cache.callback); + }); + + yield; + + apps.splice (0, apps.n_items, id_to_app.get_values_as_ptr_array ().data); + } + + public App? get_app (string id) { + if (id in id_to_app) { + return id_to_app[id]; + } + + /* Haven't found it but it's possibly the cache just hasn't been built yet so + * try manually. + */ + var info = new DesktopAppInfo (id); + + if (info == null) { + return null; + } + + return id_to_app[id]; + } +} diff --git a/src/AppSystem/AppSystem.vala b/src/AppSystem/AppSystem.vala index 4dd84634..e93930d9 100644 --- a/src/AppSystem/AppSystem.vala +++ b/src/AppSystem/AppSystem.vala @@ -17,11 +17,14 @@ public class Dock.AppSystem : Object, UnityClient { public signal void app_added (App app); - private GLib.HashTable id_to_app; + public AppCache app_cache { get; construct; } + + private GLib.HashTable id_to_app; // This only stores apps that are visible in the dock private AppSystem () { } construct { + app_cache = new AppCache (); id_to_app = new HashTable (str_hash, str_equal); } @@ -31,17 +34,22 @@ public class Dock.AppSystem : Object, UnityClient { public async void load () { foreach (string app_id in settings.get_strv ("launchers")) { - var app_info = new GLib.DesktopAppInfo (app_id); - add_app (app_info, true); + add_app (app_id, true); } yield sync_windows (); WindowSystem.get_default ().notify["windows"].connect (sync_windows); } - private App add_app (DesktopAppInfo app_info, bool pinned) { - var app = new App (app_info, pinned); - id_to_app[app_info.get_id ()] = app; + private App? add_app (string id, bool pinned) { + var app = app_cache.get_app (id); + if (app == null) { + warning ("App %s not found.", id); + return null; + } + + app.pinned = pinned; + id_to_app[id] = app; app.removed.connect ((_app) => id_to_app.remove (_app.app_info.get_id ())); app_added (app); return app; @@ -54,12 +62,11 @@ public class Dock.AppSystem : Object, UnityClient { foreach (var window in windows) { App? app = id_to_app[window.app_id]; if (app == null) { - var app_info = new GLib.DesktopAppInfo (window.app_id); - if (app_info == null) { + app = add_app (window.app_id, false); + + if (app == null) { continue; } - - app = add_app (app_info, false); } var window_list = app_window_list.get (app); @@ -85,14 +92,7 @@ public class Dock.AppSystem : Object, UnityClient { return; } - var app_info = new DesktopAppInfo (app_id); - - if (app_info == null) { - warning ("App not found: %s", app_id); - return; - } - - add_app (app_info, true); + add_app (app_id, true); } public void remove_app_by_id (string app_id) { diff --git a/src/AppSystem/ApplicationMenu/ApplicationButton.vala b/src/AppSystem/ApplicationMenu/ApplicationButton.vala new file mode 100644 index 00000000..e51e248b --- /dev/null +++ b/src/AppSystem/ApplicationMenu/ApplicationButton.vala @@ -0,0 +1,84 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + */ + +public class Dock.ApplicationButton : Gtk.FlowBoxChild { + private static GLib.Settings dock_settings; + + static construct { + dock_settings = new GLib.Settings ("io.elementary.dock"); + } + + public App app { get; construct; } + + private Gtk.PopoverMenu popover; + private Gtk.Image image; + + public ApplicationButton (App app) { + Object (app: app); + } + + construct { + popover = new Gtk.PopoverMenu.from_model (app.menu_model) { + autohide = true, + position = BOTTOM, + has_arrow = false, + halign = START + }; + popover.set_parent (this); + + image = new Gtk.Image.from_gicon (app.app_info.get_icon ()); + dock_settings.bind ("icon-size", image, "pixel-size", GET); + + var label = new Gtk.Label (app.app_info.get_display_name ()) { + wrap = true, + ellipsize = END, + }; + + var box = new Gtk.Box (VERTICAL, 6); + box.append (image); + box.append (label); + + var button = new Gtk.Button () { + child = box + }; + button.add_css_class (Granite.STYLE_CLASS_FLAT); + + child = button; + + insert_action_group (App.ACTION_GROUP_PREFIX, app.action_group); + + var long_press = new Gtk.GestureLongPress (); + add_controller (long_press); + long_press.pressed.connect (popup_menu); + + var gesture_click = new Gtk.GestureClick () { + button = Gdk.BUTTON_SECONDARY + }; + add_controller (gesture_click); + gesture_click.released.connect ((n_press, x, y) => popup_menu (x, y)); + + button.clicked.connect (on_clicked); + + // The AppSystem might not know about this app (if it's not running in the dock) + // so we have to actually call pin + app.notify["pinned"].connect (() => { + if (app.pinned) { + AppSystem.get_default ().add_app_for_id (app.app_info.get_id ()); + } + }); + } + + private void on_clicked () { + var popover = (Gtk.Popover) get_ancestor (typeof (Gtk.Popover)); + popover.popdown (); + + app.launch (Gdk.Display.get_default ().get_app_launch_context ()); + } + + private void popup_menu (double x, double y) { + popover.set_pointing_to ({ (int) x, (int) y }); + popover.popup (); + } +} diff --git a/src/AppSystem/ApplicationMenu/ApplicationGrid.vala b/src/AppSystem/ApplicationMenu/ApplicationGrid.vala new file mode 100644 index 00000000..c034d7ee --- /dev/null +++ b/src/AppSystem/ApplicationMenu/ApplicationGrid.vala @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + */ + +public class Dock.ApplicationGrid : Granite.Bin { + private AppCache app_cache; + private Adw.Carousel carousel; + + construct { + app_cache = AppSystem.get_default ().app_cache; + + carousel = new Adw.Carousel () { + hexpand = true, + vexpand = true + }; + + var dots = new Adw.CarouselIndicatorDots () { + carousel = carousel + }; + + var box = new Gtk.Box (VERTICAL, 6) { + margin_bottom = 12, + margin_top = 12, + margin_start = 12, + margin_end = 12 + }; + box.append (carousel); + box.append (dots); + + child = box; + height_request = ApplicationMenu.HEIGHT; + width_request = ApplicationMenu.WIDTH; + + repopulate_carousel (); + app_cache.apps.items_changed.connect (repopulate_carousel); + } + + private void repopulate_carousel () { + var n_pages = app_cache.apps.n_items / ApplicationGridPage.PAGE_SIZE; + for (int i = 0; i < n_pages; i++) { + if (i < carousel.n_pages) { + continue; + } + + var page = new ApplicationGridPage (app_cache.apps, i); + carousel.append (page); + } + + // for (uint i = carousel.n_pages - 1; i >= n_pages; i--) { + // carousel.remove (carousel.get_nth_page (i)); + // } + } +} diff --git a/src/AppSystem/ApplicationMenu/ApplicationGridPage.vala b/src/AppSystem/ApplicationMenu/ApplicationGridPage.vala new file mode 100644 index 00000000..df6ce09f --- /dev/null +++ b/src/AppSystem/ApplicationMenu/ApplicationGridPage.vala @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + */ + +public class Dock.ApplicationGridPage : Granite.Bin { + public const int PAGE_SIZE = 20; + + public ListModel apps { get; construct; } + public int page { get; construct; } + + private Gtk.SliceListModel slice_model; + + public ApplicationGridPage (ListModel apps, int page) { + Object (apps: apps, page: page); + } + + construct { + slice_model = new Gtk.SliceListModel (apps, page * PAGE_SIZE, PAGE_SIZE); + + var flow_box = new Gtk.FlowBox () { + max_children_per_line = 5, + min_children_per_line = 5, + homogeneous = true, + selection_mode = NONE, + row_spacing = 12, + column_spacing = 12, + }; + flow_box.bind_model (slice_model, create_widget); + + hexpand = true; + child = flow_box; + } + + private Gtk.Widget create_widget (Object item) { + var app = (App) item; + return new ApplicationButton (app); + } +} diff --git a/src/AppSystem/ApplicationMenu/ApplicationMenu.vala b/src/AppSystem/ApplicationMenu/ApplicationMenu.vala new file mode 100644 index 00000000..fadad03a --- /dev/null +++ b/src/AppSystem/ApplicationMenu/ApplicationMenu.vala @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + */ + +public class Dock.ApplicationMenu : Gtk.Popover { + public const int WIDTH = 600; + public const int HEIGHT = 600; + + construct { + var overlay = new Gtk.Overlay () { + child = new Container (), + }; + overlay.add_overlay (new ApplicationGrid ()); + + position = TOP; + has_arrow = false; + height_request = WIDTH; + width_request = HEIGHT; + margin_bottom = 12; + child = overlay; + remove_css_class (Granite.STYLE_CLASS_BACKGROUND); + } + + public void toggle () { + popup (); + } + + public override void snapshot (Gtk.Snapshot snapshot) { + base.snapshot (snapshot); + // We need to append something here otherwise GTK thinks the snapshot is empty and therefore doesn't + // render anything and therefore doesn't present a window which is needed for our popovers + snapshot.append_color ({0, 0, 0, 0}, {{0, 0}, {0, 0}}); + } +} diff --git a/src/AppSystem/ApplicationMenu/ApplicationMenuButton.vala b/src/AppSystem/ApplicationMenu/ApplicationMenuButton.vala new file mode 100644 index 00000000..6084ae62 --- /dev/null +++ b/src/AppSystem/ApplicationMenu/ApplicationMenuButton.vala @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + */ + +public class Dock.ApplicationMenuButton : BaseItem { + class construct { + set_css_name ("launcher"); + } + + construct { + var add_image = new Gtk.Image.from_icon_name ("applications-other") { + hexpand = true, + vexpand = true + }; + + overlay.child = add_image; + + dock_settings.bind ("icon-size", this, "width-request", DEFAULT); + dock_settings.bind ("icon-size", this, "height-request", DEFAULT); + + dock_settings.bind_with_mapping ( + "icon-size", add_image, "pixel_size", DEFAULT | GET, + (value, variant, user_data) => { + var icon_size = variant.get_int32 (); + value.set_int (icon_size / 2); + return true; + }, + (value, expected_type, user_data) => { + return new Variant.maybe (null, null); + }, + null, null + ); + + gesture_click.button = Gdk.BUTTON_PRIMARY; + gesture_click.released.connect (on_clicked); + } + + private void on_clicked () { + activate_action (Application.ACTION_PREFIX + Application.TOGGLE_APPLICATION_MENU_ACTION, null); + } +} diff --git a/src/Application.vala b/src/Application.vala index 92f162f3..cc244420 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -4,6 +4,9 @@ */ public class Dock.Application : Gtk.Application { + public const string ACTION_PREFIX = "app."; + public const string TOGGLE_APPLICATION_MENU_ACTION = "toggle-application-menu"; + public Application () { Object (application_id: "io.elementary.dock"); } @@ -23,6 +26,12 @@ public class Dock.Application : Gtk.Application { ); gtk_settings.gtk_application_prefer_dark_theme = granite_settings.prefers_color_scheme == DARK; + + var application_menu_action = new SimpleAction (TOGGLE_APPLICATION_MENU_ACTION, null); + application_menu_action.activate.connect (() => { + ItemManager.get_default ().toggle_application_menu (); + }); + add_action (application_menu_action); } protected override void activate () { diff --git a/src/BaseItem.vala b/src/BaseItem.vala index e040df04..7afae5b8 100644 --- a/src/BaseItem.vala +++ b/src/BaseItem.vala @@ -24,7 +24,7 @@ public class Dock.BaseItem : Gtk.Box { private Adw.TimedAnimation reveal; private Adw.TimedAnimation timed_animation; - private BaseItem () {} + protected BaseItem () {} construct { orientation = VERTICAL; diff --git a/src/ItemManager.vala b/src/ItemManager.vala index 3910ad91..b0139993 100644 --- a/src/ItemManager.vala +++ b/src/ItemManager.vala @@ -14,10 +14,13 @@ public Launcher? added_launcher { get; set; default = null; } private Adw.TimedAnimation resize_animation; + private ApplicationMenuButton app_menu_button; private List launchers; // Only used to keep track of launcher indices private List icon_groups; // Only used to keep track of icon group indices private DynamicWorkspaceIcon dynamic_workspace_item; + private ApplicationMenu application_menu; + static construct { settings = new Settings ("io.elementary.dock"); } @@ -28,10 +31,14 @@ // Idle is used here to because DynamicWorkspaceIcon depends on ItemManager Idle.add_once (() => { + app_menu_button = new ApplicationMenuButton (); dynamic_workspace_item = new DynamicWorkspaceIcon (); add_item (dynamic_workspace_item); }); + application_menu = new ApplicationMenu (); + application_menu.set_parent (this); + overflow = VISIBLE; resize_animation = new Adw.TimedAnimation ( @@ -131,6 +138,9 @@ private void reposition_items () { int index = 0; + + position_item (app_menu_button, ref index); + foreach (var launcher in launchers) { position_item (launcher, ref index); } @@ -256,6 +266,10 @@ launchers.nth (index - 1).data.app.launch (context); } + public void toggle_application_menu () { + application_menu.toggle (); + } + public static int get_launcher_size () { return settings.get_int ("icon-size") + Launcher.PADDING * 2; } diff --git a/src/MainWindow.vala b/src/MainWindow.vala index 6f0ae0ef..39c9e940 100644 --- a/src/MainWindow.vala +++ b/src/MainWindow.vala @@ -3,13 +3,13 @@ * SPDX-FileCopyrightText: 2022-2025 elementary, Inc. (https://elementary.io) */ -public class Dock.MainWindow : Gtk.ApplicationWindow { - private class Container : Gtk.Box { - class construct { - set_css_name ("dock"); - } +public class Container : Gtk.Box { + class construct { + set_css_name ("dock"); } +} +public class Dock.MainWindow : Gtk.ApplicationWindow { private class BottomMargin : Gtk.Widget { class construct { set_css_name ("bottom-margin"); diff --git a/src/meson.build b/src/meson.build index 34b5ca58..7825d145 100644 --- a/src/meson.build +++ b/src/meson.build @@ -4,9 +4,15 @@ sources = [ 'ItemManager.vala', 'MainWindow.vala', 'AppSystem' / 'App.vala', + 'AppSystem' / 'AppCache.vala', 'AppSystem' / 'AppSystem.vala', 'AppSystem' / 'Launcher.vala', 'AppSystem' / 'PoofPopover.vala', + 'AppSystem' / 'ApplicationMenu' / 'ApplicationButton.vala', + 'AppSystem' / 'ApplicationMenu' / 'ApplicationGrid.vala', + 'AppSystem' / 'ApplicationMenu' / 'ApplicationGridPage.vala', + 'AppSystem' / 'ApplicationMenu' / 'ApplicationMenu.vala', + 'AppSystem' / 'ApplicationMenu' / 'ApplicationMenuButton.vala', 'DBus' / 'GalaDBus.vala', 'DBus' / 'ItemInterface.vala', 'DBus' / 'ShellKeyGrabber.vala',