commit c1c6a759f4557e4a063fbffdf781d573141212aa Author: Xinhao Yuan Date: Thu Jul 4 17:32:05 2019 -0400 initial diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..530a3e8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2019 Xinhao Yuan + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d854097 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# layout-machi + +A simple and static layout for Awesome with a rapid interactive layout editor. + +## Why? + +1. Dynamic tiling is an overkill, since tiling is only useful for persistent windows, and people extensively use hibernate/sleep these days. +2. I want to have a flexible layout such that I can quickly adjust to whatever I need. + +## Use the layout + +Use `layout-machi.layout.create_layout([LAYOUT_NAME}, [DEFAULT_REGIONS])` to instantiate the layout. +For example: + +``` +layout-machi.layout.create_layout("default", {}) +``` + +Creates a layout with no regions + +## Use the editor + +Call `layout-machi.editor.start_editor(data)` to enter the editor for the current layout (given it is a machi instance). +`data` is am object for storing the history of the editing, initially `{}`. +The editor starts with the open area of the entire workarea, taking command to split the current area into multiple sub-areas, then editing each of them. +The editor is keyboard driven, accepting a number of command keys. +Before each command, you can optionally provide at most 2 digits for parameters (A, B) of the command. +By default A = B = 1. + +1. `Up`/`Down`: restore to the history command sequence +2. `h`/`v`: split the current region horizontally/vertically into 2 regions. The split will respect the ratio A:B. +3. `w`: Take two parameters (A, B), and split the current region equally into A columns and B rows. If both A and B is 1, behave the same as `Space` without parameters. +4. `s`: shift the current editing region with other open sibling regions. +5. `Space` or `-`: Without parameters, close the current region and move to the next open region. With parameters, set the maximum depth of splitting (default is 2). +6. `Enter`/`.`: close all open regions. When all regions are closed, press `Enter` will save the layout and exit the editor. +7. `Backspace`: undo the last command. +8. `Escape`: exit the editor without saving the layout. + +## Other functions + +`layout-machi.editor.cycle_region(c)` will fit a floating client into the closest region, then cycle through all regions. + +## Demos: + +I used `Super + /` for editor and `Super + Tab` for fitting the windows. + + +h-v + +``` +11 22 +11 22 +11 +11 33 +11 33 +``` + +![][https://i.imgur.com/QbvMRTW.gif] + + +hvv (or 22w) + +``` +11 33 +11 33 + +22 44 +22 44 +``` + +![][https://i.imgur.com/xJebxcF.gif] + + +history + +![][https://i.imgur.com/gzFr48V.gif] + +## TODO + + - Make history persistent + +## License + +Apache 2.0 --- See LICENSE diff --git a/editor.lua b/editor.lua new file mode 100644 index 0000000..43d10c6 --- /dev/null +++ b/editor.lua @@ -0,0 +1,537 @@ +local api = { + beautiful = require("beautiful"), + wibox = require("wibox"), + awful = require("awful"), + screen = require("awful.screen"), + layout = require("awful.layout"), + keygrabber = require("awful.keygrabber"), + naughty = require("naughty"), + gears = require("gears"), + dpi = require("beautiful.xresources").apply_dpi, +} + +local gap = api.beautiful.useless_gap or 0 +local label_font_family = api.beautiful.get_font( + api.beautiful.mono_font or api.beautiful.font):get_family() +local label_size = api.dpi(30) +local info_size = api.dpi(60) +-- colors are in rgba +local border_color = "#ffffffc0" +local active_color = "#6c7ea780" +local open_color = "#00000080" +local closed_color = "#00000080" +local init_max_depth = 2 + +function is_tiling(c) + return + not (c.tomb_floating or c.floating or c.maximized_horizontal or c.maximized_vertical or c.maximized or c.fullscreen) +end + +function set_tiling(c) + c.floating = false + c.maximized = false + c.maximized_vertical = false + c.maximized_horizontal = false + c.fullscreen = false +end + +function min(a, b) + if a < b then return a else return b end +end + +function max(a, b) + if a < b then return b else return a end +end + +function set_region(c, r) + c.floating = false + c.maximized = false + c.fullscreen = false + c.machi_region = r + api.layout.arrange(c.screen) +end + +-- find the best region for the area +function fit_region(c, regions) + local choice = 1 + local choice_value = nil + local c_area = c.width * c.height + for i, a in ipairs(regions) do + local x_cap = max(0, min(c.x + c.width, a.x + a.width) - max(c.x, a.x)) + local y_cap = max(0, min(c.y + c.height, a.y + a.height) - max(c.y, a.y)) + local cap = x_cap * y_cap + -- -- a cap b / a cup b + -- local cup = c_area + a.width * a.height - cap + -- if cup > 0 then + -- local itx_ratio = cap / cup + -- if choice_value == nil or choice_value < itx_ratio then + -- choice_value = itx_ratio + -- choice = i + -- end + -- end + -- a cap b + if choice_value == nil or choice_value < cap then + choice = i + choice_value = cap + end + end + return choice +end + +function cycle_region(c) + layout = api.layout.get(c.screen) + regions = layout.get_regions and layout.get_regions() + if type(regions) ~= "table" or #regions < 1 then + c.float = true + return + end + current_region = c.machi_region or 1 + if not is_tiling(c) then + -- find out which region has the most intersection, calculated by a cap b / a cup b + c.machi_region = fit_region(c, regions) + set_tiling(c) + elseif current_region >= #regions then + c.machi_region = 1 + else + c.machi_region = current_region + 1 + end + api.layout.arrange(c.screen) +end + +function _area_tostring(wa) + return "{x:" .. tostring(wa.x) .. ",y:" .. tostring(wa.y) .. ",w:" .. tostring(wa.width) .. ",h:" .. tostring(wa.height) .. "}" +end + +function shrink_area_with_gap(a, gap) + return { x = a.x + (a.bl and 0 or gap / 2), y = a.y + (a.bu and 0 or gap / 2), + width = a.width - (a.bl and 0 or gap / 2) - (a.br and 0 or gap / 2), + height = a.height - (a.bu and 0 or gap / 2) - (a.bd and 0 or gap / 2) } +end + +function start_editor(data) + if data.cmds == nil then + data.cmds = {} + end + + local cmd_index = #data.cmds + 1 + data.cmds[cmd_index] = "" + + local screen = api.screen.focused() + local init_area = { + x = screen.workarea.x, + y = screen.workarea.y, + width = screen.workarea.width, + height = screen.workarea.height, + border = 15, + depth = 0, + -- we do not want to rely on bitop + bl = true, br = true, bu = true, bd = true, + } + local kg + local infobox = api.wibox({ + x = screen.workarea.x, + y = screen.workarea.y, + width = screen.workarea.width, + height = screen.workarea.height, + bg = "#ffffff00", + opacity = 1, + ontop = true + }) + infobox.visible = true + + local closed_areas + local open_areas + local history + local num_1 + local num_2 + local max_depth + local current_info + local current_cmd + local to_exit + local to_apply + + local function init() + closed_areas = {} + open_areas = {init_area} + history = {} + num_1 = nil + num_2 = nil + max_depth = init_max_depth + current_info = "" + current_cmd = "" + to_exit = false + to_apply = false + end + + local function draw_info(context, cr, width, height) + cr:set_source_rgba(0, 0, 0, 0) + cr:rectangle(0, 0, width, height) + cr:fill() + + local msg, ext + + for i, a in ipairs(closed_areas) do + local sa = shrink_area_with_gap(a, gap) + cr:rectangle(sa.x, sa.y, sa.width, sa.height) + cr:clip() + cr:set_source(api.gears.color(closed_color)) + cr:rectangle(sa.x, sa.y, sa.width, sa.height) + cr:fill() + cr:set_source(api.gears.color(border_color)) + cr:rectangle(sa.x, sa.y, sa.width, sa.height) + cr:set_line_width(10.0) + cr:stroke() + + cr:select_font_face(label_font_family, "normal", "normal") + cr:set_font_size(label_size) + cr:set_font_face(cr:get_font_face()) + msg = tostring(i) + ext = cr:text_extents(msg) + cr:set_source_rgba(1, 1, 1, 1) + cr:move_to(sa.x + sa.width / 2 - ext.width / 2 - ext.x_bearing, sa.y + sa.height / 2 - ext.height / 2 - ext.y_bearing) + cr:show_text(msg) + cr:reset_clip() + end + + for i, a in ipairs(open_areas) do + local sa = shrink_area_with_gap(a, gap) + cr:rectangle(sa.x, sa.y, sa.width, sa.height) + cr:clip() + if i == #open_areas then + cr:set_source(api.gears.color(active_color)) + else + cr:set_source(api.gears.color(open_color)) + end + cr:rectangle(sa.x, sa.y, sa.width, sa.height) + cr:fill() + + cr:set_source(api.gears.color(border_color)) + cr:rectangle(sa.x, sa.y, sa.width, sa.height) + cr:set_line_width(10.0) + if i ~= #open_areas then + cr:set_dash({5, 5}, 0) + cr:stroke() + cr:set_dash({}, 0) + else + cr:stroke() + end + cr:reset_clip() + end + + cr:select_font_face(label_font_family, "normal", "normal") + cr:set_font_size(info_size) + cr:set_font_face(cr:get_font_face()) + msg = current_info + ext = cr:text_extents(msg) + cr:move_to(width / 2 - ext.width / 2 - ext.x_bearing, height / 2 - ext.height / 2 - ext.y_bearing) + cr:text_path(msg) + cr:set_source_rgba(1, 1, 1, 1) + cr:fill() + cr:move_to(width / 2 - ext.width / 2 - ext.x_bearing, height / 2 - ext.height / 2 - ext.y_bearing) + cr:text_path(msg) + cr:set_source_rgba(0, 0, 0, 1) + cr:set_line_width(2.0) + cr:stroke() + end + + local function push_history() + history[#history + 1] = {#closed_areas, #open_areas, {}, current_info, current_cmd, max_depth, num_1, num_2} + end + + local function discard_history() + table.remove(history, #history) + end + + local function pop_history() + if #history == 0 then return end + for i = history[#history][1] + 1, #closed_areas do + table.remove(closed_areas, #closed_areas) + end + + for i = history[#history][2] + 1, #open_areas do + table.remove(open_areas, #open_areas) + end + + for i = 1, #history[#history][3] do + open_areas[history[#history][2] - i + 1] = history[#history][3][i] + end + + current_info = history[#history][4] + current_cmd = history[#history][5] + max_depth = history[#history][6] + num_1 = history[#history][7] + num_2 = history[#history][8] + + table.remove(history, #history) + end + + local function pop_open_area() + local a = open_areas[#open_areas] + table.remove(open_areas, #open_areas) + local idx = history[#history][2] - #open_areas + -- only save when the position has been firstly poped + if idx > #history[#history][3] then + history[#history][3][#history[#history][3] + 1] = a + end + return a + end + + local function refresh() + print("closed areas:") + for i, a in ipairs(closed_areas) do + print(" " .. _area_tostring(a)) + end + print("open areas:") + for i, a in ipairs(open_areas) do + print(" " .. _area_tostring(a)) + end + infobox.bgimage = draw_info + end + + local function handle_split(method, alt) + if num_1 == nil then num_1 = 1 end + if num_2 == nil then num_2 = 1 end + + if alt then + local tmp = num_1 + num_1 = num_2 + num_2 = tmp + end + + local a = pop_open_area() + local lu, rd + + print("split " .. method .. " " .. tostring(alt) .. " " .. _area_tostring(a)) + + if method == "h" then + lu = { + x = a.x, y = a.y, + width = a.width / (num_1 + num_2) * num_1, height = a.height, + depth = a.depth + 1, + bl = a.bl, br = false, bu = a.bu, bd = a.bd, + } + rd = { + x = a.x + lu.width, y = a.y, + width = a.width - lu.width, height = a.height, + depth = a.depth + 1, + bl = false, br = a.br, bu = a.bu, bd = a.bd, + } + open_areas[#open_areas + 1] = rd + open_areas[#open_areas + 1] = lu + elseif method == "v" then + lu = { + x = a.x, y = a.y, + width = a.width, height = a.height / (num_1 + num_2) * num_1, + depth = a.depth + 1, + bl = a.bl, br = a.br, bu = a.bu, bd = false + } + rd = { + x = a.x, y = a.y + lu.height, + width = a.width, height = a.height - lu.height, + depth = a.depth + 1, + bl = a.bl, br = a.br, bu = false, bd = a.bd, + } + open_areas[#open_areas + 1] = rd + open_areas[#open_areas + 1] = lu + elseif method == "w" then + local x_interval = a.width / num_1 + local y_interval = a.height / num_2 + for y = num_2, 1, -1 do + for x = num_1, 1, -1 do + local r = { + x = a.x + x_interval * (x - 1), + y = a.y + y_interval * (y - 1), + width = x_interval, + height = y_interval, + depth = a.depth + 1 + } + if x == 1 then r.bl = a.bl else r.bl = false end + if x == num_1 then r.br = a.br else r.br = false end + if y == 1 then r.bu = a.bu else r.bu = false end + if y == num_2 then r.bd = a.bd else r.bd = false end + open_areas[#open_areas + 1] = r + end + end + elseif method == "P" then + -- XXX + end + + num_1 = nil + num_2 = nil + end + + local function cleanup() + infobox.visible = false + end + + local function push_area() + closed_areas[#closed_areas + 1] = pop_open_area() + infobox.bgimage = draw_info + end + + local function handle_command(key) + if key == "h" or key == "H" then + handle_split("h", key == "H") + elseif key == "v" or key == "V" then + handle_split("v", key == "V") + elseif key == "w" or key == "W" then + push_history() + if num_1 == nil and num_2 == nil then + push_area() + else + handle_split("w", key == "W") + end + elseif key == "p" or key == "P" then + handle_split("p", key == "P") + elseif key == "s" or key == "S" then + if #open_areas > 0 then + key = "s" + local top = pop_open_area() + local t = {} + while #open_areas > 0 and open_areas[#open_areas].depth == top.depth do + t[#t + 1] = pop_open_area() + end + open_areas[#open_areas + 1] = top + for i = #t, 1, -1 do + open_areas[#open_areas + 1] = t[i] + end + num_1 = nil + num_2 = nil + else + return nil + end + elseif key == " " or key == "-" then + key = "-" + if num_1 ~= nil then + max_depth = num_1 + num_1 = nil + num_2 = nil + else + push_area() + end + elseif key == "Return" or key == "." then + key = "." + while #open_areas > 0 do + push_area() + end + elseif tonumber(key) ~= nil then + local v = tonumber(key) + if num_1 == nil then + num_1 = v + elseif num_2 == nil then + num_2 = v + else + return nil + end + else + return nil + end + + while #open_areas > 0 and open_areas[#open_areas].depth >= max_depth do + push_area() + end + + return key + end + + print("interactive layout editing starts") + + init() + refresh() + + kg = keygrabber.run(function (mod, key, event) + if event == "release" then + return + end + + if key == "BackSpace" then + pop_history() + elseif key == "Escape" then + to_exit = true + elseif key == "Up" or key == "Down" then + if current_cmd ~= data.cmds[cmd_index] then + data.cmds[#data.cmds] = current_cmd + end + + if key == "Up" and cmd_index > 1 then + cmd_index = cmd_index - 1 + elseif key == "Down" and cmd_index < #data.cmds then + cmd_index = cmd_index + 1 + end + + print("restore history #" .. tostring(cmd_index) .. ":" .. data.cmds[cmd_index]) + init() + for i = 1, #data.cmds[cmd_index] do + cmd = data.cmds[cmd_index]:sub(i, i) + + push_history() + local ret = handle_command(cmd) + + current_info = current_info .. ret + current_cmd = current_cmd .. ret + end + + if #open_areas == 0 then + current_info = current_info .. " (enter to save)" + end + elseif #open_areas > 0 then + push_history() + local ret = handle_command(key) + if ret ~= nil then + current_info = current_info .. ret + current_cmd = current_cmd .. ret + else + discard_history() + end + + if #open_areas == 0 then + current_info = current_info .. " (enter to save)" + end + else + if key == "Return" then + table.remove(data.cmds, #data.cmds) + if cmd_index <= #data.cmds and current_cmd == data.cmds[cmd_index] then + table.remove(data.cmds, cmd_index) + end + data.cmds[#data.cmds + 1] = current_cmd + current_info = "Saved!" + to_exit = true + to_apply = true + end + end + + refresh() + + if to_exit then + print("interactive layout editing ends") + if to_apply then + layout = api.layout.get(screen) + if layout.set_regions then + local areas_with_gap = {} + for _, a in ipairs(closed_areas) do + areas_with_gap[#areas_with_gap + 1] = shrink_area_with_gap(a, gap) + end + layout.set_regions(areas_with_gap) + api.layout.arrange(screen) + end + api.gears.timer{ + timeout = 1, + autostart = true, + singleshot = true, + callback = cleanup + } + else + cleanup() + end + keygrabber.stop(kg) + return + end + end) +end + +return + { + set_region = set_region, + cycle_region = cycle_region, + start_editor = start_editor, + } diff --git a/layout.lua b/layout.lua new file mode 100644 index 0000000..17ba71a --- /dev/null +++ b/layout.lua @@ -0,0 +1,57 @@ +function do_arrange(p, priv) + local wa = p.workarea + local cls = p.clients + local regions = priv.regions + + for i, c in ipairs(cls) do + if c.floating then + print("Ignore client " .. tostring(c)) + else + local region + if c.machi_region == nil then + c.machi_region = 1 + region = 1 + elseif c.machi_region > #regions or c.machi_region <= 1 then + region = 1 + else + region = c.machi_region + end + + p.geometries[c] = { + x = regions[region].x, + y = regions[region].y, + width = regions[region].width, + height = regions[region].height, + } + + print("Put client " .. tostring(c) .. " to region " .. region) + + end + end +end + +function create_layout(name, regions) + local priv = {} + + local function set_regions(regions) + priv.regions = regions + end + + local function get_regions() + return priv.regions + end + + set_regions(regions) + + return { + name = "machi[" .. name .. "]", + arrange = function (p) do_arrange(p, priv) end, + get_region_count = function () return #priv.regions end, + set_regions = set_regions, + get_regions = get_regions, + } +end + +return { + create_layout = create_layout, +}