diff --git a/lib/FocusController.vala b/lib/FocusController.vala new file mode 100644 index 000000000..0d78d35ee --- /dev/null +++ b/lib/FocusController.vala @@ -0,0 +1,88 @@ +/* + * Copyright 2025 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.FocusController : Object { + private static HashTable instances; + + static construct { + instances = new HashTable (null, null); + } + + public static FocusController get_for_stage (Clutter.Stage stage) { + if (!instances.contains (stage)) { + instances[stage] = new FocusController (stage); + } + return instances[stage]; + } + + public Clutter.Stage stage { get; construct; } + public bool focus_visible { get; private set; default = false; } + + private Gee.List root_focusables; + private uint timeout_id = 0; + + private FocusController (Clutter.Stage stage) { + Object (stage: stage); + } + + construct { + root_focusables = new Gee.LinkedList (); + stage.key_press_event.connect (handle_key_event); + } + + public void register_root (Focusable root) { + if (root in root_focusables) { + warning ("Trying to register root focusable multiple times."); + return; + } + + root_focusables.add (root); + root.weak_ref ((obj) => root_focusables.remove ((Focusable) obj)); + } + + private bool handle_key_event (Clutter.Event event) { + Focusable? mapped_root = null; + foreach (var root_focusable in root_focusables) { + if (root_focusable.mapped) { + mapped_root = root_focusable; + break; + } + } + + var direction = Focusable.FocusDirection.get_for_event (event); + + if (mapped_root == null || direction == null) { + return Clutter.EVENT_PROPAGATE; + } + + if (!mapped_root.focus (direction)) { +#if HAS_MUTTER47 + stage.context.get_backend ().get_default_seat ().bell_notify (); +#else + Clutter.get_default_backend ().get_default_seat ().bell_notify (); +#endif + } + + show_focus (); + + return Clutter.EVENT_STOP; + } + + private void show_focus () { + if (timeout_id != 0) { + Source.remove (timeout_id); + } else { + focus_visible = true; + } + + timeout_id = Timeout.add_seconds (5, () => { + focus_visible = false; + timeout_id = 0; + return Source.REMOVE; + }); + } +} diff --git a/lib/Focusable.vala b/lib/Focusable.vala new file mode 100644 index 000000000..8f4dcd60f --- /dev/null +++ b/lib/Focusable.vala @@ -0,0 +1,202 @@ +/* + * Copyright 2025 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public interface Gala.Focusable : Clutter.Actor { + public enum FocusDirection { + UP, + DOWN, + LEFT, + RIGHT, + NEXT, + PREVIOUS; + + public bool is_forward () { + return this == DOWN || this == RIGHT || this == NEXT; + } + + public static FocusDirection? get_for_event (Clutter.Event event) { + switch (event.get_key_symbol ()) { + case Clutter.Key.Up: return UP; + case Clutter.Key.Down: return DOWN; + case Clutter.Key.Left: return LEFT; + case Clutter.Key.Right: return RIGHT; + case Clutter.Key.Tab: + if (SHIFT_MASK in event.get_state ()) { + return PREVIOUS; + } else { + return NEXT; + } + } + + return null; + } + } + + public bool focus (FocusDirection direction) { + var focus_actor = get_stage ().get_key_focus (); + + // We have focus so try to move it to a child + if (focus_actor == this) { + if (direction.is_forward ()) { + return move_focus (direction); + } + + return false; + } + + // A child of us (or subchild) has focus, try to move it to the next one. + // If that doesn't work and we are moving backwards focus us + if (focus_actor != null && focus_actor is Focusable && focus_actor in this) { + if (move_focus (direction)) { + return true; + } + + if (direction.is_forward ()) { + return false; + } else { + return grab_focus (); + } + } + + // Focus is outside of us, try to take it + if (direction.is_forward ()) { + if (grab_focus ()) { + return true; + } + + return move_focus (direction); + } else { + if (move_focus (direction)) { + return true; + } + + return grab_focus (); + } + } + + protected virtual bool move_focus (FocusDirection direction) { + var children = get_focusable_children (); + + filter_children_for_direction (children, direction); + + switch (direction) { + case NEXT: + sort_children_for_direction (children, DOWN); + sort_children_for_direction (children, RIGHT); + break; + + case PREVIOUS: + sort_children_for_direction (children, UP); + sort_children_for_direction (children, LEFT); + break; + + default: + sort_children_for_direction (children, direction); + break; + } + + foreach (var child in children) { + if (child.focus (direction)) { + return true; + } + } + + return false; + } + + private Gee.List get_focusable_children () { + var focusable_children = new Gee.ArrayList (); + for (var child = get_first_child (); child != null; child = child.get_next_sibling ()) { + if (child is Focusable) { + focusable_children.add ((Focusable) child); + } + } + return focusable_children; + } + + private void filter_children_for_direction (Gee.List children, FocusDirection direction) { + var focus_actor = get_stage ().get_key_focus (); + + Focusable? focus_child = null; + for (var child = get_first_child (); child != null; child = child.get_next_sibling ()) { + if (focus_actor in child) { + if (child is Focusable) { + focus_child = (Focusable) child; + } + break; + } + } + + var to_retain = new Gee.LinkedList (); + to_retain.add_all_iterator (children.filter ((c) => { + if (focus_child == null || c == focus_child || direction == NEXT || direction == PREVIOUS) { + return true; + } + + var focus_rect = get_allocation_rect (focus_child); + var rect = get_allocation_rect (c); + + if ((direction == UP || direction == DOWN) && !rect.horiz_overlap (focus_rect) || + (direction == LEFT || direction == RIGHT) && !rect.vert_overlap (focus_rect) + ) { + return false; + } + + return ( + direction == UP && rect.y + rect.height <= focus_rect.y || + direction == DOWN && rect.y >= focus_rect.y + focus_rect.height || + direction == LEFT && rect.x + rect.width <= focus_rect.x || + direction == RIGHT && rect.x >= focus_rect.x + focus_rect.width + ); + })); + + children.retain_all (to_retain); + } + + private inline Mtk.Rectangle get_allocation_rect (Clutter.Actor actor) { + return {(int) actor.x, (int) actor.y, (int) actor.width, (int) actor.height}; + } + + private void sort_children_for_direction (Gee.List children, FocusDirection direction) { + children.sort ((a, b) => { + if (direction == UP && a.y + a.height > b.y + b.height || + direction == DOWN && a.y < b.y || + direction == LEFT && a.x + a.width > b.x + b.width || + direction == RIGHT && a.x < b.x + ) { + return -1; + } + + return 1; + }); + } + + private bool grab_focus () { + if (!can_focus ()) { + return false; + } + + var stage = get_stage (); + stage.set_key_focus (this); + focus_changed (); + key_focus_out.connect (focus_changed); + FocusController.get_for_stage (stage).notify["focus-visible"].connect (focus_changed); + + return true; + } + + public virtual bool can_focus () { + return false; + } + + private void focus_changed () { + var stage = get_stage (); + update_focus (stage?.get_key_focus () == this && FocusController.get_for_stage (stage).focus_visible); + } + + protected virtual void update_focus (bool has_visible_focus) { } +} diff --git a/lib/meson.build b/lib/meson.build index 7b8ec254a..2dd56894b 100644 --- a/lib/meson.build +++ b/lib/meson.build @@ -13,6 +13,8 @@ gala_lib_sources = files( 'Drawing/Color.vala', 'Drawing/StyleManager.vala', 'Drawing/Utilities.vala', + 'Focusable.vala', + 'FocusController.vala', 'Image.vala', 'Plugin.vala', 'RoundedCornersEffect.vala', diff --git a/src/Widgets/MultitaskingView/MonitorClone.vala b/src/Widgets/MultitaskingView/MonitorClone.vala index eec6d59a4..3121cc52c 100644 --- a/src/Widgets/MultitaskingView/MonitorClone.vala +++ b/src/Widgets/MultitaskingView/MonitorClone.vala @@ -11,7 +11,7 @@ * as the WindowGroup is hidden while the view is active. Only used when * workspaces-only-on-primary is set to true. */ -public class Gala.MonitorClone : ActorTarget { +public class Gala.MonitorClone : ActorTarget, Focusable { public signal void window_selected (Meta.Window window); public WindowManager wm { get; construct; } diff --git a/src/Widgets/MultitaskingView/MultitaskingView.vala b/src/Widgets/MultitaskingView/MultitaskingView.vala index fefb94ea3..efa8912e9 100644 --- a/src/Widgets/MultitaskingView/MultitaskingView.vala +++ b/src/Widgets/MultitaskingView/MultitaskingView.vala @@ -15,12 +15,14 @@ // along with this program. If not, see . // +public class Gala.PrimaryMonitorContainer : ActorTarget, Focusable { } + /** * The central class for the MultitaskingView which takes care of * preparing the wm, opening the components and holds containers for * the icon groups, the WorkspaceClones and the MonitorClones. */ -public class Gala.MultitaskingView : ActorTarget, RootTarget, ActivatableComponent { +public class Gala.MultitaskingView : ActorTarget, RootTarget, ActivatableComponent, Focusable { public const int ANIMATION_DURATION = 250; private GestureController workspaces_gesture_controller; @@ -54,6 +56,8 @@ public class Gala.MultitaskingView : ActorTarget, RootTarget, ActivatableCompone reactive = true; clip_to_allocation = true; + FocusController.get_for_stage (wm.stage).register_root (this); + opened = false; display = wm.get_display (); @@ -78,7 +82,7 @@ public class Gala.MultitaskingView : ActorTarget, RootTarget, ActivatableCompone // Create a child container that will be sized to fit the primary monitor, to contain the "main" // multitasking view UI. The Clutter.Actor of this class has to be allowed to grow to the size of the // stage as it contains MonitorClones for each monitor. - primary_monitor_container = new ActorTarget (); + primary_monitor_container = new PrimaryMonitorContainer (); primary_monitor_container.add_child (workspaces); add_child (primary_monitor_container); @@ -107,6 +111,8 @@ public class Gala.MultitaskingView : ActorTarget, RootTarget, ActivatableCompone }); style_manager.notify["prefers-color-scheme"].connect (update_brightness_effect); + + wm.stage.key_press_event.connect (on_stage_key_press_event); } /** @@ -245,7 +251,6 @@ public class Gala.MultitaskingView : ActorTarget, RootTarget, ActivatableCompone wm.window_group.hide (); wm.top_window_group.hide (); show (); - grab_key_focus (); modal_proxy = wm.push_modal (get_stage (), false); modal_proxy.set_keybinding_filter (keybinding_filter); @@ -369,34 +374,12 @@ public class Gala.MultitaskingView : ActorTarget, RootTarget, ActivatableCompone } } - /** - * Collect key events, mainly for redirecting them to the WindowCloneContainers to - * select the active window. - */ - public override bool key_press_event (Clutter.Event event) { - if (!opened) { - return Clutter.EVENT_PROPAGATE; - } - - return get_active_window_clone_container ().key_press_event (event); - } - - /** - * Finds the active WorkspaceClone - * - * @return The active WorkspaceClone - */ - private WindowCloneContainer get_active_window_clone_container () { - unowned var manager = display.get_workspace_manager (); - unowned var active_workspace = manager.get_active_workspace (); - foreach (unowned var child in workspaces.get_children ()) { - unowned var workspace_clone = (WorkspaceClone) child; - if (workspace_clone.workspace == active_workspace) { - return workspace_clone.window_container; - } + private bool on_stage_key_press_event (Clutter.Event event) { + if (opened && event.get_key_symbol () == Clutter.Key.Escape) { + close (); + return Clutter.EVENT_STOP; } - - assert_not_reached (); + return Clutter.EVENT_PROPAGATE; } private void window_selected (Meta.Window window) { diff --git a/src/Widgets/MultitaskingView/WindowClone.vala b/src/Widgets/MultitaskingView/WindowClone.vala index bf92b2e2f..3faff655c 100644 --- a/src/Widgets/MultitaskingView/WindowClone.vala +++ b/src/Widgets/MultitaskingView/WindowClone.vala @@ -8,7 +8,7 @@ * A container for a clone of the texture of a MetaWindow, a WindowIcon, a Tooltip with the title, * a close button and a shadow. Used together with the WindowCloneContainer. */ -public class Gala.WindowClone : ActorTarget, RootTarget { +public class Gala.WindowClone : ActorTarget, RootTarget, Focusable { private const int WINDOW_ICON_SIZE = 64; private const int ACTIVE_SHAPE_SIZE = 12; private const int FADE_ANIMATION_DURATION = 200; @@ -35,21 +35,6 @@ public class Gala.WindowClone : ActorTarget, RootTarget { */ public Mtk.Rectangle? slot { get; private set; default = null; } - /** - * When active fades a white border around the window in. Used for the visually - * indicating the WindowCloneContainer's current_window. - */ - public bool active { - set { - active_shape.update_color (); - - active_shape.save_easing_state (); - active_shape.set_easing_duration (Utils.get_animation_duration (FADE_ANIMATION_DURATION)); - active_shape.opacity = value ? 255 : 0; - active_shape.restore_easing_state (); - } - } - public bool overview_mode { get; construct; } public float monitor_scale { get; construct set; } @@ -151,6 +136,8 @@ public class Gala.WindowClone : ActorTarget, RootTarget { window_title.set_text (window.get_title () ?? ""); notify["has-pointer"].connect (() => update_hover_widgets ()); + + key_press_event.connect (on_key_press_event); } ~WindowClone () { @@ -272,6 +259,10 @@ public class Gala.WindowClone : ActorTarget, RootTarget { public override void start_progress (GestureAction action) { update_hover_widgets (true); + + if (action == MULTITASKING_VIEW && get_current_commit (MULTITASKING_VIEW) == 0 && window.has_focus ()) { + focus (NEXT); + } } public override void update_progress (Gala.GestureAction action, double progress) { @@ -379,6 +370,31 @@ public class Gala.WindowClone : ActorTarget, RootTarget { return Clutter.EVENT_STOP; } + public override bool can_focus () { + return true; + } + + protected override void update_focus (bool has_visible_focus) { + active_shape.update_color (); + + active_shape.save_easing_state (); + active_shape.set_easing_duration (Utils.get_animation_duration (FADE_ANIMATION_DURATION)); + active_shape.opacity = has_visible_focus ? 255 : 0; + active_shape.restore_easing_state (); + } + + private bool on_key_press_event (Clutter.Event event) { + switch (event.get_key_symbol ()) { + case Clutter.Key.Return: + case Clutter.Key.KP_Enter: + selected (); + return Clutter.EVENT_STOP; + + default: + return Clutter.EVENT_PROPAGATE; + } + } + private void update_hover_widgets (bool? animating = null) { if (animating != null) { in_slot_animation = animating; diff --git a/src/Widgets/MultitaskingView/WindowCloneContainer.vala b/src/Widgets/MultitaskingView/WindowCloneContainer.vala index 8adac200a..5b95a3a4b 100644 --- a/src/Widgets/MultitaskingView/WindowCloneContainer.vala +++ b/src/Widgets/MultitaskingView/WindowCloneContainer.vala @@ -7,7 +7,7 @@ /** * Container which controls the layout of a set of WindowClones. */ -public class Gala.WindowCloneContainer : ActorTarget { +public class Gala.WindowCloneContainer : ActorTarget, Focusable { public signal void window_selected (Meta.Window window); public signal void requested_close (); public signal void last_window_closed (); @@ -23,12 +23,6 @@ public class Gala.WindowCloneContainer : ActorTarget { private bool opened = false; - /** - * The window that is currently selected via keyboard shortcuts. - * It is not necessarily the same as the active window. - */ - private unowned WindowClone? current_window = null; - public WindowCloneContainer (WindowManager wm, float monitor_scale, bool overview_mode = false) { Object (wm: wm, monitor_scale: monitor_scale, overview_mode: overview_mode); } @@ -48,19 +42,7 @@ public class Gala.WindowCloneContainer : ActorTarget { var new_window = new WindowClone (wm, window, monitor_scale, overview_mode); new_window.selected.connect ((_new_window) => window_selected (_new_window.window)); new_window.request_reposition.connect (() => reflow (false)); - new_window.destroy.connect ((_new_window) => { - // make sure to release reference if the window is selected - if (_new_window == current_window) { - select_next_window (Meta.MotionDirection.RIGHT, false); - } - - // if window is still selected, reset the selection - if (_new_window == current_window) { - current_window = null; - } - - reflow (false); - }); + new_window.destroy.connect ((_new_window) => reflow (false)); bind_property ("monitor-scale", new_window, "monitor-scale"); unowned Meta.Window? target = null; @@ -108,18 +90,6 @@ public class Gala.WindowCloneContainer : ActorTarget { if (!opened) { opened = true; - if (current_window != null) { - current_window.active = false; - } - - unowned var focus_window = wm.get_display ().focus_window; - foreach (unowned var clone in (GLib.List) get_children ()) { - if (clone.window == focus_window) { - current_window = clone; - break; - } - } - restack_windows (); reflow (true); } else if (action == MULTITASKING_VIEW) { // If we are open we only want to restack when we close @@ -204,152 +174,6 @@ public class Gala.WindowCloneContainer : ActorTarget { } } - /** - * Collect key events, mainly for redirecting them to the WindowCloneContainers to - * select the active window. - */ - public override bool key_press_event (Clutter.Event event) { - if (!opened) { - return Clutter.EVENT_PROPAGATE; - } - - switch (event.get_key_symbol ()) { - case Clutter.Key.Escape: - requested_close (); - break; - case Clutter.Key.Down: - select_next_window (Meta.MotionDirection.DOWN, true); - break; - case Clutter.Key.Up: - select_next_window (Meta.MotionDirection.UP, true); - break; - case Clutter.Key.Left: - select_next_window (Meta.MotionDirection.LEFT, true); - break; - case Clutter.Key.Right: - select_next_window (Meta.MotionDirection.RIGHT, true); - break; - case Clutter.Key.Return: - case Clutter.Key.KP_Enter: - if (current_window == null) { - requested_close (); - } else { - window_selected (current_window.window); - } - break; - } - - return Clutter.EVENT_STOP; - } - - /** - * Look for the next window in a direction and make this window the new current_window. - * Used for keyboard navigation. - * - * @param direction The MetaMotionDirection in which to search for windows for. - * @param user_action Whether we must select a window and, if failed, play a bell sound. - */ - public void select_next_window (Meta.MotionDirection direction, bool user_action) { - if (get_n_children () == 0) { - return; - } - - WindowClone? closest = null; - - if (current_window == null) { - closest = (WindowClone) get_child_at_index (0); - } else { - var current_rect = current_window.slot; - - foreach (unowned var clone in (GLib.List) get_children ()) { - if (clone == current_window) { - continue; - } - - var window_rect = clone.slot; - - if (window_rect == null) { - continue; - } - - if (direction == LEFT) { - if (window_rect.x > current_rect.x) { - continue; - } - - // test for vertical intersection - if (window_rect.y + window_rect.height > current_rect.y - && window_rect.y < current_rect.y + current_rect.height) { - - if (closest == null || closest.slot.x < window_rect.x) { - closest = clone; - } - } - } else if (direction == RIGHT) { - if (window_rect.x < current_rect.x) { - continue; - } - - // test for vertical intersection - if (window_rect.y + window_rect.height > current_rect.y - && window_rect.y < current_rect.y + current_rect.height) { - - if (closest == null || closest.slot.x > window_rect.x) { - closest = clone; - } - } - } else if (direction == UP) { - if (window_rect.y > current_rect.y) { - continue; - } - - // test for horizontal intersection - if (window_rect.x + window_rect.width > current_rect.x - && window_rect.x < current_rect.x + current_rect.width) { - - if (closest == null || closest.slot.y < window_rect.y) { - closest = clone; - } - } - } else if (direction == DOWN) { - if (window_rect.y < current_rect.y) { - continue; - } - - // test for horizontal intersection - if (window_rect.x + window_rect.width > current_rect.x - && window_rect.x < current_rect.x + current_rect.width) { - - if (closest == null || closest.slot.y > window_rect.y) { - closest = clone; - } - } - } else { - warning ("Invalid direction"); - break; - } - } - } - - if (closest == null) { - if (current_window != null && user_action) { - InternalUtils.bell_notify (wm.get_display ()); - current_window.active = true; - } - return; - } - - if (current_window != null) { - current_window.active = false; - } - - if (user_action) { - closest.active = true; - } - - current_window = closest; - } - /** * Sorts the windows by stacking order so that the window on active workspaces come first. */ diff --git a/src/Widgets/MultitaskingView/WorkspaceClone.vala b/src/Widgets/MultitaskingView/WorkspaceClone.vala index 40c986e12..45a07e621 100644 --- a/src/Widgets/MultitaskingView/WorkspaceClone.vala +++ b/src/Widgets/MultitaskingView/WorkspaceClone.vala @@ -91,7 +91,7 @@ private class Gala.FramedBackground : BackgroundManager { * The latter is not added to the WorkspaceClone itself though but to a container * of the MultitaskingView. */ -public class Gala.WorkspaceClone : ActorTarget { +public class Gala.WorkspaceClone : ActorTarget, Focusable { /** * The offset of the scaled background to the bottom of the monitor bounds */ diff --git a/src/Widgets/MultitaskingView/WorkspaceRow.vala b/src/Widgets/MultitaskingView/WorkspaceRow.vala index 1398a2361..6ffc59916 100644 --- a/src/Widgets/MultitaskingView/WorkspaceRow.vala +++ b/src/Widgets/MultitaskingView/WorkspaceRow.vala @@ -5,7 +5,7 @@ * Authored by: Leonhard Kargl */ -public class Gala.WorkspaceRow : ActorTarget { +public class Gala.WorkspaceRow : ActorTarget, Focusable { public const int WORKSPACE_GAP = 24; public Meta.Display display { get; construct; } @@ -48,4 +48,9 @@ public class Gala.WorkspaceRow : ActorTarget { set_child_at_index (workspace_clone, workspace_clone.workspace.index ()); } } + + public override bool move_focus (FocusDirection direction) { + var child_index = - (int) get_current_commit (SWITCH_WORKSPACE); + return ((Focusable) get_child_at_index (child_index)).focus (direction); + } }