Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions lib/FocusController.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright 2025 elementary, Inc. (https://elementary.io)
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Authored by: Leonhard Kargl <[email protected]>
*/

public class Gala.FocusController : Object {
private static HashTable<Clutter.Stage, FocusController> instances;

static construct {
instances = new HashTable<Clutter.Stage, FocusController> (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<weak Focusable> root_focusables;
private uint timeout_id = 0;

private FocusController (Clutter.Stage stage) {
Object (stage: stage);
}

construct {
root_focusables = new Gee.LinkedList<unowned Focusable> ();
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;
});
}
}
202 changes: 202 additions & 0 deletions lib/Focusable.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/*
* Copyright 2025 elementary, Inc. (https://elementary.io)
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Authored by: Leonhard Kargl <[email protected]>
*/

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<Focusable> get_focusable_children () {
var focusable_children = new Gee.ArrayList<Focusable> ();
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<Focusable> 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<Focusable> ();
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<Focusable> 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) { }
}
2 changes: 2 additions & 0 deletions lib/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -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',
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 : ActorTarget, Focusable {
public signal void window_selected (Meta.Window window);

public WindowManager wm { get; construct; }
Expand Down
Loading