diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 201ea5393..6104f3a23 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,7 +31,7 @@ jobs: - name: Install Dependencies run: | apt update - apt install -y gettext gsettings-desktop-schemas-dev libatk-bridge2.0-dev libclutter-1.0-dev libgee-0.8-dev libglib2.0-dev libgnome-desktop-4-dev libgnome-bg-4-dev libgranite-dev libgtk-3-dev ${{ matrix.mutter_pkg }} libsoup-3.0-dev libsqlite3-dev meson systemd-dev valac valadoc + apt install -y gettext gsettings-desktop-schemas-dev libatk-bridge2.0-dev libclutter-1.0-dev libgee-0.8-dev libglib2.0-dev libgnome-desktop-4-dev libgnome-bg-4-dev libgranite-dev libgtk-3-dev libibus-1.0-dev ${{ matrix.mutter_pkg }} libsoup-3.0-dev libsqlite3-dev meson systemd-dev valac valadoc - name: Build env: DESTDIR: out @@ -59,7 +59,7 @@ jobs: - uses: actions/checkout@v6 - name: Install Dependencies run: | - dnf install -y desktop-file-utils gettext gsettings-desktop-schemas-devel atk-devel clutter-devel libgee-devel glib2-devel gnome-desktop3-devel granite-devel granite-7-devel gtk3-devel gtk4-devel libhandy-devel mutter-devel sqlite-devel meson valac valadoc + dnf install -y desktop-file-utils gettext gsettings-desktop-schemas-devel atk-devel clutter-devel libgee-devel glib2-devel gnome-desktop3-devel granite-devel granite-7-devel gtk3-devel gtk4-devel ibus-devel libhandy-devel mutter-devel sqlite-devel meson valac valadoc - name: Build env: DESTDIR: out @@ -78,7 +78,7 @@ jobs: run: | zypper addrepo https://download.opensuse.org/repositories/X11:Pantheon/16.0/X11:Pantheon.repo zypper --gpg-auto-import-keys refresh - zypper --non-interactive install tar git desktop-file-utils gsettings-desktop-schemas-devel libatk-1_0-0 clutter-devel libgee-devel glib2-devel libgnome-desktop-4-devel granite6-devel granite-devel gtk3-devel gtk4-devel libhandy-devel mutter-devel sqlite3-devel meson vala valadoc gcc + zypper --non-interactive install tar git desktop-file-utils gsettings-desktop-schemas-devel libatk-1_0-0 clutter-devel libgee-devel glib2-devel libgnome-desktop-4-devel granite6-devel granite-devel gtk3-devel gtk4-devel ibus-devel libhandy-devel mutter-devel sqlite3-devel meson vala valadoc gcc - uses: actions/checkout@v6 - name: Build env: diff --git a/daemon/IBus/Candidate.vala b/daemon/IBus/Candidate.vala new file mode 100644 index 000000000..0a7217753 --- /dev/null +++ b/daemon/IBus/Candidate.vala @@ -0,0 +1,15 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.Candidate : Object { + public string? label { get; construct; } + public string? candidate { get; construct; } + + public Candidate (string? label, string? candidate) { + Object (label: label, candidate: candidate); + } +} diff --git a/daemon/IBus/CandidateArea.vala b/daemon/IBus/CandidateArea.vala new file mode 100644 index 000000000..3f41e7257 --- /dev/null +++ b/daemon/IBus/CandidateArea.vala @@ -0,0 +1,131 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.CandidateArea : Granite.Bin { + private const string[] DEFAULT_LABELS = { + "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "a", "b", "c", "d", "e", "f" + }; + + public IBus.PanelService service { get; construct; } + + private ListStore model; + private Gtk.SingleSelection selection_model; + private Gtk.ListView list_view; + + private Gtk.Button prev_page_button; + private Gtk.Button next_page_button; + private Granite.Box button_box; + + private Granite.Box content_box; + + public CandidateArea (IBus.PanelService service) { + Object (service: service); + } + + construct { + model = new ListStore (typeof (Candidate)); + + selection_model = new Gtk.SingleSelection (model); + + var factory = new Gtk.SignalListItemFactory (); + factory.setup.connect (on_setup); + factory.bind.connect (on_bind); + + list_view = new Gtk.ListView (selection_model, factory); + + prev_page_button = new Gtk.Button (); + prev_page_button.clicked.connect (service.page_up); + + next_page_button = new Gtk.Button (); + next_page_button.clicked.connect (service.page_down); + + button_box = new Granite.Box (HORIZONTAL, LINKED) { + hexpand = true + }; + button_box.append (prev_page_button); + button_box.append (next_page_button); + + content_box = new Granite.Box (VERTICAL); + content_box.append (list_view); + content_box.append (button_box); + + child = content_box; + } + + private void on_setup (Object obj) { + var item = (Gtk.ListItem) obj; + item.child = new CandidateBox (service, item); + } + + private void on_bind (Object obj) { + var item = (Gtk.ListItem) obj; + var candidate = (Candidate) item.item; + + var box = (CandidateBox) item.child; + box.set_candidate (candidate); + } + + public void update (IBus.LookupTable table) { + model.remove_all (); + + if (table.get_orientation () == IBus.Orientation.HORIZONTAL) { + update_orientation (HORIZONTAL); + } else { /* VERTICAL or SYSTEM */ + update_orientation (VERTICAL); + } + + var n_candidates = table.get_number_of_candidates (); + var page_size = table.get_page_size (); + + if (page_size == 0) { + /* I don't think 0 is intended to happen so print a warning */ + warning ("LookupTable page size is 0, using 5"); + page_size = 5; + } + + var cursor_pos = table.get_cursor_pos (); + var page = (uint) (cursor_pos / page_size); + + var start_index = page * page_size; + var end_index = uint.min (start_index + page_size, n_candidates); + + for (uint i = start_index; i < end_index; i++) { + var ibus_label = table.get_label (i)?.text; + var label = ibus_label != null && ibus_label.strip () != "" ? ibus_label : ( + i - start_index < DEFAULT_LABELS.length ? DEFAULT_LABELS[i - start_index] : null + ); + + var candidate = table.get_candidate (i)?.text; + + model.append (new Candidate (label, candidate)); + } + + selection_model.selected = table.get_cursor_in_page (); + + update_buttons (table.is_round (), page, (uint) ((n_candidates + page_size - 1) / page_size)); + } + + private void update_orientation (Gtk.Orientation orientation) { + content_box.orientation = orientation; + list_view.orientation = orientation; + + if (orientation == HORIZONTAL) { + prev_page_button.icon_name = "go-previous"; + next_page_button.icon_name = "go-next"; + } else { + prev_page_button.icon_name = "go-up"; + next_page_button.icon_name = "go-down"; + } + } + + private void update_buttons (bool wraps_around, uint page, uint n_pages) { + button_box.visible = n_pages > 1; + + prev_page_button.sensitive = wraps_around || page > 0; + next_page_button.sensitive = wraps_around || page < n_pages - 1; + } +} diff --git a/daemon/IBus/CandidateBox.vala b/daemon/IBus/CandidateBox.vala new file mode 100644 index 000000000..506225ebd --- /dev/null +++ b/daemon/IBus/CandidateBox.vala @@ -0,0 +1,44 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.CandidateBox : Granite.Bin { + public IBus.PanelService service { get; construct; } + public unowned Gtk.ListItem list_item { get; construct; } + + private Gtk.Label label_label; + private Gtk.Label candidate_label; + + public CandidateBox (IBus.PanelService service, Gtk.ListItem list_item) { + Object (service: service, list_item: list_item); + } + + construct { + label_label = new Gtk.Label (null); + label_label.add_css_class (Granite.CssClass.DIM); + + candidate_label = new Gtk.Label (null); + + var content_box = new Granite.Box (HORIZONTAL, HALF); + content_box.append (label_label); + content_box.append (candidate_label); + + child = content_box; + + var gesture_click = new Gtk.GestureClick (); + gesture_click.released.connect (on_clicked); + add_controller (gesture_click); + } + + private void on_clicked (Gtk.GestureClick gesture, int n_press, double x, double y) { + service.candidate_clicked (list_item.position, gesture.get_current_button (), gesture.get_current_event_state ()); + } + + public void set_candidate (Candidate candidate) { + label_label.label = candidate.label; + candidate_label.label = candidate.candidate; + } +} diff --git a/daemon/IBus/IBusCandidateWindow.vala b/daemon/IBus/IBusCandidateWindow.vala new file mode 100644 index 000000000..62c534749 --- /dev/null +++ b/daemon/IBus/IBusCandidateWindow.vala @@ -0,0 +1,125 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.IBusCandidateWindow : Gtk.Window { + public IBus.PanelService service { get; construct; } + + public bool disabled { get; set; default = false; } + + private Gtk.Label preedit_text; + private Gtk.Label auxiliary_text; + private CandidateArea candidate_area; + + public IBusCandidateWindow (IBus.PanelService service) { + Object (service: service); + } + + construct { + preedit_text = new Gtk.Label (null) { + halign = START, + visible = false, + }; + + auxiliary_text = new Gtk.Label (null) { + halign = START, + visible = false, + }; + + candidate_area = new CandidateArea (service) { + hexpand = true, + visible = false, + }; + + var content_box = new Granite.Box (VERTICAL) { + margin_start = 6, + margin_end = 6, + margin_top = 6, + margin_bottom = 6, + }; + content_box.append (preedit_text); + content_box.append (auxiliary_text); + content_box.append (candidate_area); + + titlebar = new Gtk.Grid () { visible = false }; + child = content_box; + /* Used to identify the window for correct positioning in the wm */ + title = "IBUS_CANDIDATE"; + resizable = false; + + service.show_preedit_text.connect (on_show_preedit_text); + service.hide_preedit_text.connect (on_hide_preedit_text); + service.update_preedit_text.connect (on_update_preedit_text); + service.show_auxiliary_text.connect (on_show_auxiliary_text); + service.hide_auxiliary_text.connect (on_hide_auxiliary_text); + service.update_auxiliary_text.connect (on_update_auxiliary_text); + service.show_lookup_table.connect (on_show_lookup_table); + service.hide_lookup_table.connect (on_hide_lookup_table); + service.update_lookup_table.connect (on_update_lookup_table); + service.focus_out.connect (hide); + } + + private void update_visibility () { + var is_visible = !disabled && (preedit_text.visible || auxiliary_text.visible || candidate_area.visible); + + if (is_visible) { + present (); + } else { + hide (); + } + } + + private void on_show_preedit_text () { + preedit_text.visible = true; + update_visibility (); + } + + private void on_hide_preedit_text () { + preedit_text.visible = false; + update_visibility (); + } + + private void on_update_preedit_text (IBus.Text text, uint cursor_pos, bool visible) { + preedit_text.visible = visible; + preedit_text.label = text.text; + + update_visibility (); + } + + private void on_show_auxiliary_text () { + auxiliary_text.visible = true; + update_visibility (); + } + + private void on_hide_auxiliary_text () { + auxiliary_text.visible = false; + update_visibility (); + } + + private void on_update_auxiliary_text (IBus.Text text, bool visible) { + auxiliary_text.visible = visible; + auxiliary_text.label = text.text; + + update_visibility (); + } + + private void on_show_lookup_table () { + candidate_area.visible = true; + update_visibility (); + } + + private void on_hide_lookup_table () { + candidate_area.visible = false; + update_visibility (); + } + + private void on_update_lookup_table (IBus.LookupTable table, bool visible) { + candidate_area.visible = visible; + update_visibility (); + + candidate_area.update (table); + } +} diff --git a/daemon/IBus/IBusService.vala b/daemon/IBus/IBusService.vala new file mode 100644 index 000000000..c4b056d80 --- /dev/null +++ b/daemon/IBus/IBusService.vala @@ -0,0 +1,73 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.IBusService : Object { + private ListStore _candidates; + public ListModel candidates { get { return _candidates; } } + + public bool disable_popup { get; set; default = false; } + public IBus.PanelService service { get; private set; } + + private IBus.Bus bus; + private IBusCandidateWindow candidate_window; + + construct { + _candidates = new ListStore (typeof (Candidate)); + + bus = new IBus.Bus.async (); + bus.connected.connect (on_connected); + } + + private void on_connected () { + bus.request_name_async.begin ( + IBus.SERVICE_PANEL, IBus.BusNameFlag.REPLACE_EXISTING, -1, null, + on_name_acquired + ); + } + + private void on_name_acquired (Object? obj, AsyncResult res) { + try { + bus.request_name_async_finish (res); + } catch (Error e) { + warning ("Failed to acquire bus name: %s", e.message); + return; + } + + /* We need to go via Object.new because we need to pass construct properties */ + service = (IBus.PanelService) Object.@new (typeof (IBus.PanelService), "connection", bus.get_connection (), "object-path", IBus.PATH_PANEL); + candidate_window = new IBusCandidateWindow (service); + bind_property ("disable-popup", candidate_window, "disabled", SYNC_CREATE); + + service.update_lookup_table.connect (on_update_lookup_table); + } + + private void on_update_lookup_table (IBus.LookupTable table) { + _candidates.remove_all (); + + var n_candidates = table.get_number_of_candidates (); + var page_size = table.get_page_size (); + + if (page_size == 0) { + /* I don't think 0 is intended to happen so print a warning */ + warning ("LookupTable page size is 0, using 5"); + page_size = 5; + } + + var cursor_pos = table.get_cursor_pos (); + var page = (uint) (cursor_pos / page_size); + + var start_index = page * page_size; + var end_index = uint.min (start_index + page_size, n_candidates); + + for (uint i = start_index; i < end_index; i++) { + var label = table.get_label (i)?.text; + var candidate = table.get_candidate (i)?.text; + + _candidates.append (new Candidate (label, candidate)); + } + } +} diff --git a/daemon/Main.vala b/daemon/Main.vala index bab809098..d9c2b0f57 100644 --- a/daemon/Main.vala +++ b/daemon/Main.vala @@ -4,10 +4,18 @@ */ public class Gala.Daemon.Application : Gtk.Application { + private IBusService ibus_service; + private OSKManager osk_manager; + public Application () { Object (application_id: "org.pantheon.gala.daemon"); } + construct { + ibus_service = new IBusService (); + osk_manager = new OSKManager (ibus_service); + } + public override void startup () { base.startup (); diff --git a/daemon/OSK/InputManager.vala b/daemon/OSK/InputManager.vala new file mode 100644 index 000000000..9c25f3a18 --- /dev/null +++ b/daemon/OSK/InputManager.vala @@ -0,0 +1,24 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.InputManager : Object { + public OSKService service { private get; construct; } + + public InputManager (OSKService service) { + Object (service: service); + } + + public void send_keyval (uint keyval) { + service.keyval_pressed (keyval); + service.keyval_released (keyval); + } + + public void erase () { + service.keyval_pressed (Gdk.Key.BackSpace); + service.keyval_released (Gdk.Key.BackSpace); + } +} diff --git a/daemon/OSK/KeyboardModel/Key.vala b/daemon/OSK/KeyboardModel/Key.vala new file mode 100644 index 000000000..1c869604c --- /dev/null +++ b/daemon/OSK/KeyboardModel/Key.vala @@ -0,0 +1,42 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.Key : Object { + public const string ACTION_GROUP_PREFIX = "keyboard"; + public const string ACTION_PREFIX = ACTION_GROUP_PREFIX + "."; + /* Types the keyval given as the action target */ + public const string ACTION_TYPE_KEY_VAL = "keyval"; + /* Erases the last character */ + public const string ACTION_ERASE = "erase"; + /* Latches the keyboard view with the name given as the action target */ + public const string ACTION_LATCH_VIEW = "latch-view"; + /* Sets the keyboard view with the name given as the action target */ + public const string ACTION_SET_VIEW = "set-view"; + + public double left_offset { get; construct; default = 0.0; } + public double width { get; construct; default = 1.0; } + public double height { get; construct; default = 1.0; } + + public string detailed_action_name { get; construct; } + + public ListModel popup_keys { get; construct; } + + public string? label { get; construct; } + public Icon? icon { get; construct; } + + public Key (double left_offset, double width, double height, string detailed_action_name, ListModel popup_keys, string? label, Icon? icon) { + Object ( + left_offset: left_offset, + width: width, + height: height, + detailed_action_name: detailed_action_name, + popup_keys: popup_keys, + label: label, + icon: icon + ); + } +} diff --git a/daemon/OSK/KeyboardModel/KeyboardModel.vala b/daemon/OSK/KeyboardModel/KeyboardModel.vala new file mode 100644 index 000000000..b0241de22 --- /dev/null +++ b/daemon/OSK/KeyboardModel/KeyboardModel.vala @@ -0,0 +1,36 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.KeyboardModel : Object { + public ListModel views { get; construct; } + + public KeyboardModel (ListModel views) { + Object (views: views); + } + + public KeyboardView? get_view_by_name (string name) { + for (uint i = 0; i < views.get_n_items (); i++) { + var view = (KeyboardView) views.get_item (i); + if (view.name == name) { + return view; + } + } + + return null; + } + + public KeyboardView? find_default_view () { + for (uint i = 0; i < views.get_n_items (); i++) { + var view = (KeyboardView) views.get_item (i); + if (view.is_default) { + return view; + } + } + + return (KeyboardView?) views.get_item (0); + } +} diff --git a/daemon/OSK/KeyboardModel/KeyboardModelBuilder.vala b/daemon/OSK/KeyboardModel/KeyboardModelBuilder.vala new file mode 100644 index 000000000..1f8cc1319 --- /dev/null +++ b/daemon/OSK/KeyboardModel/KeyboardModelBuilder.vala @@ -0,0 +1,143 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.KeyboardModelBuilder : Object { + private KeyboardModel model; + private ListStore views_store; + + private string? current_view_name; + private ListStore? current_view_store; + private bool current_view_is_default = false; + + private ListStore? current_row; + + private double current_key_left_offset = 0.0; + private double current_key_width = 1.0; + private double current_key_height = 1.0; + private string? current_key_detailed_action_name; // Mandatory to set + private ListStore? current_key_popup_keys; + private string? current_key_label; + private Icon? current_key_icon; + + construct { + views_store = new ListStore (typeof (KeyboardView)); + model = new KeyboardModel (views_store); + } + + public KeyboardModel end () { + return model; + } + + public void begin_view (string name) requires (current_view_name == null && current_view_store == null) { + current_view_name = name; + current_view_store = new ListStore (typeof (ListStore)); + } + + public void set_view_default () requires (current_view_name != null && current_view_store != null) { + current_view_is_default = true; + } + + public void end_view () requires (current_view_name != null && current_view_store != null) { + var view = new KeyboardView (current_view_name, current_view_store, current_view_is_default); + views_store.append (view); + + + current_view_name = null; + current_view_store = null; + current_view_is_default = false; + } + + public void begin_row () requires (current_view_store != null && current_row == null) { + current_row = new ListStore (typeof (Key)); + } + + public void end_row () requires (current_view_store != null && current_row != null) { + current_view_store.append (current_row); + current_row = null; + } + + public void begin_key () requires (current_row != null && current_key_detailed_action_name == null) { + current_key_popup_keys = new ListStore (typeof (Key)); + } + + public void set_key_left_offset (double left_offset) { + current_key_left_offset = left_offset; + } + + public void set_key_width (double width) { + current_key_width = width; + } + + public void set_key_height (double height) { + current_key_height = height; + } + + public void set_key_val_action (uint val) { + current_key_detailed_action_name = Action.print_detailed_name (Key.ACTION_PREFIX + Key.ACTION_TYPE_KEY_VAL, new Variant.uint32 (val)); + } + + public void set_erase_action () { + current_key_detailed_action_name = Action.print_detailed_name (Key.ACTION_PREFIX + Key.ACTION_ERASE, null); + } + + public void set_latch_view_action (string view_name) { + current_key_detailed_action_name = Action.print_detailed_name (Key.ACTION_PREFIX + Key.ACTION_LATCH_VIEW, new Variant.string (view_name)); + } + + public void set_set_view_action (string view_name) { + current_key_detailed_action_name = Action.print_detailed_name (Key.ACTION_PREFIX + Key.ACTION_SET_VIEW, new Variant.string (view_name)); + } + + public void set_key_label (string label) { + current_key_label = label; + } + + public void set_key_icon (Icon icon) { + current_key_icon = icon; + } + + public void set_key_icon_name (string icon_name) { + current_key_icon = new ThemedIcon (icon_name); + } + + public void add_popup_key (string popup_key_string) requires (current_key_popup_keys != null) { + // var popup_key = new Key ( + // 1.0f, + // 1.0f, + // ACTION_PREFIX + "popup." + popup_key_string, + // null, + // popup_key_string, + // null + // ); + // current_key_popup_keys.append (popup_key); + } + + public void end_key () requires (current_row != null) { + if (current_key_label != null && current_key_icon != null) { + critical ("A key should have at least an icon or label."); + } + + var key = new Key ( + current_key_left_offset, + current_key_width, + current_key_height, + current_key_detailed_action_name ?? "none", + current_key_popup_keys, + current_key_label, + current_key_icon + ); + current_row.append (key); + + current_key_left_offset = 0.0; + current_key_width = 1.0; + current_key_height = 1.0; + current_key_detailed_action_name = null; + current_key_popup_keys = null; + current_key_label = null; + current_key_icon = null; + } +} diff --git a/daemon/OSK/KeyboardModel/KeyboardView.vala b/daemon/OSK/KeyboardModel/KeyboardView.vala new file mode 100644 index 000000000..e0f8ac2d0 --- /dev/null +++ b/daemon/OSK/KeyboardModel/KeyboardView.vala @@ -0,0 +1,16 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.KeyboardView : Object { + public string name { get; construct; } + public ListModel rows { get; construct; } + public bool is_default { get; construct; } + + public KeyboardView (string name, ListModel rows, bool is_default = false) { + Object (name: name, rows: rows, is_default: is_default); + } +} diff --git a/daemon/OSK/ModelManager.vala b/daemon/OSK/ModelManager.vala new file mode 100644 index 000000000..8ac70a018 --- /dev/null +++ b/daemon/OSK/ModelManager.vala @@ -0,0 +1,103 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.ModelManager : Object { + public OSKService service { private get; construct; } + + public KeyboardModel? current_model { get; private set; } + + private Settings settings; + + public ModelManager (OSKService service) { + Object (service: service); + } + + construct { + settings = new Settings ("org.gnome.desktop.input-sources"); + settings.changed["current"].connect (update_model); + settings.changed["sources"].connect (update_model); + + update_model.begin (); + } + + private async void update_model () { + var input_sources = settings.get_value ("sources"); + + var current_source_index = settings.get_uint ("current"); + + var current_source = input_sources.get_child_value (current_source_index); + + string type, id; + current_source.get ("(ss)", out type, out id); + + var group_name = get_group_name (type, id); + + var eligible_models = get_eligible_models (group_name); + + foreach (var model in eligible_models) { + warning ("Load keyboard model: %s", model); + if (yield load_model (model)) { + return; + } + } + } + + private string get_group_name (string type, string id) { + return id; + } + + private string[] get_eligible_models (string current_group_name) { + switch (service.osk_input_purpose) { + case DIGITS: + return { "digits" }; + case NUMBER: + return { "number" }; + case PHONE: + return { "phone" }; + case EMAIL: + return { "email" }; + case URL: + return { "url" }; + + default: + break; + } + + string[] groups = { current_group_name }; + + if ("+" in current_group_name) { + try { + groups += (/\+.*/).replace (current_group_name, current_group_name.length, 0, ""); + } catch (Error e) { + warning ("Failed to parse group name: %s", e.message); + } + } + + groups += "us"; + + if (service.osk_input_purpose == TERMINAL) { + for (int i = 0; i < groups.length; i++) { + groups[i] += "-extended"; + } + } + + return groups; + } + + private async bool load_model (string name) { + var file = File.new_for_path ("/home/leonhard/Projects/gnome-shell/data/osk-layouts/%s.json".printf (name)); + var parser = new GnomeOSKParser (); + + try { + current_model = yield parser.parse (file); + return true; + } catch (Error e) { + warning ("Failed to load keyboard model: %s", e.message); + return false; + } + } +} diff --git a/daemon/OSK/OSKManager.vala b/daemon/OSK/OSKManager.vala new file mode 100644 index 000000000..08d8ee07e --- /dev/null +++ b/daemon/OSK/OSKManager.vala @@ -0,0 +1,49 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.OSKManager : Object { + public IBusService ibus_service { private get; construct; } + + private OSKService osk_service; + private OSKWindow? osk_window; + + public OSKManager (IBusService ibus_service) { + Object (ibus_service: ibus_service); + } + + construct { + osk_service = new OSKService (); + osk_service.notify["osk-enabled"].connect (on_osk_enabled_changed); + + Bus.own_name (SESSION, "io.elementary.OSK", NONE, null, on_name_acquired); + } + + private void on_name_acquired (DBusConnection connection, string name) { + try { + connection.register_object ("/io/elementary/OSK", osk_service); + } catch (Error e) { + warning ("Failed to get D-Bus session bus: %s", e.message); + } + } + + private void on_osk_enabled_changed () { + /* If the OSK is active we show the candidates directly in the OSK so disable the popup */ + ibus_service.disable_popup = osk_service.osk_enabled; + + if (!osk_service.osk_enabled) { + osk_window?.destroy (); + osk_window = null; + return; + } + + var model_manager = new ModelManager (osk_service); + var input_manager = new InputManager (osk_service); + + osk_window = new OSKWindow (model_manager, input_manager, ibus_service); + osk_window.present (); + } +} diff --git a/daemon/OSK/OSKService.vala b/daemon/OSK/OSKService.vala new file mode 100644 index 000000000..af25cfab1 --- /dev/null +++ b/daemon/OSK/OSKService.vala @@ -0,0 +1,24 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +[DBus (name = "io.elementary.OSK")] +public class Gala.Daemon.OSKService : Object { + public signal void keyval_pressed (uint keyval); + public signal void keyval_released (uint keyval); + + internal bool osk_enabled { get; private set; } + internal IBus.InputPurpose osk_input_purpose { get; private set; } + + public void set_enabled (bool enabled) throws DBusError, IOError { + warning ("Set enabled"); + osk_enabled = enabled; + } + + public void set_input_purpose (IBus.InputPurpose input_purpose) throws DBusError, IOError { + osk_input_purpose = input_purpose; + } +} diff --git a/daemon/OSK/Parsers/GnomeOSKParser.vala b/daemon/OSK/Parsers/GnomeOSKParser.vala new file mode 100644 index 000000000..313f1caf3 --- /dev/null +++ b/daemon/OSK/Parsers/GnomeOSKParser.vala @@ -0,0 +1,156 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.GnomeOSKParser : Object { + private HashTable level_modes = new HashTable (str_hash, str_equal); + + public async KeyboardModel parse (File file) throws Error { + var stream = yield file.read_async (Priority.DEFAULT); + + var parser = new Json.Parser (); + yield parser.load_from_stream_async (stream, null); + + var root = parser.get_root ().get_object (); + var builder = new KeyboardModelBuilder (); + + var levels = root.get_array_member ("levels"); + parse_levels (levels, builder); + + return builder.end (); + } + + private void parse_levels (Json.Array json, KeyboardModelBuilder builder) throws Error { + level_modes.remove_all (); + + for (uint i = 0; i < json.get_length (); i++) { + var level_node = json.get_object_element (i); + parse_level (level_node, builder); + } + } + + private void parse_level (Json.Object json, KeyboardModelBuilder builder) throws Error { + var level_name = json.get_string_member ("level"); + + /* For GNOME every level has a set mode (locked or latched) but for us every level can be in every mode */ + var level_mode = json.get_string_member ("mode"); + level_modes[level_name] = level_mode; + + builder.begin_view (level_name); + + if (level_mode == "default") { + builder.set_view_default (); + } + + var rows = json.get_array_member ("rows"); + for (uint i = 0; i < rows.get_length (); i++) { + var row_node = rows.get_array_element (i); + parse_row (row_node, builder); + } + + builder.end_view (); + } + + private void parse_row (Json.Array json, KeyboardModelBuilder builder) throws Error { + builder.begin_row (); + + for (uint i = 0; i < json.get_length (); i++) { + var key_node = json.get_object_element (i); + parse_key (key_node, builder); + } + + builder.end_row (); + } + + private void parse_key (Json.Object json, KeyboardModelBuilder builder) throws Error { + builder.begin_key (); + + if (json.has_member ("strings")) { + var strings = json.get_array_member ("strings"); + for (uint i = 0; i < strings.get_length (); i++) { + var str = strings.get_string_element (i); + + if (i == 0) { + builder.set_key_val_action (Gdk.unicode_to_keyval (str[0])); + builder.set_key_label (str); + } else { + builder.add_popup_key (str); + } + } + } + + if (json.has_member ("leftOffset")) { + builder.set_key_left_offset (json.get_double_member ("leftOffset")); + } + + if (json.has_member ("width")) { + builder.set_key_width (json.get_double_member ("width")); + } + + if (json.has_member ("height")) { + builder.set_key_height (json.get_double_member ("height")); + } + + if (json.has_member ("label")) { + var label = json.get_string_member ("label"); + builder.set_key_label (label); + } + + if (json.has_member ("iconName")) { + var icon_name = json.get_string_member ("iconName"); + builder.set_key_icon_name (icon_name); + } + + if (json.has_member ("keyval")) { + var keyval = json.get_string_member ("keyval"); + builder.set_key_val_action (0); + // TODO: This is in hex so convert to uint + } + + if (json.has_member ("action")) { + var action = json.get_string_member ("action"); + switch (action) { + case "delete": + builder.set_erase_action (); + break; + + case "levelSwitch": + var level = json.get_string_member ("level"); + var level_mode = level_modes[level]; + + switch (level_mode) { + case null: + builder.set_set_view_action (level); + break; + + case "latched": + builder.set_latch_view_action (level); + break; + + case "default": + case "locked": + builder.set_set_view_action (level); + break; + } + break; + + case "emoji": + // TODO + break; + + case "languageMenu": + // TODO + break; + + case "hide": + // TODO + break; + } + } + + builder.end_key (); + } +} diff --git a/daemon/OSK/Window/Keyboard/KeyButton.vala b/daemon/OSK/Window/Keyboard/KeyButton.vala new file mode 100644 index 000000000..a8121cd37 --- /dev/null +++ b/daemon/OSK/Window/Keyboard/KeyButton.vala @@ -0,0 +1,29 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.KeyButton : Granite.Bin { + public Key key { + set { + if (value.label != null) { + button.label = value.label; + } else if (value.icon != null) { + button.child = new Gtk.Image.from_gicon (value.icon); + } else { + button.label = _("Unknown Key"); + } + + button.set_detailed_action_name (value.detailed_action_name); + } + } + + private Gtk.Button button; + + construct { + button = new Gtk.Button (); + child = button; + } +} diff --git a/daemon/OSK/Window/Keyboard/Keyboard.vala b/daemon/OSK/Window/Keyboard/Keyboard.vala new file mode 100644 index 000000000..03711cf0c --- /dev/null +++ b/daemon/OSK/Window/Keyboard/Keyboard.vala @@ -0,0 +1,95 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.Keyboard : Granite.Bin { + private const ActionEntry [] ACTIONS = { + { Key.ACTION_TYPE_KEY_VAL, on_type_key_val, "u" }, + { Key.ACTION_ERASE, on_erase }, + { Key.ACTION_SET_VIEW, on_set_view, "s" }, + { Key.ACTION_LATCH_VIEW, on_latch_view, "s" }, + }; + + public ModelManager model_manager { get; construct; } + public InputManager input_manager { get; construct; } + + private ViewContainer view_container; + + private KeyboardView? current_view; + /* Can be the same as current_view or a different one if another view is latched */ + private KeyboardView? _active_view; + private KeyboardView? active_view { + get { return _active_view; } + set { + _active_view = value; + + view_container.view = value?.rows; + } + } + + public Keyboard (ModelManager model_manager, InputManager input_manager) { + Object (model_manager: model_manager, input_manager: input_manager); + } + + construct { + view_container = new ViewContainer (); + child = view_container; + vexpand = true; + + var action_group = new SimpleActionGroup (); + action_group.add_action_entries (ACTIONS, this); + + insert_action_group (Key.ACTION_GROUP_PREFIX, action_group); + + model_manager.notify["current-model"].connect (on_current_model_changed); + } + + private void on_current_model_changed () { + current_view = model_manager.current_model?.find_default_view (); + active_view = current_view; + } + + private void on_type_key_val (SimpleAction action, Variant? param) { + var keyval = (uint) param.get_uint32 (); + + input_manager.send_keyval (keyval); + + if (active_view != current_view) { + /* Reset a latched view */ + active_view = current_view; + } + } + + private void on_erase () { + input_manager.erase (); + } + + private void on_set_view (SimpleAction action, Variant? param) { + var view_name = param.get_string (); + var view = model_manager.current_model.get_view_by_name (view_name); + + if (view == null) { + warning ("Tried to set view to '%s' but no such view exists", view_name); + return; + } + + current_view = view; + active_view = view; + } + + private void on_latch_view (SimpleAction action, Variant? param) { + var view_name = param.get_string (); + + var view = model_manager.current_model.get_view_by_name (view_name); + + if (view == null) { + warning ("Tried to latch view to '%s' but no such view exists", view_name); + return; + } + + active_view = view; + } +} diff --git a/daemon/OSK/Window/Keyboard/ViewContainer.vala b/daemon/OSK/Window/Keyboard/ViewContainer.vala new file mode 100644 index 000000000..e07e0ad46 --- /dev/null +++ b/daemon/OSK/Window/Keyboard/ViewContainer.vala @@ -0,0 +1,78 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.ViewContainer : Granite.Bin { + private const int KEY_SIZE = 2; + + public ListModel view { set { update_keys (value); } } + + private Gtk.Grid grid; + private Gtk.AspectFrame aspect_frame; + + construct { + grid = new Gtk.Grid () { + row_homogeneous = true, + column_homogeneous = true, + row_spacing = 4, + column_spacing = 4, + }; + + aspect_frame = new Gtk.AspectFrame (0.5f, 0.5f, 1.0f, false) { + child = grid, + margin_top = 6, + margin_bottom = 6, + margin_start = 6, + margin_end = 6, + }; + + child = aspect_frame; + } + + private void update_keys (ListModel rows) { + while (grid.get_first_child () != null) { + grid.remove (grid.get_first_child ()); + } + + int max_row_width = 0; + int current_row = 0; + + for (int i = 0; i < rows.get_n_items (); i++) { + var row = (ListModel) rows.get_item (i); + + int row_width, row_height; + attach_row (current_row, row, out row_width, out row_height); + + max_row_width = int.max (max_row_width, row_width); + current_row += row_height; + } + + aspect_frame.ratio = (float) max_row_width / (float) current_row; + } + + private void attach_row (int index, ListModel row, out int row_width, out int row_height) { + row_width = 0; + row_height = 0; + + for (int i = 0; i < row.get_n_items (); i++) { + var key = (Key) row.get_item (i); + + var key_button = new KeyButton () { + key = key, + }; + + row_width += (int) (key.left_offset * KEY_SIZE); + + var width = (int) (key.width * KEY_SIZE); + var height = (int) (key.height * KEY_SIZE); + + grid.attach (key_button, row_width, index, width, height); + + row_width += width; + row_height = int.max (row_height, height); + } + } +} diff --git a/daemon/OSK/Window/OSKWindow.vala b/daemon/OSK/Window/OSKWindow.vala new file mode 100644 index 000000000..85aa0fa3f --- /dev/null +++ b/daemon/OSK/Window/OSKWindow.vala @@ -0,0 +1,41 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.OSKWindow : Gtk.Window { + public ModelManager model_manager { get; construct; } + public InputManager input_manager { get; construct; } + public IBusService ibus_service { get; construct; } + + public OSKWindow (ModelManager model_manager, InputManager input_manager, IBusService ibus_service) { + Object (model_manager: model_manager, input_manager: input_manager, ibus_service: ibus_service); + } + + construct { + var suggestions = new Suggestions (ibus_service); + + var keyboard = new Keyboard (model_manager, input_manager); + + var box = new Granite.Box (VERTICAL); + box.append (suggestions); + box.append (keyboard); + + child = box; + titlebar = new Gtk.Grid () { visible = false }; + title = "OSK"; + + ((Gtk.Widget) this).realize.connect (update_size); + } + + private void update_size () { + var display = Gdk.Display.get_default (); + var monitor = display.get_monitor_at_surface (get_surface ()); + var monitor_geom = monitor.geometry; + + default_width = monitor_geom.width; + default_height = monitor_geom.height / 3; + } +} diff --git a/daemon/OSK/Window/Suggestions.vala b/daemon/OSK/Window/Suggestions.vala new file mode 100644 index 000000000..7ba0d6244 --- /dev/null +++ b/daemon/OSK/Window/Suggestions.vala @@ -0,0 +1,41 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.Suggestions : Granite.Bin { + public IBusService ibus_service { private get; construct; } + + public Suggestions (IBusService ibus_service) { + Object (ibus_service: ibus_service); + } + + construct { + var selection_model = new Gtk.NoSelection (ibus_service.candidates); + + var factory = new Gtk.SignalListItemFactory (); + factory.setup.connect (on_setup); + factory.bind.connect (on_bind); + + var list_view = new Gtk.ListView (selection_model, factory) { + orientation = HORIZONTAL, + }; + child = list_view; + halign = CENTER; + } + + private void on_setup (Object obj) { + var item = (Gtk.ListItem) obj; + item.child = new CandidateBox (ibus_service.service, item); + } + + private void on_bind (Object obj) { + var item = (Gtk.ListItem) obj; + var candidate = (Candidate) item.item; + + var box = (CandidateBox) item.child; + box.set_candidate (candidate); + } +} diff --git a/daemon/meson.build b/daemon/meson.build index 671161ebb..465ddb59c 100644 --- a/daemon/meson.build +++ b/daemon/meson.build @@ -5,10 +5,31 @@ gala_daemon_sources = files( 'MonitorLabel.vala', 'Window.vala', 'WindowMenu.vala', + 'IBus' / 'Candidate.vala', + 'IBus' / 'CandidateArea.vala', + 'IBus' / 'CandidateBox.vala', + 'IBus' / 'IBusService.vala', + 'IBus' / 'IBusCandidateWindow.vala', + 'OSK' / 'InputManager.vala', + 'OSK' / 'ModelManager.vala', + 'OSK' / 'OSKManager.vala', + 'OSK' / 'OSKService.vala', + 'OSK' / 'KeyboardModel' / 'Key.vala', + 'OSK' / 'KeyboardModel' / 'KeyboardModel.vala', + 'OSK' / 'KeyboardModel' / 'KeyboardModelBuilder.vala', + 'OSK' / 'KeyboardModel' / 'KeyboardView.vala', + 'OSK' / 'Parsers' / 'GnomeOSKParser.vala', + 'OSK' / 'Window' / 'OSKWindow.vala', + 'OSK' / 'Window' / 'Suggestions.vala', + 'OSK' / 'Window' / 'Keyboard' / 'Keyboard.vala', + 'OSK' / 'Window' / 'Keyboard' / 'KeyButton.vala', + 'OSK' / 'Window' / 'Keyboard' / 'ViewContainer.vala', ) +adw_dep = dependency('libadwaita-1') gtk4_dep = dependency('gtk4') granite7_dep = dependency('granite-7') +json_dep = dependency('json-glib-1.0') executable( 'gala-daemon', @@ -16,6 +37,6 @@ executable( gala_common_enums, config_header, gala_resources, - dependencies: [gtk4_dep, granite7_dep], + dependencies: [adw_dep, gtk4_dep, granite7_dep, ibus_dep, json_dep], install: true ) diff --git a/data/gala-daemon.css b/data/gala-daemon.css index 43a0f9eb1..bdb0fb1eb 100644 --- a/data/gala-daemon.css +++ b/data/gala-daemon.css @@ -16,3 +16,13 @@ daemon-window { margin: 1em; text-shadow: 0 1px 1px alpha(white, 0.1); } + +listview { + background: none; + border-spacing: 3px; +} + +listview > row { + padding: 3px; + border-radius: 4px; +} diff --git a/docs/meson.build b/docs/meson.build index 130ccc20d..4e0e537ce 100644 --- a/docs/meson.build +++ b/docs/meson.build @@ -43,6 +43,7 @@ all_doc_target = custom_target( '--pkg', 'atk-bridge-2.0', '--pkg', 'gnome-bg-4', '--pkg', 'gnome-desktop-4', + '--pkg', 'ibus-1.0', '--pkg', 'libsystemd', '--pkg', 'wayland-server', '--pkg', 'pantheon-desktop-shell', diff --git a/meson.build b/meson.build index 0e78f370d..bfd8e366a 100644 --- a/meson.build +++ b/meson.build @@ -70,6 +70,7 @@ gio_unix_dep = dependency('gio-unix-2.0', version: '>= @0@'.format(glib_version_ gmodule_dep = dependency('gmodule-2.0') gee_dep = dependency('gee-0.8') gnome_desktop_dep = dependency('gnome-desktop-4') +ibus_dep = dependency('ibus-1.0') gnome_bg_dep = dependency('gnome-bg-4') m_dep = cc.find_library('m', required: false) posix_dep = vala.find_library('posix', required: false) @@ -171,7 +172,7 @@ endif add_project_arguments(vala_flags, language: 'vala') add_project_link_arguments(['-Wl,-rpath,@0@'.format(mutter_typelib_dir)], language: 'c') -gala_base_dep = [atk_bridge_dep, gdk_pixbuf_def, gtk4_dep, glib_dep, gobject_dep, gio_dep, gio_unix_dep, gmodule_dep, gee_dep, mutter_dep, gnome_desktop_dep, gnome_bg_dep, m_dep, posix_dep, sqlite3_dep, xext_dep] +gala_base_dep = [atk_bridge_dep, gdk_pixbuf_def, gtk4_dep, glib_dep, gobject_dep, gio_dep, gio_unix_dep, gmodule_dep, gee_dep, mutter_dep, gnome_desktop_dep, gnome_bg_dep, ibus_dep, m_dep, posix_dep, sqlite3_dep, xext_dep] if get_option('systemd') gala_base_dep += systemd_dep diff --git a/src/DaemonManager.vala b/src/DaemonManager.vala index 7303efca3..02ecd7ba1 100644 --- a/src/DaemonManager.vala +++ b/src/DaemonManager.vala @@ -6,8 +6,8 @@ */ public class Gala.DaemonManager : GLib.Object { - private const string DAEMON_DBUS_NAME = "org.pantheon.gala.daemon"; - private const string DAEMON_DBUS_OBJECT_PATH = "/org/pantheon/gala/daemon"; + public const string DAEMON_DBUS_NAME = "org.pantheon.gala.daemon"; + public const string DAEMON_DBUS_OBJECT_PATH = "/org/pantheon/gala/daemon"; private const int SPACING = 12; [DBus (name = "org.pantheon.gala.daemon")] @@ -32,7 +32,12 @@ public class Gala.DaemonManager : GLib.Object { client = new ManagedClient (display, args); client.window_created.connect ((window) => { - window.shown.connect (handle_daemon_window); +#if HAS_MUTTER49 + window.set_type (DOCK); +#elif HAS_MUTTER46 + client.wayland_client.make_dock (window); +#endif + window.notify["title"].connect ((obj, pspec) => handle_daemon_window ((Meta.Window) obj)); }); } @@ -71,6 +76,14 @@ public class Gala.DaemonManager : GLib.Object { window.make_above (); window.stick (); break; + + case "IBUS_CANDIDATE": + ShellClientsManager.get_instance ().make_ibus_candidate_window (window); + break; + + case "OSK": + ShellClientsManager.get_instance ().make_osk_window (window); + break; } } diff --git a/src/InputMethod.vala b/src/InputMethod.vala new file mode 100644 index 000000000..b633e7b36 --- /dev/null +++ b/src/InputMethod.vala @@ -0,0 +1,318 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.InputMethod : Clutter.InputMethod { + public Meta.Display display { private get; construct; } + public Graphene.Rect cursor_location { get; private set; } + + private IBus.Bus bus; + private IBus.InputContext? context; + + private bool preedit_visible; + private string? preedit_text; + private uint preedit_cursor; + private uint preedit_anchor; + private Clutter.PreeditResetMode preedit_mode; + + private string? surrounding_text; + private uint surrounding_cursor; + private uint surrounding_anchor; + + private IBus.InputPurpose input_purpose; + private IBus.InputHints input_hints; + + public InputMethod (Meta.Display display) { + Object (display: display); + } + + construct { + IBus.init (); + + bus = new IBus.Bus.async (); + bus.connected.connect (on_connected); + + if (bus.is_connected ()) { + on_connected (); + } + } + + private void on_connected () { + bus.create_input_context_async.begin ("gala", -1, null, on_input_context_created); + } + + private void on_input_context_created (Object? obj, AsyncResult res) { + try { + context = bus.create_input_context_async_finish (res); + } catch (Error e) { + warning ("Failed to create IBus input context: %s", e.message); + return; + } + + context.commit_text.connect (on_commit_text); + context.require_surrounding_text.connect (on_require_surrounding_text); + context.delete_surrounding_text.connect (on_delete_surrounding_text); + context.update_preedit_text.connect (on_update_preedit_text); + context.update_preedit_text_with_mode.connect (on_update_preedit_text_with_mode); + context.show_preedit_text.connect (on_show_preedit_text); + context.hide_preedit_text.connect (on_hide_preedit_text); + context.forward_key_event.connect (on_forward_key_event); + context.destroy.connect (on_destroy); + + update_capabilities (); + } + + private void update_capabilities () { + IBus.Capabilite caps = PREEDIT_TEXT | FOCUS; + + if (surrounding_text != null) { + caps |= SURROUNDING_TEXT; + } + + context?.set_capabilities (caps); + } + + private void on_commit_text (IBus.Text text) { + commit (text.text); + } + + private void on_require_surrounding_text () { + request_surrounding (); + } + + private void on_delete_surrounding_text (int offset, uint length) { + delete_surrounding (offset, length); + } + + private void on_update_preedit_text (IBus.Text text, uint cursor_pos, bool visible) { + on_update_preedit_text_with_mode (text, cursor_pos, visible, preedit_mode); + } + + private void on_update_preedit_text_with_mode (IBus.Text text, uint cursor_pos, bool visible, uint mode) { + var preedit = text.text; + + if (preedit == "") { + preedit = null; + } + + var anchor = cursor_pos; + + if (visible) { + set_preedit_text (preedit, cursor_pos, anchor, mode); + } else if (preedit_visible) { + set_preedit_text (null, cursor_pos, anchor, mode); + } + + preedit_visible = visible; + preedit_text = preedit; + preedit_cursor = cursor_pos; + preedit_anchor = anchor; + preedit_mode = (Clutter.PreeditResetMode) mode; + } + + private void on_show_preedit_text () { + preedit_visible = true; + set_preedit_text (preedit_text, preedit_cursor, preedit_anchor, preedit_mode); + } + + private void on_hide_preedit_text () { + set_preedit_text (null, preedit_cursor, preedit_anchor, preedit_mode); + preedit_visible = false; + } + + private void on_forward_key_event (uint keyval, uint keycode, uint _modifiers) { + var modifiers = (IBus.ModifierType) _modifiers; + var press = !(IBus.ModifierType.RELEASE_MASK in modifiers); + modifiers &= ~IBus.ModifierType.RELEASE_MASK; + + var time = display.get_current_time (); + + forward_key (keyval, keycode + 8, modifiers & Clutter.ModifierType.MODIFIER_MASK, time, press); + } + + private void on_destroy () { + debug ("IBus input context was destroyed"); + context = null; + } + + private void maybe_request_surrounding () { + if (context != null && context.needs_surrounding_text ()) { + request_surrounding (); + } + } + + public override void focus_in (Clutter.InputFocus actor) { + update_capabilities (); + context?.set_content_type (input_purpose, input_hints); + maybe_request_surrounding (); + + context?.focus_in (); + } + + public override void focus_out () { + context?.set_content_type (0, 0); + context?.reset (); + + context?.focus_out (); + + if (preedit_visible) { + set_preedit_text (null, preedit_cursor, preedit_anchor, preedit_mode); + preedit_text = null; + } + + set_input_panel_state (OFF); + } + + public override void reset () { + context?.reset (); + maybe_request_surrounding (); + + surrounding_text = null; + surrounding_cursor = 0; + surrounding_anchor = 0; + preedit_text = null; + } + + public override void set_cursor_location (Graphene.Rect rect) { + context?.set_cursor_location ((int) rect.origin.x, (int) rect.origin.y, (int) rect.size.width, (int) rect.size.height); + cursor_location = rect; + } + + public override void set_surrounding (string text, uint cursor_index, uint anchor_index) { + var update_caps = (surrounding_text == null) != (text == null); + + surrounding_text = text; + surrounding_cursor = cursor_index; + surrounding_anchor = anchor_index; + + if (update_caps) { + update_capabilities (); + } + + if (text == null) { + return; + } + + var ibus_text = new IBus.Text.from_string (text); + context?.set_surrounding_text (ibus_text, cursor_index, anchor_index); + } + + public override bool filter_key_event (Clutter.Event event) { + if (context == null) { + return false; + } + + var state = (IBus.ModifierType) event.get_state (); + + if (IBus.ModifierType.IGNORED_MASK in state) { + return false; + } + + if (event.get_type () == Clutter.EventType.KEY_RELEASE) { + state |= IBus.ModifierType.RELEASE_MASK; + } + + context.process_key_event_async.begin ( + event.get_key_symbol (), event.get_key_code () - 8, state, -1, null, + (obj, res) => { + try { + var handled = context.process_key_event_async_finish (res); + notify_key_event (event, handled); + } catch (Error e) { + warning ("Failed to process key event on IM: %s", e.message); + } + } + ); + + return true; + } + + public override void update_content_hints (Clutter.InputContentHintFlags hints) { + IBus.InputHints ibus_hints = 0; + + if (COMPLETION in hints) { + ibus_hints |= IBus.InputHints.WORD_COMPLETION; + } + + if (SPELLCHECK in hints) { + ibus_hints |= IBus.InputHints.SPELLCHECK; + } + + if (AUTO_CAPITALIZATION in hints) { + ibus_hints |= IBus.InputHints.UPPERCASE_SENTENCES; + } + + if (LOWERCASE in hints) { + ibus_hints |= IBus.InputHints.LOWERCASE; + } + + if (UPPERCASE in hints) { + ibus_hints |= IBus.InputHints.UPPERCASE_CHARS; + } + + if (TITLECASE in hints) { + ibus_hints |= IBus.InputHints.UPPERCASE_WORDS; + } + + if (SENSITIVE_DATA in hints) { + ibus_hints |= IBus.InputHints.PRIVATE; + } + + if (HIDDEN_TEXT in hints) { + // TODO: Probably needs a newer version + // ibus_hints |= IBus.InputHints.HIDDEN_TEXT; + } + + input_hints = ibus_hints; + + context?.set_content_type (input_purpose, input_hints); + } + + public override void update_content_purpose (Clutter.InputContentPurpose purpose) { + IBus.InputPurpose ibus_purpose; + + switch (purpose) { + case NORMAL: + ibus_purpose = FREE_FORM; + break; + case ALPHA: + ibus_purpose = ALPHA; + break; + case DIGITS: + ibus_purpose = DIGITS; + break; + case NUMBER: + ibus_purpose = NUMBER; + break; + case PHONE: + ibus_purpose = PHONE; + break; + case URL: + ibus_purpose = URL; + break; + case EMAIL: + ibus_purpose = EMAIL; + break; + case NAME: + ibus_purpose = NAME; + break; + case PASSWORD: + ibus_purpose = PASSWORD; + break; + case TERMINAL: + ibus_purpose = TERMINAL; + break; + default: + warning ("Unknown input purpose: %d", purpose); + ibus_purpose = FREE_FORM; + break; + } + + input_purpose = ibus_purpose; + + context?.set_content_type (input_purpose, input_hints); + } +} diff --git a/src/OSK/OSKManager.vala b/src/OSK/OSKManager.vala new file mode 100644 index 000000000..703a3e884 --- /dev/null +++ b/src/OSK/OSKManager.vala @@ -0,0 +1,79 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +/** + * Handles enabling/disabling and showing/hiding the on-screen keyboard (OSK). + */ +public class Gala.OSKManager : Object { + private const string OSK_BUS_NAME = "io.elementary.OSK"; + private const string OSK_OBJECT_PATH = "/io/elementary/OSK"; + + public Meta.Display display { private get; construct; } + public InputMethod im { private get; construct; } + + public int monitor { get; private set; default = 0; } + public bool visible { get; private set; default = false; } + + private OSKProxy? osk; + private OSKReceiver? receiver; + + private bool enabled = false; + + public OSKManager (Meta.Display display, InputMethod im) { + Object (display: display, im: im); + } + + construct { + Bus.watch_name (SESSION, OSK_BUS_NAME, NONE, () => osk_appeared.begin (), osk_lost); + + im.input_panel_state.connect (on_input_panel_state_changed); + + sync_enabled (); + } + + private async void osk_appeared () { + try { + osk = yield Bus.get_proxy (SESSION, OSK_BUS_NAME, OSK_OBJECT_PATH); + } catch (Error e) { + warning ("Failed to get OSK proxy: %s", e.message); + return; + } + + receiver = new OSKReceiver (display, osk, im); + + osk.set_enabled.begin (enabled); + } + + private void osk_lost () { + osk = null; + receiver = null; + } + + private void sync_enabled () { + enabled = true; + + if (osk != null) { + osk.set_enabled.begin (enabled); + } + } + + private void on_input_panel_state_changed (Clutter.InputPanelState state) { + switch (state) { + case ON: + visible = true; + break; + + case OFF: + visible = false; + break; + + case TOGGLE: + visible = !visible; + break; + } + } +} diff --git a/src/OSK/OSKProxy.vala b/src/OSK/OSKProxy.vala new file mode 100644 index 000000000..0dc4d8b81 --- /dev/null +++ b/src/OSK/OSKProxy.vala @@ -0,0 +1,14 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +[DBus (name = "io.elementary.OSK")] +public interface Gala.OSKProxy : Object { + public signal void keyval_pressed (uint keyval); + public signal void keyval_released (uint keyval); + + public async abstract void set_enabled (bool enabled) throws DBusError, IOError; +} diff --git a/src/OSK/OSKReceiver.vala b/src/OSK/OSKReceiver.vala new file mode 100644 index 000000000..5f1e83148 --- /dev/null +++ b/src/OSK/OSKReceiver.vala @@ -0,0 +1,38 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +/** + * Once the OSK is enabled the Manager creates a receiver which handles the input received from + * the OSK and forwards it. + */ +public class Gala.OSKReceiver : Object { + public Meta.Display display { private get; construct; } + public OSKProxy osk { private get; construct; } + public InputMethod im { private get; construct; } + + private Clutter.VirtualInputDevice virtual_device; + + public OSKReceiver (Meta.Display display, OSKProxy osk, InputMethod im) { + Object (display: display, osk: osk, im: im); + } + + construct { + var seat = Clutter.get_default_backend ().get_default_seat (); + virtual_device = seat.create_virtual_device (KEYBOARD_DEVICE); + + osk.keyval_pressed.connect (on_keyval_pressed); + osk.keyval_released.connect (on_keyval_released); + } + + private void on_keyval_pressed (uint keyval) { + virtual_device.notify_keyval (Clutter.get_current_event_time () * 1000, keyval, PRESSED); + } + + private void on_keyval_released (uint keyval) { + virtual_device.notify_keyval (Clutter.get_current_event_time () * 1000, keyval, RELEASED); + } +} diff --git a/src/ShellClients/IBusCandidateWindow.vala b/src/ShellClients/IBusCandidateWindow.vala new file mode 100644 index 000000000..2e8c73d3a --- /dev/null +++ b/src/ShellClients/IBusCandidateWindow.vala @@ -0,0 +1,23 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.IBusCandidateWindow : PositionedWindow { + public InputMethod im { get; construct; } + + public IBusCandidateWindow (InputMethod im, Meta.Window window) { + Object (im: im, window: window); + } + + construct { + im.notify["cursor-location"].connect (position_window); + } + + protected override void get_window_position (Mtk.Rectangle window_rect, out int x, out int y) { + x = (int) (im.cursor_location.origin.x + im.cursor_location.size.width); + y = (int) (im.cursor_location.origin.y + im.cursor_location.size.height); + } +} diff --git a/src/ShellClients/OSKWindow.vala b/src/ShellClients/OSKWindow.vala new file mode 100644 index 000000000..7a0d2f048 --- /dev/null +++ b/src/ShellClients/OSKWindow.vala @@ -0,0 +1,54 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.OSKWindow : ShellWindow, RootTarget { + public OSKManager manager { private get; construct; } + + public Clutter.Actor? actor { get { return (Clutter.Actor) window.get_compositor_private (); } } + + private GestureController gesture_controller; + + public OSKWindow (OSKManager manager, Meta.Window window) { + Object (manager: manager, window: window); + } + + construct { + gesture_controller = new GestureController (CUSTOM) { + progress = 1 + }; + add_gesture_controller (gesture_controller); + + window.size_changed.connect (update_target); + window.shown.connect (update_target); + + manager.notify["visible"].connect (sync_visible); + window.shown.connect (sync_visible); + } + + private void update_target () { + var actor = (Clutter.Actor) window.get_compositor_private (); + hide_target = new PropertyTarget (CUSTOM, actor, "translation-y", typeof (float), 0f, actor.height); + } + + private void sync_visible () { + if (manager.visible) { + gesture_controller.goto (0); + } else { + gesture_controller.goto (1); + } + } + + protected override double get_hidden_progress () { + return gesture_controller.progress; + } + + protected override void get_window_position (Mtk.Rectangle window_rect, out int x, out int y) { + var monitor_geom = window.display.get_monitor_geometry (manager.monitor); + x = monitor_geom.x + (monitor_geom.width - window_rect.width) / 2; + y = monitor_geom.y + monitor_geom.height - window_rect.height; + } +} diff --git a/src/ShellClients/ShellClientsManager.vala b/src/ShellClients/ShellClientsManager.vala index 84594cbad..ea64bcec0 100644 --- a/src/ShellClients/ShellClientsManager.vala +++ b/src/ShellClients/ShellClientsManager.vala @@ -8,12 +8,12 @@ public class Gala.ShellClientsManager : Object, GestureTarget { private static ShellClientsManager instance; - public static void init (WindowManager wm) { + public static void init (WindowManager wm, InputMethod im, OSKManager osk_manager) { if (instance != null) { return; } - instance = new ShellClientsManager (wm); + instance = new ShellClientsManager (wm, im, osk_manager); } public static unowned ShellClientsManager? get_instance () { @@ -21,6 +21,8 @@ public class Gala.ShellClientsManager : Object, GestureTarget { } public WindowManager wm { get; construct; } + public InputMethod im { get; construct; } + public OSKManager osk_manager { get; construct; } private NotificationsClient notifications_client; private ManagedClient[] protocol_clients = {}; @@ -30,9 +32,11 @@ public class Gala.ShellClientsManager : Object, GestureTarget { private GLib.HashTable panel_windows = new GLib.HashTable (null, null); private GLib.HashTable positioned_windows = new GLib.HashTable (null, null); private GLib.HashTable monitor_label_windows = new GLib.HashTable (null, null); + private IBusCandidateWindow? ibus_candidate_window = null; + private OSKWindow? osk_window = null; - private ShellClientsManager (WindowManager wm) { - Object (wm: wm); + private ShellClientsManager (WindowManager wm, InputMethod im, OSKManager osk_manager) { + Object (wm: wm, im: im, osk_manager: osk_manager); } construct { @@ -257,6 +261,18 @@ public class Gala.ShellClientsManager : Object, GestureTarget { window.unmanaging.connect_after ((_window) => monitor_label_windows.remove (_window)); } + public void make_ibus_candidate_window (Meta.Window window) requires (ibus_candidate_window == null) { + ibus_candidate_window = new IBusCandidateWindow (im, window); + + window.unmanaged.connect_after (() => ibus_candidate_window = null); + } + + public void make_osk_window (Meta.Window window) requires (osk_window == null) { + osk_window = new OSKWindow (osk_manager, window); + + window.unmanaged.connect_after (() => osk_window = null); + } + public void propagate (UpdateType update_type, GestureAction action, double progress) { foreach (var window in positioned_windows.get_values ()) { window.propagate (update_type, action, progress); @@ -272,7 +288,9 @@ public class Gala.ShellClientsManager : Object, GestureTarget { (window in positioned_windows && positioned_windows[window].modal) || (window in panel_windows) || (window in monitor_label_windows) || - NotificationStack.is_notification (window) + NotificationStack.is_notification (window) || + window == ibus_candidate_window?.window || + window == osk_window?.window ); } @@ -318,6 +336,10 @@ public class Gala.ShellClientsManager : Object, GestureTarget { return is_itself_system_modal (window) && positioned_windows[window].dim; } + public bool is_osk_window (Meta.Window window) { + return window.find_root_ancestor () == osk_window?.window; + } + //X11 only private void parse_mutter_hints (Meta.Window window) requires (!Meta.Util.is_wayland_compositor ()) { if (window.mutter_hints == null) { diff --git a/src/WindowManager.vala b/src/WindowManager.vala index a7c6a1193..cbea3fa24 100644 --- a/src/WindowManager.vala +++ b/src/WindowManager.vala @@ -51,6 +51,8 @@ namespace Gala { private Clutter.Actor menu_group { get; set; } + private Clutter.Actor osk_group; + /** * The group that contains all WindowActors that are system modal. * See {@link ShellClientsManager.is_system_modal_window}. @@ -85,6 +87,10 @@ namespace Gala { private KeyboardManager keyboard_manager; + private InputMethod input_method; + + private OSKManager osk_manager; + public WindowTracker? window_tracker { get; private set; } private WindowMover window_mover; @@ -133,7 +139,12 @@ namespace Gala { } public override void start () { - ShellClientsManager.init (this); + input_method = new InputMethod (get_display ()); + Clutter.get_default_backend ().set_input_method (input_method); + + osk_manager = new OSKManager (get_display (), input_method); + + ShellClientsManager.init (this, input_method, osk_manager); BlurManager.init (this); daemon_manager = new DaemonManager (get_display ()); @@ -232,6 +243,7 @@ namespace Gala { * +-- window overview * +-- shell group * +-- menu group + * +-- osk group * +-- modal group * +-- feedback group (e.g. DND icons) * +-- pointer locator @@ -300,6 +312,9 @@ namespace Gala { menu_group = new Clutter.Actor (); ui_group.add_child (menu_group); + osk_group = new Clutter.Actor (); + ui_group.add_child (osk_group); + modal_group = new ModalGroup (this, ShellClientsManager.get_instance ()); modal_group.add_constraint (new Clutter.BindConstraint (stage, SIZE, 0)); ui_group.add_child (modal_group); @@ -1048,6 +1063,11 @@ namespace Gala { return; } + if (ShellClientsManager.get_instance ().is_osk_window (window)) { + InternalUtils.clutter_actor_reparent (actor, osk_group); + return; + } + if (ShellClientsManager.get_instance ().is_shell_window (window)) { InternalUtils.clutter_actor_reparent (actor, shell_group); diff --git a/src/meson.build b/src/meson.build index aa42ee15e..e789367dc 100644 --- a/src/meson.build +++ b/src/meson.build @@ -4,6 +4,7 @@ gala_bin_sources = files( 'DBusAccelerator.vala', 'DaemonManager.vala', 'DesktopIntegration.vala', + 'InputMethod.vala', 'InternalUtils.vala', 'KeyboardManager.vala', 'Main.vala', @@ -43,11 +44,16 @@ gala_bin_sources = files( 'HotCorners/Barrier.vala', 'HotCorners/HotCorner.vala', 'HotCorners/HotCornerManager.vala', + 'OSK/OSKManager.vala', + 'OSK/OSKProxy.vala', + 'OSK/OSKReceiver.vala', 'ShellClients/ExtendedBehaviorWindow.vala', 'ShellClients/HideTracker.vala', + 'ShellClients/IBusCandidateWindow.vala', 'ShellClients/ManagedClient.vala', 'ShellClients/MonitorLabelWindow.vala', 'ShellClients/NotificationsClient.vala', + 'ShellClients/OSKWindow.vala', 'ShellClients/PanelWindow.vala', 'ShellClients/PositionedWindow.vala', 'ShellClients/ShellClientsManager.vala',