diff --git a/README.md b/README.md index 0cceb2d..233d2ca 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ in Wayfire 0.11.0-r19 or newer. Unpatched upstream Wayfire is not supported. | `workspace-backgrounds` | Per-workspace wallpapers | gdk-pixbuf | | `screensaver` | Per-monitor idle fade, black, power-off, keep-awake and auto-lock | Wayfire | | `find-pointer` | Press Ctrl twice to spotlight the pointer | Wayfire (GLES2 renderer) | +| `clipboard-keys` | Super+C and Super+V copy and paste in every app | Wayfire | -All five build by default. Building a module does not enable it in Wayfire. +All six build by default. Building a module does not enable it in Wayfire. Window-actions owns the desktop's spatial transactions, previews and physical capture identity; it has no storage and no options. It replaces the former `window-state` plugin: in `wayfire.ini`, replace `window-state` with @@ -38,6 +39,7 @@ The installed libraries: - `libworkspace-backgrounds.so`. - `libscreensaver.so`. - `libfind-pointer.so`. +- `libclipboard-keys.so`. The package replaces the standalone implementations. Compatibility ebuilds for `wayfire-window-state` and `wayfire-panel-desktop` depend diff --git a/meson.build b/meson.build index 7eb9888..ac0e6bc 100644 --- a/meson.build +++ b/meson.build @@ -2,10 +2,10 @@ project('wayfire-plugins-custom', 'cpp', version: '0.1.0', default_options: ['cpp_std=c++17', 'warning_level=3']) wf = dependency('wayfire', version: '>=0.11.0') -if not get_option('window-actions') and not get_option('panel-desktop') and not get_option('workspace-backgrounds') and not get_option('screensaver') and not get_option('find-pointer') +if not get_option('window-actions') and not get_option('panel-desktop') and not get_option('workspace-backgrounds') and not get_option('screensaver') and not get_option('find-pointer') and not get_option('clipboard-keys') error('Enable at least one plugin') endif -foreach plugin : ['window-actions', 'panel-desktop', 'workspace-backgrounds', 'screensaver', 'find-pointer'] +foreach plugin : ['window-actions', 'panel-desktop', 'workspace-backgrounds', 'screensaver', 'find-pointer', 'clipboard-keys'] if get_option(plugin) subdir('plugins' / plugin) endif @@ -16,5 +16,6 @@ summary({ 'workspace-backgrounds': get_option('workspace-backgrounds'), 'screensaver': get_option('screensaver'), 'find-pointer': get_option('find-pointer'), + 'clipboard-keys': get_option('clipboard-keys'), 'tests': get_option('tests'), }, section: 'Custom Wayfire plugins') diff --git a/meson_options.txt b/meson_options.txt index 11efc31..a2ec9d2 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -5,3 +5,4 @@ option('workspace-backgrounds', type: 'boolean', value: true, description: 'Work option('workspace-backgrounds-test-api', type: 'boolean', value: false, description: 'Private compositor test controls; do not enable in installed builds') option('screensaver', type: 'boolean', value: true, description: 'Per-monitor idle fading, black, power-off, keep-awake and auto-lock') option('find-pointer', type: 'boolean', value: true, description: 'Press Ctrl twice to spotlight the pointer') +option('clipboard-keys', type: 'boolean', value: true, description: 'Super+C and Super+V copy and paste in every app') diff --git a/plugins/clipboard-keys/LICENSE b/plugins/clipboard-keys/LICENSE new file mode 100644 index 0000000..bc836b3 --- /dev/null +++ b/plugins/clipboard-keys/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 clipboard-keys contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/clipboard-keys/README.md b/plugins/clipboard-keys/README.md new file mode 100644 index 0000000..a203b62 --- /dev/null +++ b/plugins/clipboard-keys/README.md @@ -0,0 +1,28 @@ +# clipboard-keys + +Super+C and Super+V copy and paste in every app, the way Omarchy does, so one +pair of keys works in terminals and everywhere else. + +- **Terminals** get Ctrl+Shift+C and Ctrl+Shift+V. Ctrl+C would stop the + program running in them. +- **Every other app** gets Ctrl+C and Ctrl+V. + +An app counts as a terminal when a word of its app id is in the `terminals` +option. "org.gnome.Terminal", "xfce4-terminal" and "gnome-terminal-server" all +match `terminal`; "st-256color" matches `st`. If Super+C stops programs in your +terminal, add a word of its app id (`wfctl pick`, then click the window, shows it). + +The shortcut goes straight to the focused app through the seat: the app sees +Ctrl (and Shift) with the letter and not the Super key you are holding. The +letter is looked up in your keyboard layout, so Dvorak and other layouts work; +a layout without Latin letters uses the first layout that has them. While the +screen is locked, or when nothing has keyboard focus, the keys pass through +unchanged. Apps that inhibit compositor shortcuts (virtual machines, remote +desktops) receive Super+C and Super+V themselves. + +| Option | Default | Meaning | +| --- | --- | --- | +| `copy` | ` KEY_C` | Copy in the focused app | +| `paste` | ` KEY_V` | Paste in the focused app | +| `cut` | `none` | Cut (Ctrl+X); in a terminal it copies. Unbound because Super+X keeps a window on top | +| `terminals` | a list of terminal names | Words that mark an app id as a terminal | diff --git a/plugins/clipboard-keys/clipboard-keys.xml b/plugins/clipboard-keys/clipboard-keys.xml new file mode 100644 index 0000000..f8c1960 --- /dev/null +++ b/plugins/clipboard-keys/clipboard-keys.xml @@ -0,0 +1,26 @@ + + + + <_short>Clipboard + <_long>Super+C and Super+V copy and paste in every app, terminals included. A terminal gets Ctrl+Shift+C and Ctrl+Shift+V, so Super+C never stops a running program; other apps get Ctrl+C and Ctrl+V. + Utility + + + + + + diff --git a/plugins/clipboard-keys/meson.build b/plugins/clipboard-keys/meson.build new file mode 100644 index 0000000..2204529 --- /dev/null +++ b/plugins/clipboard-keys/meson.build @@ -0,0 +1,6 @@ +shared_module('clipboard-keys', 'src/plugin.cpp', dependencies: wf, + install: true, install_dir: wf.get_variable(pkgconfig: 'plugindir')) +install_data('clipboard-keys.xml', install_dir: wf.get_variable(pkgconfig: 'metadatadir')) +if get_option('tests') + test('clipboard-keys-shortcuts', executable('clipboard-keys-test', 'tests/keys-test.cpp'), timeout: 30) +endif diff --git a/plugins/clipboard-keys/src/keys.hpp b/plugins/clipboard-keys/src/keys.hpp new file mode 100644 index 0000000..1f8dd95 --- /dev/null +++ b/plugins/clipboard-keys/src/keys.hpp @@ -0,0 +1,86 @@ +// Which shortcut Super+C/V/X becomes in the focused app. Display-free, so the +// unit test covers it. +#pragma once +#include +#include +#include +#include +#include + +namespace clipboard_keys +{ +enum class action_t { copy, paste, cut }; + +struct shortcut_t +{ + char letter; // c, v or x, sent with Ctrl + bool shift; +}; + +// Lower-case words of an app id: "org.wezfurlong.wezterm" -> org, wezfurlong, wezterm. +inline std::vector words(const std::string& text) +{ + std::vector result{""}; + for (unsigned char c : text) + { + if (std::isalnum(c)) + { + result.back() += (char)std::tolower(c); + } else if (!result.back().empty()) + { + result.emplace_back(); + } + } + + if (result.back().empty()) + { + result.pop_back(); + } + + return result; +} + +// A terminal is an app whose id contains one of the listed words, so +// "org.gnome.Terminal", "xfce4-terminal" and "st-256color" all count. +inline bool is_terminal(const std::string& app_id, const std::string& terminals) +{ + std::set listed; + std::istringstream in(terminals); + for (std::string word; in >> word;) + { + for (auto& w : words(word)) + { + listed.insert(w); + } + } + + for (auto& w : words(app_id)) + { + if (listed.count(w)) + { + return true; + } + } + + return false; +} + +// Terminals copy and paste on Ctrl+Shift+C/V, because Ctrl+C there stops the +// running program. They have no cut, so cut copies. +inline shortcut_t shortcut_for(action_t action, bool terminal) +{ + switch (action) + { + case action_t::copy: + return {'c', terminal}; + + case action_t::paste: + return {'v', terminal}; + + case action_t::cut: + return terminal ? shortcut_t{'c', true} : shortcut_t{'x', false}; + } + + return {'c', terminal}; +} +} diff --git a/plugins/clipboard-keys/src/plugin.cpp b/plugins/clipboard-keys/src/plugin.cpp new file mode 100644 index 0000000..b2435e6 --- /dev/null +++ b/plugins/clipboard-keys/src/plugin.cpp @@ -0,0 +1,141 @@ +// clipboard-keys: Super+C and Super+V copy and paste in every app, as on +// Omarchy. The focused app receives Ctrl+Shift+C/V when it is a terminal and +// Ctrl+C/V otherwise, sent straight to it through the seat. +#include "keys.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using clipboard_keys::action_t; + +struct key_t +{ + xkb_keycode_t code; + xkb_layout_index_t layout; +}; + +// The key that types this letter on its own, in the active layout if it has +// one (Dvorak's c is not on KEY_C), else in any layout (a Cyrillic layout has +// no c; apps match Ctrl shortcuts across layouts). +std::optional find_key(xkb_keymap *keymap, xkb_layout_index_t active, xkb_keysym_t sym) +{ + for (int pass = 0; pass < 2; pass++) + { + for (xkb_keycode_t code = xkb_keymap_min_keycode(keymap); code <= xkb_keymap_max_keycode(keymap); code++) + { + xkb_layout_index_t layouts = xkb_keymap_num_layouts_for_key(keymap, code); + for (xkb_layout_index_t layout = 0; layout < layouts; layout++) + { + if ((pass == 0) && (layout != active)) + { + continue; + } + + const xkb_keysym_t *syms; + if ((xkb_keymap_key_get_syms_by_level(keymap, code, layout, 0, &syms) == 1) && (syms[0] == sym)) + { + return key_t{code, layout}; + } + } + } + } + + return {}; +} + +class clipboard_keys_plugin_t : public wf::plugin_interface_t +{ + wf::option_wrapper_t copy_binding{"clipboard-keys/copy"}; + wf::option_wrapper_t paste_binding{"clipboard-keys/paste"}; + wf::option_wrapper_t cut_binding{"clipboard-keys/cut"}; + wf::option_wrapper_t terminals{"clipboard-keys/terminals"}; + + static bool locked() + { + for (auto o : wf::get_core().output_layout->get_outputs()) + { + if (o->is_inhibited()) + { + return true; + } + } + + return false; + } + + // Unhandled (so the keys pass on unchanged) while locked, with nothing + // focused, or without a keyboard. + bool send(action_t action) + { + auto& core = wf::get_core(); + wlr_seat *seat = core.get_current_seat(); + wlr_keyboard *keyboard = wlr_seat_get_keyboard(seat); + if (locked() || !keyboard || !keyboard->keymap || !seat->keyboard_state.focused_surface) + { + return false; + } + + auto view = core.seat->get_active_view(); + bool terminal = view && clipboard_keys::is_terminal(view->get_app_id(), terminals); + auto shortcut = clipboard_keys::shortcut_for(action, terminal); + auto key = find_key(keyboard->keymap, keyboard->modifiers.group, xkb_keysym_from_name( + std::string(1, shortcut.letter).c_str(), XKB_KEYSYM_NO_FLAGS)); + xkb_mod_index_t ctrl = xkb_keymap_mod_get_index(keyboard->keymap, XKB_MOD_NAME_CTRL); + xkb_mod_index_t shift = xkb_keymap_mod_get_index(keyboard->keymap, XKB_MOD_NAME_SHIFT); + xkb_mod_index_t caps = xkb_keymap_mod_get_index(keyboard->keymap, XKB_MOD_NAME_CAPS); + if (!key || (ctrl == XKB_MOD_INVALID) || (shortcut.shift && (shift == XKB_MOD_INVALID))) + { + return false; + } + + // The app sees exactly Ctrl(+Shift) and the letter: the held Super is + // left out, and Caps Lock too so the letter's case doesn't vary. + wlr_keyboard_modifiers held = keyboard->modifiers; + wlr_keyboard_modifiers shortcut_mods{}; + shortcut_mods.depressed = (1u << ctrl) | (shortcut.shift ? (1u << shift) : 0); + shortcut_mods.locked = held.locked & ~((caps == XKB_MOD_INVALID) ? 0 : (1u << caps)); + shortcut_mods.group = key->layout; + uint32_t time = wf::get_current_time(); + // wlroots keycodes are evdev codes, xkb's are 8 higher. + wlr_seat_keyboard_notify_modifiers(seat, &shortcut_mods); + wlr_seat_keyboard_notify_key(seat, time, key->code - 8, WL_KEYBOARD_KEY_STATE_PRESSED); + wlr_seat_keyboard_notify_key(seat, time, key->code - 8, WL_KEYBOARD_KEY_STATE_RELEASED); + wlr_seat_keyboard_notify_modifiers(seat, &held); + return true; + } + + wf::activator_callback on_copy = [this] (auto) { return send(action_t::copy); }; + wf::activator_callback on_paste = [this] (auto) { return send(action_t::paste); }; + wf::activator_callback on_cut = [this] (auto) { return send(action_t::cut); }; + + public: + void init() override + { + auto& bindings = wf::get_core().bindings; + bindings->add_activator(copy_binding, &on_copy); + bindings->add_activator(paste_binding, &on_paste); + bindings->add_activator(cut_binding, &on_cut); + } + + void fini() override + { + auto& bindings = wf::get_core().bindings; + bindings->rem_binding(&on_copy); + bindings->rem_binding(&on_paste); + bindings->rem_binding(&on_cut); + } +}; +} + +DECLARE_WAYFIRE_PLUGIN(clipboard_keys_plugin_t); diff --git a/plugins/clipboard-keys/tests/keys-test.cpp b/plugins/clipboard-keys/tests/keys-test.cpp new file mode 100644 index 0000000..39c81ba --- /dev/null +++ b/plugins/clipboard-keys/tests/keys-test.cpp @@ -0,0 +1,56 @@ +// Unit test: which app ids count as terminals and what each key sends. +#include "../src/keys.hpp" +#include + +using namespace clipboard_keys; + +static int failures = 0; +#define CHECK(cond) do { if (!(cond)) { std::printf("FAIL line %d: %s\n", __LINE__, #cond); failures++; } } while (0) + +int main() +{ + const std::string list = "alacritty foot footclient kitty term terminal st console wezterm ghostty konsole"; + CHECK(words("org.wezfurlong.wezterm") == (std::vector{"org", "wezfurlong", "wezterm"})); + CHECK(words("--St-256color--") == (std::vector{"st", "256color"})); + CHECK(words("").empty()); + + for (auto id : {"Alacritty", "foot", "footclient", "kitty", "org.gnome.Terminal", "xfce4-terminal", + "gnome-terminal-server", "st-256color", "org.gnome.Console", "org.wezfurlong.wezterm", + "com.mitchellh.ghostty", "org.kde.konsole", "cool-retro-term"}) + { + if (!is_terminal(id, list)) + { + std::printf("not a terminal: %s\n", id); + failures++; + } + } + + for (auto id : {"firefox", "org.gnome.Nautilus", "steam", "Stellarium", "org.wayfire.Files", "", "kittycat", + "determination"}) + { + if (is_terminal(id, list)) + { + std::printf("wrongly a terminal: %s\n", id); + failures++; + } + } + + CHECK(is_terminal("MyTerm", " myterm ")); + CHECK(!is_terminal("Alacritty", "")); + + auto s = shortcut_for(action_t::copy, false); + CHECK(s.letter == 'c' && !s.shift); + s = shortcut_for(action_t::copy, true); + CHECK(s.letter == 'c' && s.shift); + s = shortcut_for(action_t::paste, false); + CHECK(s.letter == 'v' && !s.shift); + s = shortcut_for(action_t::paste, true); + CHECK(s.letter == 'v' && s.shift); + s = shortcut_for(action_t::cut, false); + CHECK(s.letter == 'x' && !s.shift); + s = shortcut_for(action_t::cut, true); + CHECK(s.letter == 'c' && s.shift); + + std::printf("%s\n", failures ? "FAILED" : "ok"); + return failures ? 1 : 0; +}