local api = { beautiful = require("beautiful"), wibox = require("wibox"), awful = require("awful"), screen = require("awful.screen"), layout = require("awful.layout"), naughty = require("naughty"), gears = require("gears"), gfs = require("gears.filesystem"), lgi = require("lgi"), dpi = require("beautiful.xresources").apply_dpi, } local ERROR = 2 local WARNING = 1 local INFO = 0 local DEBUG = -1 local module = { log_level = WARNING, nested_layouts = { ["0"] = api.layout.suit.tile, ["1"] = api.layout.suit.spiral, ["2"] = api.layout.suit.fair, ["3"] = api.layout.suit.fair.horizontal, }, } local function log(level, msg) if level > module.log_level then print(msg) end end local function with_alpha(col, alpha) local r, g, b _, r, g, b, _ = col:get_rgba() return api.lgi.cairo.SolidPattern.create_rgba(r, g, b, alpha) end local function max(a, b) if a < b then return b else return a end end local 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 local function set_tiling(c) c.floating = false c.maximized = false c.maximized_vertical = false c.maximized_horizontal = false c.fullscreen = false end local function parse_arg_string(s, default) local ret = {} if #s == 0 then return ret end local index = 1 local comma_mode = s:find(",") ~= nil local p = index while index <= #s do if comma_mode then if s:sub(index, index) == "," then local r = tonumber(s:sub(p, index - 1)) if r == nil then ret[#ret + 1] = default else ret[#ret + 1] = r end p = index + 1 end else local r = tonumber(s:sub(index, index)) if r == nil then ret[#ret + 1] = default else ret[#ret + 1] = r end end index = index + 1 end if comma_mode then local r = tonumber(s:sub(p, index - 1)) if r == nil then ret[#ret + 1] = default else ret[#ret + 1] = r end p = index + 1 end return ret end local function test_parse_arg_string() local x = parse_arg_string("12a3", "aha") assert(#x == 4 and x[1] == 1 and x[2] == 2 and x[3] == "aha" and x[4] == 3) local x = parse_arg_string("12,a3,4", "aha") assert(#x == 3 and x[1] == 12 and x[2] == "aha" and x[3] == 4) end -- test_parse_arg_string() local function fair_split(total, shares, shares_sum) local ret = {} local acc = 0 local acc_ret = 0 if shares_sum == nil then shares_sum = 0 for i = 1, #shares do shares_sum = shares_sum + shares[i] end end for i = 1, #shares do acc = acc + shares[i] ret[i] = i < #shares and math.floor(total / shares_sum * acc - acc_ret + 0.5) or total - acc_ret acc_ret = acc_ret + ret[i] end return ret end local function min(a, b) if a < b then return a else return b end end local function max(a, b) if a < b then return b else return a end end local function _area_tostring(wa) return "{x:" .. tostring(wa.x) .. ",y:" .. tostring(wa.y) .. ",w:" .. tostring(wa.width) .. ",h:" .. tostring(wa.height) .. "}" end local function shrink_area_with_gap(a, inner_gap, outer_gap) return { x = a.x + (a.bl and outer_gap or inner_gap / 2), y = a.y + (a.bu and outer_gap or inner_gap / 2), width = a.width - (a.bl and outer_gap or inner_gap / 2) - (a.br and outer_gap or inner_gap / 2), height = a.height - (a.bu and outer_gap or inner_gap / 2) - (a.bd and outer_gap or inner_gap / 2), layout = a.layout } end function module.restore_data(data) if data.history_file then local file, err = io.open(data.history_file, "r") if err then log(INFO, "cannot read history from " .. data.history_file) else data.cmds = {} data.last_cmd = {} local last_layout_name for line in file:lines() do if line:sub(1, 1) == "+" then last_layout_name = line:sub(2, #line) else if last_layout_name ~= nil then log(DEBUG, "restore last cmd " .. line .. " for " .. last_layout_name) data.last_cmd[last_layout_name] = line last_layout_name = nil else log(DEBUG, "restore cmd " .. line) data.cmds[#data.cmds + 1] = line end end end file:close() end end return data end function module.create(data) if data == nil then data = module.restore_data({ history_file = api.gfs.get_cache_dir() .. "/history_machi", history_save_max = 100, }) end if data.cmds == nil then data.cmds = {} end if data.last_cmd == nil then data.last_cmd = {} end local init_max_depth = 2 local closed_areas local open_areas local history local arg_str local max_depth local current_info local current_cmd local pending_op local to_exit local to_apply local function init(init_area, extend) closed_areas = {} open_areas = { { x = init_area.x - extend, y = init_area.y - extend, width = init_area.width + extend * 2, height = init_area.height + extend * 2, depth = 0, group_id = 0, bl = true, br = true, bu = true, bd = true, } } history = {} arg_str = "" max_depth = init_max_depth current_info = "" current_cmd = "" pending_op = nil to_exit = false to_apply = false end local function push_history() if history == nil then return end history[#history + 1] = {#closed_areas, #open_areas, {}, current_info, current_cmd, pending_op, max_depth, arg_str} end local function discard_history() if history == nil then return end table.remove(history, #history) end local function pop_history() if history == nil or #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] pending_op = history[#history][6] max_depth = history[#history][7] arg_str = 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) if history == nil or #history == 0 then return a end 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 push_area() closed_areas[#closed_areas + 1] = pop_open_area() end local function push_children(c) for i = #c, 1, -1 do if c[i].x ~= math.floor(c[i].x) or c[i].y ~= math.floor(c[i].y) or c[i].width ~= math.floor(c[i].width) or c[i].height ~= math.floor(c[i].height) then log(WARNING, "splitting yields floating area " .. _area_tostring(c[i])) end open_areas[#open_areas + 1] = c[i] end end local op_count = 0 local function handle_op(method) op_count = op_count + 1 local l = method:lower() local alt = method ~= l method = l log(DEBUG, "op " .. method .. " " .. tostring(alt) .. " " .. arg_str) if method == "h" or method == "v" then local a = pop_open_area() local args = parse_arg_string(arg_str, 0) if #args == 0 then args = {1, 1} elseif #args == 1 then args[2] = 1 end local total = 0 local shares = { } for i = 1, #args do local arg if not alt then arg = args[i] else arg = args[#args - i + 1] end if arg < 1 then arg = 1 end total = total + arg shares[i] = arg end local children = {} if method == "h" then shares = fair_split(a.width, shares, total) for i = 1, #shares do local child = { x = i == 1 and a.x or children[#children].x + children[#children].width, y = a.y, width = shares[i], height = a.height, depth = a.depth + 1, group_id = op_count, bl = i == 1 and a.bl or false, br = i == #shares and a.br or false, bu = a.bu, bd = a.bd, } children[#children + 1] = child end else shares = fair_split(a.height, shares, total) for i = 1, #shares do local child = { x = a.x, y = i == 1 and a.y or children[#children].y + children[#children].height, width = a.width, height = shares[i], depth = a.depth + 1, group_id = op_count, bl = a.bl, br = a.br, bu = i == 1 and a.bu or false, bd = i == #shares and a.bd or false, } children[#children + 1] = child end end push_children(children) elseif method == "w" then local a = pop_open_area() local args = parse_arg_string(arg_str, 0) if #args == 0 then args = {1, 1} elseif #args == 1 then args[2] = 1 end local h_split, v_split if alt then h_split = args[2] v_split = args[1] else h_split = args[1] v_split = args[2] end if h_split < 1 then h_split = 1 end if v_split < 1 then v_split = 1 end local x_shares = {} local y_shares = {} for i = 1, h_split do x_shares[i] = 1 end for i = 1, v_split do y_shares[i] = 1 end x_shares = fair_split(a.width, x_shares, h_split) y_shares = fair_split(a.height, y_shares, v_split) local children = {} for y_index = 1, v_split do for x_index = 1, h_split do local r = { x = x_index == 1 and a.x or children[#children].x + children[#children].width, y = y_index == 1 and a.y or (x_index == 1 and children[#children].y + children[#children].height or children[#children].y), width = x_shares[x_index], height = y_shares[y_index], depth = a.depth + 1, group_id = op_count, } if x_index == 1 then r.bl = a.bl else r.bl = false end if x_index == h_split then r.br = a.br else r.br = false end if y_index == 1 then r.bu = a.bu else r.bu = false end if y_index == v_split then r.bd = a.bd else r.bd = false end children[#children + 1] = r end end local merged_children = {} local start_index = 1 for i = 3, #args - 1, 2 do -- find the first index that is not merged while start_index <= #children and children[start_index] == false do start_index = start_index + 1 end if start_index > #children or children[start_index] == false then break end local x = (start_index - 1) % h_split local y = math.floor((start_index - 1) / h_split) local w = args[i] local h = args[i + 1] if w < 1 then w = 1 end if h == nil or h < 1 then h = 1 end if alt then local tmp = w w = h h = tmp end if x + w > h_split then w = h_split - x end if y + h > v_split then h = v_split - y end local end_index = start_index for ty = y, y + h - 1 do local succ = true for tx = x, x + w - 1 do if children[ty * h_split + tx + 1] == false then succ = false break elseif ty == y then end_index = ty * h_split + tx + 1 end end if not succ then break elseif ty > y then end_index = ty * h_split + x + w end end local r = { x = children[start_index].x, y = children[start_index].y, width = children[end_index].x + children[end_index].width - children[start_index].x, height = children[end_index].y + children[end_index].height - children[start_index].y, bu = children[start_index].bu, bl = children[start_index].bl, bd = children[end_index].bd, br = children[end_index].br, depth = a.depth + 1, group_id = op_count } merged_children[#merged_children + 1] = r for ty = y, y + h - 1 do local succ = true for tx = x, x + w - 1 do local index = ty * h_split + tx + 1 if index <= end_index then children[index] = false else break end end end end -- clean up children, remove all `false' local j = 1 for i = 1, #children do if children[i] ~= false then children[j] = children[i] j = j + 1 end end for i = #children, j, -1 do table.remove(children, i) end push_children(merged_children) push_children(children) elseif method == "d" then local a = pop_open_area() local shares = parse_arg_string(arg_str, 0) local x_shares = {} local y_shares = {} local current = x_shares for i = 1, #shares do if not alt then arg = shares[i] else arg = shares[#shares - i + 1] end if arg < 1 then if current == x_shares then current = y_shares else break end else current[#current + 1] = arg end end if #x_shares == 0 then open_areas[#open_areas + 1] = a return elseif #y_shares == 0 then y_shares = {1} end x_shares = fair_split(a.width, x_shares) y_shares = fair_split(a.height, y_shares) local children = {} for y_index = 1, #y_shares do for x_index = 1, #x_shares do local r = { x = x_index == 1 and a.x or children[#children].x + children[#children].width, y = y_index == 1 and a.y or (x_index == 1 and children[#children].y + children[#children].height or children[#children].y), width = x_shares[x_index], height = y_shares[y_index], depth = a.depth + 1, group_id = op_count, } if x_index == 1 then r.bl = a.bl else r.bl = false end if x_index == #x_shares then r.br = a.br else r.br = false end if y_index == 1 then r.bu = a.bu else r.bu = false end if y_index == #y_shares then r.bd = a.bd else r.bd = false end children[#children + 1] = r end end push_children(children) elseif method == "s" then if #open_areas > 0 then local times = arg_str == "" and 1 or tonumber(arg_str) local t = {} while #open_areas > 0 do t[#t + 1] = pop_open_area() end for i = #t, 1, -1 do open_areas[#open_areas + 1] = t[(i + times - 1) % #t + 1] end end elseif method == "t" then local n = tonumber(arg_str) if n ~= nil then max_depth = n end elseif method == "x" then push_area() closed_areas[#closed_areas].layout = module.nested_layouts[arg_str] elseif method == "-" then push_area() elseif method == "." then while #open_areas > 0 do push_area() end elseif method == "/" then pop_open_area() elseif method == ";" then -- nothing end while #open_areas > 0 and open_areas[#open_areas].depth >= max_depth do push_area() end arg_str = "" end local key_translate_tab = { ["Return"] = ".", [" "] = "-", } -- 3 for taking the arg string and an open area -- 2 for taking an open area -- 1 for taking nothing -- 0 for args local ch_info = { ["h"] = 3, ["H"] = 3, ["v"] = 3, ["V"] = 3, ["w"] = 3, ["W"] = 3, ["d"] = 3, ["D"] = 3, ["s"] = 3, ["S"] = 3, ["t"] = 3, ["T"] = 3, ["x"] = 3, ["-"] = 2, ["/"] = 2, ["."] = 1, [";"] = 1, ["0"] = 0, ["1"] = 0, ["2"] = 0, ["3"] = 0, ["4"] = 0, ["5"] = 0, ["6"] = 0, ["7"] = 0, ["8"] = 0, ["9"] = 0, [","] = 0, } local function handle_ch(key) if key_translate_tab[key] ~= nil then key = key_translate_tab[key] end local t = ch_info[key] if t == nil then return nil elseif t == 3 then if pending_op ~= nil then handle_op(pending_op) pending_op = nil end if #open_areas == 0 then return nil end if arg_str == "" then pending_op = key else handle_op(key) end elseif t == 2 or t == 1 then if pending_op ~= nil then handle_op(pending_op) pending_op = nil end if #open_areas == 0 and t == 2 then return nil end handle_op(key) elseif t == 0 then arg_str = arg_str .. key end return key end local function set_gap(inner_gap, outer_gap) data.inner_gap = inner_gap data.outer_gap = outer_gap end local function start_interactive(screen, layout) local outer_gap = data.outer_gap or data.gap or api.beautiful.useless_gap * 2 or 0 local inner_gap = data.inner_gap or data.gap or api.beautiful.useless_gap * 2 or 0 local label_font_family = api.beautiful.get_font( api.beautiful.font):get_family() local label_size = api.dpi(30) local info_size = api.dpi(60) -- colors are in rgba local border_color = with_alpha(api.gears.color( api.beautiful.machi_editor_border_color or api.beautiful.border_focus), api.beautiful.machi_editor_border_opacity or 0.75) local active_color = with_alpha(api.gears.color( api.beautiful.machi_editor_active_color or api.beautiful.bg_focus), api.beautiful.machi_editor_active_opacity or 0.5) local open_color = with_alpha(api.gears.color( api.beautiful.machi_editor_open_color or api.beautiful.bg_normal), api.beautiful.machi_editor_open_opacity or 0.5) local closed_color = open_color screen = screen or api.screen.focused() layout = layout or api.layout.get(screen) local tag = screen.selected_tag if layout.machi_set_cmd == nil then api.naughty.notify({ text = "The layout to edit is not machi", timeout = 3, }) return end local cmd_index = #data.cmds + 1 data.cmds[cmd_index] = "" local start_x = screen.workarea.x local start_y = screen.workarea.y local kg local infobox = api.wibox({ screen = screen, x = screen.workarea.x, y = screen.workarea.y, width = screen.workarea.width, height = screen.workarea.height, bg = "#ffffff00", opacity = 1, ontop = true, type = "dock", }) infobox.visible = true local function cleanup() infobox.visible = 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, inner_gap, inner_gap / 2) local to_highlight = false if pending_op ~= nil then to_highlight = a.group_id == op_count end cr:rectangle(sa.x - start_x, sa.y - start_y, sa.width, sa.height) cr:clip() if to_highlight then cr:set_source(active_color) else cr:set_source(closed_color) end cr:rectangle(sa.x - start_x, sa.y - start_y, sa.width, sa.height) cr:fill() cr:set_source(border_color) cr:rectangle(sa.x - start_x, sa.y - start_y, sa.width, sa.height) cr:set_line_width(10.0) cr:stroke() cr:reset_clip() end for i, a in ipairs(open_areas) do local sa = shrink_area_with_gap(a, inner_gap, inner_gap / 2) local to_highlight = false if pending_op == nil then to_highlight = i == #open_areas else to_highlight = a.group_id == op_count end cr:rectangle(sa.x - start_x, sa.y - start_y, sa.width, sa.height) cr:clip() if i == #open_areas then cr:set_source(active_color) else cr:set_source(open_color) end cr:rectangle(sa.x - start_x, sa.y - start_y, sa.width, sa.height) cr:fill() cr:set_source(border_color) cr:rectangle(sa.x - start_x, sa.y - start_y, sa.width, sa.height) cr:set_line_width(10.0) if to_highlight then cr:stroke() else cr:set_dash({5, 5}, 0) cr:stroke() cr:set_dash({}, 0) 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 refresh() log(DEBUG, "closed areas:") for i, a in ipairs(closed_areas) do log(DEBUG, " " .. _area_tostring(a)) end log(DEBUG, "open areas:") for i, a in ipairs(open_areas) do log(DEBUG, " " .. _area_tostring(a)) end infobox.bgimage = draw_info end log(DEBUG, "interactive layout editing starts") init(screen.workarea, inner_gap / 2 - outer_gap) refresh() kg = api.awful.keygrabber.run( function (mod, key, event) if event == "release" then return end local ok, err = pcall( function () if pending_op ~= nil then pop_history() end if key == "BackSpace" then pop_history() elseif key == "Escape" then table.remove(data.cmds, #data.cmds) 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 log(DEBUG, "restore history #" .. tostring(cmd_index) .. ":" .. data.cmds[cmd_index]) init(screen.workarea, inner_gap / 2 - outer_gap) for i = 1, #data.cmds[cmd_index] do local cmd = data.cmds[cmd_index]:sub(i, i) push_history() local ret = handle_ch(cmd) if ret == nil then log(WARNING, "ret is nil") else current_info = current_info .. ret current_cmd = current_cmd .. ret end end if #open_areas == 0 then current_info = current_info .. " (enter to save)" end elseif #open_areas > 0 then push_history() local ret = handle_ch(key) if ret ~= nil then current_info = current_info .. ret current_cmd = current_cmd .. ret else pop_history() end if #open_areas == 0 then current_info = current_info .. " (enter to apply)" end else if key == "Return" then table.remove(data.cmds, #data.cmds) -- remove duplicated entries local j = 1 for i = 1, #data.cmds do if data.cmds[i] ~= current_cmd then data.cmds[j] = data.cmds[i] j = j + 1 end end for i = #data.cmds, j, -1 do table.remove(data.cmds, i) end -- bring the current cmd to the front data.cmds[#data.cmds + 1] = current_cmd local instance_name, persistent = layout.machi_get_instance_info(tag) if persistent then data.last_cmd[instance_name] = current_cmd if data.history_file then local file, err = io.open(data.history_file, "w") if err then log(ERROR, "cannot save history to " .. data.history_file) else for i = max(1, #data.cmds - data.history_save_max + 1), #data.cmds do log(DEBUG, "save cmd " .. data.cmds[i]) file:write(data.cmds[i] .. "\n") end for name, cmd in pairs(data.last_cmd) do log(DEBUG, "save last cmd " .. cmd .. " for " .. name) file:write("+" .. name .. "\n" .. cmd .. "\n") end end file:close() end current_info = "Saved!" else current_info = "Applied!" end to_exit = true to_apply = true end end if not to_exit and pending_op ~= nil then push_history() handle_op(pending_op) end refresh() if to_exit then log(DEBUG, "interactive layout editing ends") if to_apply then layout.machi_set_cmd(current_cmd, tag) api.layout.arrange(screen) api.gears.timer{ timeout = 1, autostart = true, singleshot = true, callback = cleanup, } else cleanup() end end end) if not ok then log(ERROR, "Getting error in keygrabber: " .. err) to_exit = true cleanup() end if to_exit then api.awful.keygrabber.stop(kg) end end ) end local function run_cmd(init_area, cmd) local outer_gap = data.outer_gap or data.gap or api.beautiful.useless_gap * 2 or 0 local inner_gap = data.inner_gap or data.gap or api.beautiful.useless_gap * 2 or 0 init(init_area, inner_gap / 2 - outer_gap) for i = 1, #cmd do handle_ch(cmd:sub(i, i)) end local areas_with_gap = {} for _, a in ipairs(closed_areas) do areas_with_gap[#areas_with_gap + 1] = shrink_area_with_gap(a, inner_gap, inner_gap / 2) end table.sort( areas_with_gap, function (a1, a2) local s1 = a1.width * a1.height local s2 = a2.width * a2.height if math.abs(s1 - s2) < 0.01 then return (a1.x + a1.y) < (a2.x + a2.y) else return s1 > s2 end end ) return areas_with_gap end local function get_last_cmd(name) return data.last_cmd[name] end return { start_interactive = start_interactive, run_cmd = run_cmd, get_last_cmd = get_last_cmd, set_gap = set_gap, } end module.default_editor = module.create() return module