Skip to content
Merged
82 changes: 82 additions & 0 deletions lib/Widgets/FocusUtils.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright 2025 elementary, Inc. (https://elementary.io)
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Authored by: Leonhard Kargl <leo.kargl@proton.me>
*/

public enum Gala.FocusDirection {
UP,
DOWN,
LEFT,
RIGHT;

public bool is_forward () {
return this == DOWN || this == RIGHT;
}

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;
}

return null;
}
}

namespace Gala.FocusUtils {
public void filter_children_for_direction (Gee.List<Widget> children, Clutter.Actor focus_actor, FocusDirection direction) {
Widget? focus_child = null;
foreach (var child in children) {
if (focus_actor in child) {
focus_child = (Widget) child;
break;
}
}

var to_retain = new Gee.LinkedList<Widget> ();
to_retain.add_all_iterator (children.filter ((c) => {
if (focus_child == null || c == focus_child) {
return true;
}

var focus_alloc = focus_child.allocation;
var alloc = c.allocation;

var horiz_overlap = alloc.x1 < focus_alloc.x2 && alloc.x2 > focus_alloc.x1;
var vert_overlap = alloc.y1 < focus_alloc.y2 && alloc.y2 > focus_alloc.y1;

if ((direction == UP || direction == DOWN) && !horiz_overlap ||
(direction == LEFT || direction == RIGHT) && !vert_overlap
) {
return false;
}

return (
direction == UP && alloc.y2 <= focus_alloc.y1 ||
direction == DOWN && alloc.y1 >= focus_alloc.y2 ||
direction == LEFT && alloc.x2 <= focus_alloc.x1 ||
direction == RIGHT && alloc.x1 >= focus_alloc.x2
);
}));

children.retain_all (to_retain);
}

public void sort_children_for_direction (Gee.List<Widget> 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;
});
}
}
73 changes: 73 additions & 0 deletions lib/Widgets/Root.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright 2025 elementary, Inc. (https://elementary.io)
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Authored by: Leonhard Kargl <leo.kargl@proton.me>
*/

public class Gala.Root : Widget {
private bool _focus_visible = false;
internal bool focus_visible {
get { return _focus_visible; }
set {
_focus_visible = value;
(get_stage ().key_focus as Widget)?.focus_changed ();
}
}

private uint timeout_id = 0;

public override void map () {
base.map ();
// In the case the key focus moves out of our widget tree by some other means
// make sure we can recapture it
get_stage ().key_press_event.connect (check_focus);
}

public override void unmap () {
base.unmap ();
get_stage ().key_press_event.disconnect (check_focus);
}

public override bool key_press_event (Clutter.Event event) {
return check_focus (event);
}

private bool check_focus (Clutter.Event event) {
var direction = FocusDirection.get_for_event (event);

if (direction == null) {
return Clutter.EVENT_PROPAGATE;
}

if (!focus (direction)) {
#if HAS_MUTTER47
get_stage ().context.get_backend ().get_default_seat ().bell_notify ();
#else
Clutter.get_default_backend ().get_default_seat ().bell_notify ();
#endif

if (!(get_stage ().key_focus in this)) {
get_stage ().key_focus = this;
}
}

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;
});
}
}
107 changes: 107 additions & 0 deletions lib/Widgets/Widget.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright 2025 elementary, Inc. (https://elementary.io)
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Authored by: Leonhard Kargl <leo.kargl@proton.me>
*/

public class Gala.Widget : ActorTarget {
public bool can_focus { get; set; default = false; }
public bool has_visible_focus { get; private set; default = false; }

construct {
key_focus_in.connect (focus_changed);
key_focus_out.connect (focus_changed);
}
Comment thread
lenemter marked this conversation as resolved.

internal void focus_changed () {
has_visible_focus = has_key_focus () && (get_root ()?.focus_visible ?? false);
}

private Root? get_root () {
for (Clutter.Actor? actor = this; actor is Widget; actor = actor.get_parent ()) {
if (actor is Root) {
return (Root) actor;
}
}

return null;
}

public bool focus (FocusDirection direction) {
var focus_actor = get_stage ().get_key_focus ();
Comment thread
lenemter marked this conversation as resolved.

// 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 Widget && 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 ();
}
Comment thread
lenemter marked this conversation as resolved.
}

private bool grab_focus () {
if (!can_focus) {
return false;
}

grab_key_focus ();

return true;
}

protected virtual bool move_focus (FocusDirection direction) {
var children = get_widget_children ();

FocusUtils.filter_children_for_direction (children, get_stage ().key_focus, direction);
FocusUtils.sort_children_for_direction (children, direction);

foreach (var child in children) {
if (child.focus (direction)) {
return true;
}
}

return false;
}

private Gee.List<Widget> get_widget_children () {
var widget_children = new Gee.ArrayList<Widget> ();
for (var child = get_first_child (); child != null; child = child.get_next_sibling ()) {
if (child is Widget && child.visible) {
widget_children.add ((Widget) child);
}
}
return widget_children;
}
}
3 changes: 3 additions & 0 deletions lib/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@ gala_lib_sources = files(
'Widgets/AbstractSwitcher.vala',
'Widgets/CanvasActor.vala',
'Widgets/CloseButton.vala',
'Widgets/FocusUtils.vala',
'Widgets/Icon.vala',
'Widgets/Image.vala',
'Widgets/Root.vala',
'Widgets/Text.vala',
'Widgets/Widget.vala',
'Widgets/WindowIcon.vala'
) + gala_common_enums

