diff --git a/awesomerc.lua b/awesomerc.lua index c598a3e08d..3baf3d2eae 100644 --- a/awesomerc.lua +++ b/awesomerc.lua @@ -508,6 +508,7 @@ ruled.client.connect_signal("request::rules", function() -- @DOC_CSD_TITLEBARS@ id = "titlebars", rule_any = { type = { "normal", "dialog" } }, + except = { requests_no_titlebar = true }, properties = { titlebars_enabled = true } } diff --git a/common/atoms.list b/common/atoms.list index 9cf41336ee..1b5007db30 100644 --- a/common/atoms.list +++ b/common/atoms.list @@ -8,6 +8,7 @@ _NET_DESKTOP_NAMES _NET_ACTIVE_WINDOW _NET_SUPPORTING_WM_CHECK _NET_CLOSE_WINDOW +_NET_WM_MOVERESIZE _NET_FRAME_EXTENTS _NET_WM_NAME _NET_WM_STRUT_PARTIAL diff --git a/ewmh.c b/ewmh.c index 981f3d1fad..b75a84c766 100644 --- a/ewmh.c +++ b/ewmh.c @@ -35,6 +35,9 @@ #define _NET_WM_STATE_ADD 1 #define _NET_WM_STATE_TOGGLE 2 +#define _NET_WM_MOVERESIZE_MOVE 8 +#define _NET_WM_MOVERESIZE_CANCEL 11 + #define ALL_DESKTOPS 0xffffffff /** Update client EWMH hints. @@ -141,6 +144,7 @@ ewmh_init(void) _NET_DESKTOP_NAMES, _NET_ACTIVE_WINDOW, _NET_CLOSE_WINDOW, + _NET_WM_MOVERESIZE, _NET_FRAME_EXTENTS, _NET_WM_NAME, _NET_WM_STRUT_PARTIAL, @@ -455,6 +459,34 @@ ewmh_process_desktop(client_t *c, uint32_t desktop) } } +const char *moveresize_size_dir_map[] = { + "top_left", "top", "top_right", "right", + "bottom_right", "bottom", "bottom_left", "left" +}; + +static void +push_moveresize_data(lua_State *L, const uint32_t data[5]) +{ + lua_newtable(L); + + lua_pushstring(L, "mouse_pos"); + lua_newtable(L); + + lua_pushstring(L, "x"); + lua_pushnumber(L, data[0]); + lua_settable(L, -3); + + lua_pushstring(L, "y"); + lua_pushnumber(L, data[1]); + lua_settable(L, -3); + + lua_settable(L, -3); + + lua_pushstring(L, "button"); + lua_pushnumber(L, data[3]); + lua_settable(L, -3); +} + int ewmh_process_client_message(xcb_client_message_event_t *ev) { @@ -511,6 +543,47 @@ ewmh_process_client_message(xcb_client_message_event_t *ev) lua_pop(L, 1); } } + else if(ev->type == _NET_WM_MOVERESIZE) + { + if((c = client_getbywin(ev->window))) + { + lua_State *L = globalconf_get_lua_State(); + uint32_t dir = ev->data.data32[2]; + if(dir < 8) /* It's _NET_WM_MOVERESIZE_SIZE_* */ + { + luaA_object_push(L, c); + lua_pushstring(L, "ewmh"); + push_moveresize_data(L, ev->data.data32); + + lua_pushstring(L, "corner"); + lua_pushstring(L, moveresize_size_dir_map[dir]); + lua_settable(L, -3); + + luaA_object_emit_signal(L, -3, "request::mouse_resize", 2); + lua_pop(L, 1); + } + else + { + switch(dir) + { + case _NET_WM_MOVERESIZE_MOVE: + luaA_object_push(L, c); + lua_pushstring(L, "ewmh"); + push_moveresize_data(L, ev->data.data32); + luaA_object_emit_signal(L, -3, "request::mouse_move", 2); + lua_pop(L, 1); + break; + case _NET_WM_MOVERESIZE_CANCEL: + luaA_object_push(L, c); + lua_pushstring(L, "ewmh"); + luaA_object_emit_signal(L, -2, "request::mouse_cancel", 1); + lua_pop(L, 1); + break; + /* Simply ignore the _NET_WM_MOVERESIZE_*_KEYBOARD cases like i3 does */ + } + } + } + } return 0; } diff --git a/lib/awful/mouse/resize.lua b/lib/awful/mouse/resize.lua index e1ca822819..a129975714 100644 --- a/lib/awful/mouse/resize.lua +++ b/lib/awful/mouse/resize.lua @@ -188,8 +188,14 @@ local function handler(_, client, context, args) --luacheck: no unused_args end -- Quit when the button is released - for _,v in pairs(coords.buttons) do - if v then return true end + if args.mouse_buttons and #args.mouse_buttons > 0 then + for _,v in pairs(args.mouse_buttons) do + if coords.buttons[v] then return true end + end + else + for _,v in pairs(coords.buttons) do + if v then return true end + end end -- Only resize after the mouse is released, this avoids losing content diff --git a/lib/awful/permissions/init.lua b/lib/awful/permissions/init.lua index 224bc7c9cb..b15b1ea05e 100644 --- a/lib/awful/permissions/init.lua +++ b/lib/awful/permissions/init.lua @@ -26,6 +26,7 @@ local ipairs = ipairs local timer = require("gears.timer") local gtable = require("gears.table") local aclient = require("awful.client") +local mresize = require("awful.mouse.resize") local aplace = require("awful.placement") local asuit = require("awful.layout.suit") local beautiful = require("beautiful") @@ -567,6 +568,101 @@ function permissions.client_geometry_requests(c, context, hints) end end +--- Begins moving a client with the mouse. +-- +-- This is the default handler for `request::mouse_move`. When a request is +-- received, it uses `awful.mouse.resize` to initiate a mouse movement transaction +-- that lasts so long as the specified mouse button is held. +-- +-- @signalhandler awful.permissions.client_mouse_move +-- @tparam client c The client +-- @tparam string context The context +-- @tparam table args Additional information describing the movement. +-- @tparam number args.mouse_pos.x The x coordinate of the mouse when grabbed. +-- @tparam number args.mouse_pos.y The y coordinate of the mouse when grabbed. +-- @tparam number args.button The mouse button that initiated the movement. +-- @sourcesignal client request::mouse_move +function permissions.client_mouse_move(c, context, args) + if not pcommon.check(c, "client", "mouse_move", context) then return end + + if not c + or c.fullscreen + or c.maximized + or c.type == "desktop" + or c.type == "splash" + or c.type == "dock" then + return + end + + local center_pos = aplace.centered(mouse, {parent=c, pretend=true}) + mresize(c, "mouse.move", { + placement = aplace.under_mouse, + offset = { + x = center_pos.x - args.mouse_pos.x, + y = center_pos.y - args.mouse_pos.y + }, + mouse_buttons = {args.button} + }) +end + +--- Begins resizing a client with the mouse. +-- +-- This is the default handler for `request::mouse_resize`. When a request is +-- received, it uses `awful.mouse.resize` to initiate a mouse resizing transaction +-- that lasts so long as the specified mouse button is held. +-- +-- @signalhandler awful.permissions.client_mouse_resize +-- @tparam client c The client +-- @tparam string context The context +-- @tparam table args Additional information describing the resizing. +-- @tparam number args.mouse_pos.x The x coordinate of the mouse when grabbed. +-- @tparam number args.mouse_pos.y The y coordinate of the mouse when grabbed. +-- @tparam number args.button The mouse button that initiated the resizing. +-- @tparam string args.corner The corner/side of the window being resized. +-- @sourcesignal client request::mouse_resize +function permissions.client_mouse_resize(c, context, args) + if not pcommon.check(c, "client", "mouse_resize", context) then return end + + if not c + or c.fullscreen + or c.maximized + or c.type == "desktop" + or c.type == "splash" + or c.type == "dock" then + return + end + + local corner_pos = aplace[args.corner](mouse, {parent = c, pretend = true}) + mresize(c, "mouse.resize", { + corner = args.corner, + corner_lock = true, + mouse_offset = { + x = args.mouse_pos.x - corner_pos.x, + y = args.mouse_pos.y - corner_pos.y + }, + mouse_buttons = {args.button} + }) +end + +--- Cancels a mouse movement/resizing operation. +-- +-- This is the default handler for `request::mouse_cancel`. It simply ends any +-- ongoing `mousegrabber` transaction. +-- +-- @signalhandler awful.permissions.client_mouse_cancel +-- @tparam client c The client +-- @tparam string context The context +-- @sourcesignal client request::mouse_cancel +function permissions.client_mouse_cancel(c, context) + if not pcommon.check(c, "client", "mouse_cancel", context) then return end + + -- This will also stop other mouse grabber transactions, but that's probably fine; + -- a well-behaved client should only raise this signal when a mouse movement or + -- resizing operation is in progress, and so no other mouse grabber transactions + -- should be happening at this time. + mousegrabber.stop() +end + -- The magnifier layout doesn't work with focus follow mouse. permissions.add_activate_filter(function(c) if alayout.get(c.screen) ~= alayout.suit.magnifier @@ -799,6 +895,9 @@ client.connect_signal("request::urgent" , permissions.urgent) client.connect_signal("request::geometry" , permissions.geometry) client.connect_signal("request::geometry" , permissions.merge_maximization) client.connect_signal("request::geometry" , permissions.client_geometry_requests) +client.connect_signal("request::mouse_move" , permissions.client_mouse_move) +client.connect_signal("request::mouse_resize" , permissions.client_mouse_resize) +client.connect_signal("request::mouse_cancel" , permissions.client_mouse_cancel) client.connect_signal("property::border_width" , repair_geometry) client.connect_signal("property::screen" , repair_geometry) client.connect_signal("request::unmanage" , check_focus_delayed) diff --git a/lib/awful/placement.lua b/lib/awful/placement.lua index 7802bcf8b7..9fd7024f01 100644 --- a/lib/awful/placement.lua +++ b/lib/awful/placement.lua @@ -1123,6 +1123,11 @@ end -- -- * *axis*: The axis (vertical or horizontal). If none is -- specified, then the drawable will be resized on both axis. +-- * *corner*: The corner to resize from. If not specified, then +-- the corner nearest to the cursor is used. +-- * *corner_lock*: Whether to lock the corner to *corner* or not. +-- if not set, then the corner may be updated to the one nearest +-- to the cursor in the middle of resizing operation. -- --@DOC_awful_placement_resize_to_mouse_EXAMPLE@ -- @tparam drawable d A drawable (like `client`, `mouse` or `wibox`) @@ -1138,25 +1143,38 @@ function placement.resize_to_mouse(d, args) local h_only = args.axis == "horizontal" local v_only = args.axis == "vertical" - -- To support both growing and shrinking the drawable, it is necessary - -- to decide to use either "north or south" and "east or west" directions. - -- Otherwise, the result will always be 1x1 - local _, closest_corner = placement.closest_corner(capi.mouse, { - parent = d, - pretend = true, - include_sides = args.include_sides or false, - }) + if args.mouse_offset then + coords = { + x = coords.x - args.mouse_offset.x, + y = coords.y - args.mouse_offset.y + } + end + + local corner + if args.corner and args.corner_lock then + corner = args.corner + else + -- To support both growing and shrinking the drawable, it is necessary + -- to decide to use either "north or south" and "east or west" directions. + -- Otherwise, the result will always be 1x1 + local _, closest_corner = placement.closest_corner(capi.mouse, { + parent = d, + pretend = true, + include_sides = args.include_sides or false, + }) + corner = closest_corner + end -- Given "include_sides" wasn't set, it will always return a name -- with the 2 axis. If only one axis is needed, adjust the result if h_only then - closest_corner = closest_corner:match("left") or closest_corner:match("right") + corner = corner:match("left") or corner:match("right") elseif v_only then - closest_corner = closest_corner:match("top") or closest_corner:match("bottom") + corner = corner:match("top") or corner:match("bottom") end -- Use p0 (mouse), p1 and p2 to create a rectangle - local pts = resize_to_point_map[closest_corner] + local pts = resize_to_point_map[corner] local p1 = pts.p1 and rect_to_point(ngeo, pts.p1[1], pts.p1[2]) or coords local p2 = pts.p2 and rect_to_point(ngeo, pts.p2[1], pts.p2[2]) or coords diff --git a/objects/client.c b/objects/client.c index 6bd8df2679..0393d8b130 100644 --- a/objects/client.c +++ b/objects/client.c @@ -315,6 +315,60 @@ lua_class_t client_class; * @classsignal */ +/** Emitted when something requests for a client to be moved by the mouse. + * + * This is used to allow clients to manage their own "grabbable" areas, such + * as in custom title bars, but to then delegate the task of actually moving + * the client to the window manager. + * + * **Contexts are:** + * * *ewmh*: When the client requests the movement (via _NET_WM_MOVERESIZE) + * + * @signal request::mouse_move + * @tparam client c The client. + * @tparam string context Why the mouse movement was requested. + * @tparam table args Additional information describing the movement. + * @tparam number args.mouse_pos.x The x coordinate of the mouse when grabbed. + * @tparam number args.mouse_pos.y The y coordinate of the mouse when grabbed. + * @tparam number args.button The mouse button that initiated the movement. + * @classsignal +*/ + +/** Emitted when something requests for a client to be resized by the mouse. + * + * This is used to allow clients to manage their own "grabbable" areas, such + * as in custom window frames, but to then delegate the task of actually + * resizing the client to the window manager. + * + * **Contexts are:** + * * *ewmh*: When the client requests the resizing (via _NET_WM_MOVERESIZE) + * + * @signal request::mouse_resize + * @tparam client c The client. + * @tparam string context Why the mouse resizing was requested. + * @tparam table args Additional information describing the resizing. + * @tparam number args.mouse_pos.x The x coordinate of the mouse when grabbed. + * @tparam number args.mouse_pos.y The y coordinate of the mouse when grabbed. + * @tparam number args.button The mouse button that initiated the resizing. + * @tparam string args.corner The corner/side of the window being resized. + * @classsignal +*/ + +/** Emitted when something requests for a grabbed client to be released. + * + * This is used to allow clients to cancel a mouse movement or resizing + * operation that may have been started by an earlier `request::mouse_move` + * or `request::mouse_resize` signal. + * + * **Contexts are:** + * * *ewmh*: When the client requests the release (via _NET_WM_MOVERESIZE) + * + * @signal request::mouse_cancel + * @tparam client c The client + * @tparam string context Why the mouse release was requested. + * @classsignal +*/ + /** Emitted when a client requests to be moved to a tag or needs a new tag. * * @signal request::tag diff --git a/tests/_client.lua b/tests/_client.lua index 3eb015f312..b5fe4c173c 100644 --- a/tests/_client.lua +++ b/tests/_client.lua @@ -24,16 +24,21 @@ local function open_window(class, title, options) default_height = options.default_height or 100, title = title } + if options.resize_increment or options.custom_titlebar then + -- These require Gtk3, but may fail with an obscure message with Gtk2. + -- Produce a better error message instead. + assert(tonumber(require("lgi").Gtk._version) >= 3, "Gtk 3 required, but not found") + end if options.gravity then window:set_gravity(tonumber(options.gravity)) end if options.snid and options.snid ~= "" then window:set_startup_id(options.snid) end + if options.custom_titlebar then + window:set_titlebar(Gtk.Label { label = title }) + end if options.resize_increment then - -- This requires Gtk3, but fails with an obscure message with Gtk2. - -- Produce a better error message instead. - assert(tonumber(require("lgi").Gtk._version) >= 3, "Gtk 3 required, but not found") local geom = Gdk.Geometry { width_inc = 200, height_inc = 200, @@ -194,6 +199,9 @@ local function new(_, class, title, sn_rules, callback, resize_increment, args) if args.minimize_after then options = options .. "minimize_after," end + if args.custom_titlebar then + options = options .. "custom_titlebar," + end if args.size then options = table.concat { diff --git a/tests/test-ewmh-wm_moveresize.lua b/tests/test-ewmh-wm_moveresize.lua new file mode 100644 index 0000000000..733cd2b784 --- /dev/null +++ b/tests/test-ewmh-wm_moveresize.lua @@ -0,0 +1,285 @@ +local test_client = require("_client") +local placement = require("awful.placement") +local unpack = unpack or table.unpack -- luacheck: globals unpack (compatibility with Lua 5.1) + +-- Testing utils +local signals_of_interest = { + "request::mouse_move", + "request::mouse_resize", + "request::mouse_cancel" +} +local signal_buffer = {} + +local function assert_signal(sig_name, asserts) + local sig_data = signal_buffer[sig_name] + assert(sig_data ~= nil, "expected signal: " .. sig_name) + if asserts then + asserts(unpack(sig_data)) + end + signal_buffer = {} +end + +local function assert_geometry(c, x, y, width, height) + local geo = c:geometry() + assert(geo.x == x, "expected x coord " .. x .. + ", but it was " .. geo.x) + assert(geo.y == y, "expected y coord " .. y .. + ", but it was " .. geo.y) + assert(geo.width == width, "expected width " .. width .. + ", but it was " .. geo.width) + assert(geo.height == height, "expected height " .. height .. + ", but it was " .. geo.height) +end + +local function drag(x, y) + mouse.coords {x = x, y = y} +end + +local function click(x, y) + drag(x, y) + root.fake_input("button_press", 1) +end + +local function unclick() + root.fake_input("button_release", 1) +end + +local resize_corners = { + {corner = "right", bx = -1, by = 0, dx = 0, dy = 0, dw = 1, dh = 0}, + {corner = "bottom_right", bx = -1, by = -1, dx = 0, dy = 0, dw = 1, dh = 1}, + {corner = "bottom", bx = 0, by = -1, dx = 0, dy = 0, dw = 0, dh = 1}, + {corner = "bottom_left", bx = 1, by = -1, dx = 1, dy = 0, dw = -1, dh = 1}, + {corner = "left", bx = 1, by = 0, dx = 1, dy = 0, dw = -1, dh = 0}, + {corner = "top_left", bx = 1, by = 1, dx = 1, dy = 1, dw = -1, dh = -1}, + {corner = "top", bx = 0, by = 1, dx = 0, dy = 1, dw = 0, dh = -1}, + {corner = "top_right", bx = -1, by = 1, dx = 0, dy = 1, dw = 1, dh = -1}, +} +local steps = {} + +-- Mouse movement tests + +table.insert(steps, function(count) + if count == 1 then -- Setup. + test_client("foobar", "foobar", nil, nil, nil, { + custom_titlebar = true + }) + elseif #client.get() > 0 then + local c = client.get()[1] + + c : geometry { + x = 200, + y = 200, + width = 400, + height = 400, + } + + -- Attach signal handlers so that we can see which signals have been fired + for _,sig_name in ipairs(signals_of_interest) do + c:connect_signal(sig_name, function(_, ...) + signal_buffer[sig_name] = {...} + local _, args = ... + if args and args.mouse_pos then + print("got signal " .. sig_name .. + " at (" .. args.mouse_pos.x .. ", " .. args.mouse_pos.y .. ")") + else + print("got signal " .. sig_name) + end + end) + end + + return true + end +end) + +table.insert(steps, function() + local c = client.get()[1] + + -- GTK window may take some time to finish setting up + -- Just repeat until the drag works + if signal_buffer["request::mouse_move"] then return true end + + -- Just in case there is an accidental delayed geometry callback + assert_geometry(c, 200, 200, 400, 400) + + -- Click the title bar and drag to start a mouse movement transaction + -- This doesn't necessarily move the window yet! + unclick() + click(400, 220) + drag(420, 220) +end) + +table.insert(steps, function() + assert_signal("request::mouse_move", function(context, args) + assert(context == "ewmh") + assert(args.button == 1) + assert(args.mouse_pos.x == 400) + assert(args.mouse_pos.y == 220) + end) + + -- Actually move the window + drag(500, 120) + + return true +end) + +table.insert(steps, function() + local c = client.get()[1] + + assert_geometry(c, 300, 100, 400, 400) + + -- Release the mouse to end the movement + unclick() + + return true +end) + +table.insert(steps, function() + local c = client.get()[1] + + if mousegrabber.isrunning() then return end + + assert_geometry(c, 300, 100, 400, 400) + + -- Window should no longer follow mouse + drag(300, 200) + + return true +end) + +table.insert(steps, function() + local c = client.get()[1] + + assert_geometry(c, 300, 100, 400, 400) + + -- Start another mouse movement transaction + click(500, 120) + drag(520, 120) + + return true +end) + +table.insert(steps, function() + local c = client.get()[1] + + if not signal_buffer["request::mouse_move"] then return end + + assert_geometry(c, 300, 100, 400, 400) + + -- Move the window somewhere else + drag(600, 170) + + return true +end) + +table.insert(steps, function() + local c = client.get()[1] + + assert_geometry(c, 400, 150, 400, 400) + + -- Cancel the mouse movement with a signal + c:emit_signal("request::mouse_cancel", "test") + + return true +end) + +table.insert(steps, function() + local c = client.get()[1] + + assert_signal("request::mouse_cancel") + assert_geometry(c, 400, 150, 400, 400) + + -- Window should no longer follow mouse + drag(300, 200) + + return true +end) + +table.insert(steps, function() + local c = client.get()[1] + + assert_geometry(c, 400, 150, 400, 400) + + unclick() + + return true +end) + +-- Mouse resizing tests + +local exp_x = 400 +local exp_y = 150 +local exp_w = 400 +local exp_h = 400 + +local function assert_expected_geometry(c) + assert_geometry(c, exp_x, exp_y, exp_w, exp_h) +end + +local drag_amount = 50 +local exp_mouse_x +local exp_mouse_y + +for _,rc in ipairs(resize_corners) do + table.insert(steps, function() + local c = client.get()[1] + + assert_expected_geometry(c) + + local corner_pos = placement[rc.corner](mouse, {parent = c, pretend = true}) + exp_mouse_x = corner_pos.x + rc.bx * 3 + exp_mouse_y = corner_pos.y + rc.by * 3 + + -- Click the edge of the window to start a mouse resizing transaction + click(exp_mouse_x, exp_mouse_y) + + return true + end) + + table.insert(steps, function() + if not signal_buffer["request::mouse_resize"] then return end + + assert_signal("request::mouse_resize", function(context, args) + assert(context == "ewmh") + assert(args.button == 1) + assert(args.mouse_pos.x == exp_mouse_x) + assert(args.mouse_pos.y == exp_mouse_y) + end) + + -- Resize the window + drag(exp_mouse_x + drag_amount, exp_mouse_y + drag_amount) + exp_x = exp_x + rc.dx * drag_amount + exp_y = exp_y + rc.dy * drag_amount + exp_w = exp_w + rc.dw * drag_amount + exp_h = exp_h + rc.dh * drag_amount + + return true + end) + + table.insert(steps, function() + local c = client.get()[1] + + assert_expected_geometry(c) + + -- Release the mouse to end the resizing + unclick() + + return true + end) + + table.insert(steps, function() + local c = client.get()[1] + + if mousegrabber.isrunning() then return end + + assert_expected_geometry(c) + + -- Window should no longer follow mouse + drag(exp_mouse_x + 30, exp_mouse_y - 20) + + return true + end) +end + +require("_runner").run_steps(steps) + +-- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80