--- a/plugins/vswitch/vswitch.cpp +++ b/plugins/vswitch/vswitch.cpp @@ -343,6 +343,22 @@ { private: + class held_control_bindings_t : public wf::vswitch::control_bindings_t + { + std::function target; + + public: + held_control_bindings_t(wf::output_t *output, + std::function target) : control_bindings_t(output), target(target) + {} + + protected: + wayfire_toplevel_view get_target_view() override + { + return target(); + } + }; + /** * Adapter around the general algorithm, so that our own stop function is * called. @@ -371,6 +387,8 @@ wayfire_toplevel_view held_dim_view = nullptr; std::shared_ptr held_dim_overlay_node; uint32_t held_dim_modifiers = 0; + std::weak_ptr held_move_view; + uint32_t held_move_modifiers = 0; const std::string held_dim_transformer_name = "workspace-move-window-dim"; wf::option_wrapper_t window_move_alpha{"vswitch/window_move_alpha"}; static constexpr uint32_t held_window_modifiers = @@ -384,6 +402,7 @@ .name = "vswitch", .cancel = [=] () { + clear_held_window_target(); clear_held_window_dim(); algorithm->stop_switch(false); }, @@ -396,6 +415,7 @@ output->connect(&on_grabbed_view_disappear); wf::get_core().connect(&on_key_release); wf::get_core().connect(&on_modifier_change); + wf::get_core().connect(&on_held_view_set_output); algorithm = std::make_unique(output, [=] () @@ -404,7 +424,8 @@ output->deactivate_plugin(&grab_interface); }); - bindings = std::make_unique(output); + bindings = std::make_unique(output, + [this] { return get_held_window_target_view(); }); bindings->setup([this] (wf::point_t delta, wayfire_toplevel_view view, bool only_view) { // Do not switch workspace with sticky view, they are on all @@ -416,6 +437,11 @@ if (this->set_capabilities(wf::CAPABILITY_MANAGE_DESKTOP)) { + if (view && !only_view) + { + remember_held_window_target(view); + } + if (delta == wf::point_t{0, 0}) { if (view) @@ -490,6 +516,20 @@ wayfire_toplevel_view get_held_window_target_view() { + // Focus can change when the destination workspace appears. Keep the + // gesture's original target, including an empty target after it closes. + if (held_move_modifiers) + { + auto view = held_move_view.lock(); + if (view && view->is_mapped() && (view->get_output() == output)) + { + return view; + } + + held_move_view.reset(); + return nullptr; + } + auto view = find_topmost_parent( toplevel_cast(wf::get_core().seat->get_active_view())); if (!view || (view->role != wf::VIEW_ROLE_TOPLEVEL)) @@ -500,6 +540,25 @@ return view; } + void remember_held_window_target(wayfire_toplevel_view view) + { + if (!held_move_modifiers && view && view->is_mapped() && + (view->get_output() == output) && !view->sticky) + { + held_move_modifiers = wf::get_core().seat->get_keyboard_modifiers(); + if (held_move_modifiers) + { + held_move_view = view->weak_from_this(); + } + } + } + + void clear_held_window_target() + { + held_move_view.reset(); + held_move_modifiers = 0; + } + void show_held_window_dim(wayfire_toplevel_view view) { if (!view || (view->role != wf::VIEW_ROLE_TOPLEVEL) || !view->is_mapped()) @@ -519,6 +578,7 @@ return; } + remember_held_window_target(view); clear_held_window_dim(); auto transformer = std::make_shared(view); @@ -651,6 +711,11 @@ wf::signal::connection_t on_grabbed_view_disappear = [=] (wf::view_disappeared_signal *ev) { + if (ev->view.get() == held_move_view.lock().get()) + { + held_move_view.reset(); + } + if (ev->view == held_dim_view) { clear_held_window_dim(); @@ -659,6 +724,16 @@ if (ev->view == algorithm->get_overlay_view()) { algorithm->set_overlay_view(nullptr); + } + }; + + wf::signal::connection_t on_held_view_set_output = + [this] (wf::view_set_output_signal *ev) + { + if ((ev->view.get() == held_move_view.lock().get()) && (ev->view->get_output() != output)) + { + held_move_view.reset(); + clear_held_window_dim(); } }; @@ -668,6 +743,11 @@ { (void)ev; auto modifiers = wf::get_core().seat->get_keyboard_modifiers(); + if (held_move_modifiers && ((modifiers & held_move_modifiers) != held_move_modifiers)) + { + clear_held_window_target(); + } + if ((modifiers & held_window_modifiers) == held_window_modifiers) { show_held_window_dim(get_held_window_target_view()); @@ -680,12 +760,17 @@ wf::signal::connection_t> on_key_release = [=] (wf::input_event_signal *ev) { - if (!held_dim_view || (ev->event->state != WLR_KEY_RELEASED)) + if (ev->event->state != WLR_KEY_RELEASED) { return; } auto mod = wf::get_core().seat->modifier_from_keycode(ev->event->keycode); + if (mod & held_move_modifiers) + { + clear_held_window_target(); + } + if (mod & held_dim_modifiers) { clear_held_window_dim(); @@ -745,6 +830,7 @@ void fini() { + clear_held_window_target(); clear_held_window_dim(); if (is_active()) { @@ -859,4 +945,12 @@ }; }; -DECLARE_WAYFIRE_PLUGIN(wf_vswitch_global_plugin_t); +extern "C" __attribute__((visibility("default"))) wf::plugin_interface_t *newInstance() +{ + return new wf_vswitch_global_plugin_t; +} + +extern "C" __attribute__((visibility("default"))) uint32_t getWayfireVersion() +{ + return WAYFIRE_API_ABI_VERSION; +} --- a/plugins/vswitch/meson.build +++ b/plugins/vswitch/meson.build @@ -5,6 +5,7 @@ include_directories: [wayfire_api_inc, wayfire_conf_inc, plugins_common_inc, vswitch_inc, ipc_include_dirs], dependencies: [wlroots, pixman, wfconfig, json, plugin_pch_dep], link_with: [workspace_wall], + gnu_symbol_visibility: 'hidden', install: true, install_dir: join_paths(get_option('libdir'), 'wayfire')) --- /dev/null +++ b/tests/held-workspace-window.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Keep the same window through occupied workspaces until move modifiers release.""" +import argparse +import json +import os +from pathlib import Path +import socket +import struct +import subprocess +import tempfile +import time + +parser = argparse.ArgumentParser() +parser.add_argument('--plugin', default='vswitch') +parser.add_argument('--expect-bug', action='store_true') +parser.add_argument('--upgrade-from') +args = parser.parse_args() +root = Path(tempfile.mkdtemp(prefix='held-workspace-window-')) +runtime = root / 'runtime' +runtime.mkdir(mode=0o700) +env = os.environ.copy() +for name in ('DISPLAY', 'WAYLAND_DISPLAY', 'WAYFIRE_SOCKET', '_WAYFIRE_SOCKET', + 'WAYFIRE_CONFIG_FILE', 'WAYFIRE_PLUGIN_PATH', 'WAYFIRE_PLUGIN_XML_PATH'): + env.pop(name, None) +env.update(XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / 'config'), + XDG_DATA_HOME=str(root / 'data'), XDG_STATE_HOME=str(root / 'state'), + WLR_BACKENDS='headless', WLR_HEADLESS_OUTPUTS='3', WLR_RENDERER='gles2') +base = 'ipc stipc ipc-rules cube place follow-focus' +config = root / 'wayfire.ini' +config.write_text(f'''[core] +plugins = {base} {args.upgrade_from or args.plugin} +xwayland = false +stitch_enabled = true +stitch_outputs = HEADLESS-1 HEADLESS-2 HEADLESS-3 +vwidth = 4 +vheight = 1 +[output:HEADLESS-1] +mode = 320x480@60 +position = 0,0 +[output:HEADLESS-2] +mode = 320x480@60 +position = 320,0 +[output:HEADLESS-3] +mode = 320x480@60 +position = 640,0 +[vswitch] +with_win_left = KEY_LEFT +with_win_right = KEY_RIGHT +with_win_modifier = +wraparound = true +[cube] +initial_animation = 100ms circle +[follow-focus] +change_view = true +change_output = true +focus_delay = 0 +focus_on_workspace_change = true +raise_on_top = false +threshold = 10 +[workarounds] +enable_so_unloading = false +''') +children = [] +with (root / 'wayfire.log').open('w') as log: + compositor = subprocess.Popen(['/usr/bin/wayfire', '-c', str(config)], env=env, + stdout=log, stderr=log) +children.append(compositor) + + +def wait(fn, timeout=10): + until = time.monotonic() + timeout + while time.monotonic() < until: + assert compositor.poll() is None, f'Compositor exited: {root}' + result = fn() + if result: + return result + time.sleep(.03) + raise AssertionError(f'Timed out: {root}') + + +def ipc(method, data=None): + with socket.socket(socket.AF_UNIX) as s: + s.settimeout(3) + s.connect(env['WAYFIRE_SOCKET']) + body = json.dumps({'method': method, 'data': data or {}}).encode() + s.sendall(struct.pack(' 0 else 'KEY_LEFT' + key(name, True) + key(name, False) + wait(lambda: workspace() == (before + direction) % 4) + time.sleep(.3) + + +def views(): + return {v['id']: v for v in ipc('window-rules/list-views') + if v.get('type') == 'toplevel'} + + +def output(): + return ipc('window-rules/list-outputs')[0] + + +def workspace(): + return output()['workspace']['x'] + + +def view_workspace(vid): + geometry = views()[vid]['geometry'] + return (workspace() + int((geometry['x'] + geometry['width'] / 2) + // output()['geometry']['width'])) % 4 + + +def switch(n): + ipc('vswitch/set-workspace', {'x': n, 'y': 0, 'output-id': output()['id']}) + time.sleep(.3) + + +def focus(vid): + ipc('window-rules/focus-view', {'id': vid}) + + +def spawn(name, x): + with (root / (name + '.log')).open('w') as log: + proc = subprocess.Popen(['/var/tmp/wayfire-scope-toggle/scope-client', name, 'normal'], + env=env, stdin=subprocess.PIPE, stdout=log, stderr=log, text=True) + children.append(proc) + vid = wait(lambda: next((v['id'] for v in views().values() if v['app-id'] == name), None)) + ipc('window-rules/configure-view', {'id': vid, + 'geometry': {'x': x, 'y': 100, 'width': 220, 'height': 160}}) + time.sleep(.15) + return proc, vid + + +try: + env['WAYFIRE_SOCKET'] = str(wait(lambda: next(runtime.glob('wayfire-*.socket'), None))) + env['WAYLAND_DISPLAY'] = ipc('stipc/get_display')['wayland'] + time.sleep(.2) + carried_proc, carried = spawn('carried-window', 30) + switch(1) + _, resident = spawn('resident-window', 370) + switch(0) + ipc('stipc/move_cursor', {'x': 450, 'y': 180}) + focus(carried) + if args.upgrade_from: + ipc('wayfire/set-config-options', {'core/plugins': base}) + wait(lambda: 'vswitch/set-workspace' not in ipc('list-methods')['methods']) + ipc('wayfire/set-config-options', {'core/plugins': base + ' ' + args.plugin}) + wait(lambda: 'vswitch/set-workspace' in ipc('list-methods')['methods']) + print('PASS replacing the resident vswitch module', flush=True) + hold(True) + step(1) + assert view_workspace(carried) == 1 and view_workspace(resident) == 1 + first = views() + step(1) + second = views() + (root / 'occupied-workspace.json').write_text(json.dumps( + {'carried': carried, 'resident': resident, 'first': first, 'second': second}, indent=2)) + if args.expect_bug: + assert view_workspace(carried) == 1 and view_workspace(resident) == 2, second + print('PASS reproduced: held move switches to the resident window', flush=True) + else: + assert view_workspace(carried) == 2 and view_workspace(resident) == 1, second + print('PASS the original window survives destination follow-focus', flush=True) + for direction in (-1, -1, -1, 1, 1, 1): + step(direction) + assert view_workspace(carried) == workspace() + assert view_workspace(resident) == 1 + print('PASS reverse direction and workspace wraparound keep the original window', flush=True) + hold(False) + switch(1) + focus(resident) + hold(True) + step(1) + assert view_workspace(resident) == 2 + print('PASS releasing modifiers allows selecting another window', flush=True) + hold(False) + focus(carried) + hold(True) + step(-1) + carried_proc.terminate() + carried_proc.wait(timeout=5) + wait(lambda: carried not in views()) + focus(resident) + # Losing the original target must not select the newly focused window. + step(1) + assert view_workspace(resident) == 2 + step(1) + assert view_workspace(resident) == 2 + print('PASS closing the carried window does not latch onto another window', flush=True) + hold(False) +finally: + for proc in reversed(children): + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + print(f'Artifacts: {root}', flush=True)