Expand Down
2 changes: 1 addition & 1 deletion src/Widgets/MultitaskingView/MonitorClone.vala
Original file line number Diff line number Diff line change
Expand Up @@ -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 : Widget {
public signal void window_selected (Meta.Window window);

public WindowManager wm { get; construct; }
Expand Down
41 changes: 11 additions & 30 deletions src/Widgets/MultitaskingView/MultitaskingView.vala
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* 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 : Root, RootTarget, ActivatableComponent {
public const int ANIMATION_DURATION = 250;

private GestureController workspaces_gesture_controller;
Expand All @@ -35,7 +35,7 @@ public class Gala.MultitaskingView : ActorTarget, RootTarget, ActivatableCompone

private List<MonitorClone> window_containers_monitors;

private ActorTarget workspaces;
private Widget workspaces;
private Clutter.Actor primary_monitor_container;
private Clutter.BrightnessContrastEffect brightness_effect;
private BackgroundManager? blurred_bg = null;
Expand Down Expand Up @@ -85,7 +85,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 Widget ();
primary_monitor_container.add_child (workspaces);
add_child (primary_monitor_container);

Expand Down Expand Up @@ -230,7 +230,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.allow_actions (MULTITASKING_VIEW | SWITCH_WORKSPACE | ZOOM | LOCATE_POINTER | MEDIA_KEYS | SCREENSHOT | SCREENSHOT_AREA);
Expand Down Expand Up @@ -313,34 +312,16 @@ 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;
}
switch (event.get_key_symbol ()) {
case Clutter.Key.Escape:
case Clutter.Key.Return:
case Clutter.Key.KP_Enter:
close ();
return Clutter.EVENT_STOP;
default:
return Clutter.EVENT_PROPAGATE;
}

assert_not_reached ();
}

private void window_selected (Meta.Window window) {
Expand Down
2 changes: 1 addition & 1 deletion src/Widgets/MultitaskingView/StaticWindowClone.vala
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* windows (e.g. on all workspaces or moving) and fades out while the multitasking view
* is being opened.
*/
public class Gala.StaticWindowClone : ActorTarget {
public class Gala.StaticWindowClone : Widget {
public Meta.Window window { get; construct; }

public StaticWindowClone (Meta.Window window) {
Expand Down
2 changes: 1 addition & 1 deletion src/Widgets/MultitaskingView/StaticWindowContainer.vala
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* The window container use this to know whether a window became static (they shouldn't show it anymore)
* or isn't static anymore (they have to show it now).
*/
public class Gala.StaticWindowContainer : ActorTarget {
public class Gala.StaticWindowContainer : Widget {
private static GLib.Once<StaticWindowContainer> instance;
public static StaticWindowContainer get_instance (Meta.Display display) {
return instance.once (() => new StaticWindowContainer (display));
Expand Down
Loading