layout-machi/engine.lua

947 lines
31 KiB
Lua
Raw Normal View History

-- area {
-- x, y, width, height
-- parent_id
-- parent_cid
-- parent_x_shares
-- parent_y_shares
-- inhabitable
-- hole (unique)
-- draft_mode (root only)
-- }
--
-- split {
-- method
-- x_shares
-- y_shares
-- children
-- }
--
-- share {weight, adjustment, dynamic, minimum}
local in_module = ...
-- Split a length by `measures`, such that each split respect the
-- weight [1], adjustment (user [2] + engine [3]) without breaking the minimum size [4].
--
-- The split algorithm has a worst case of O(n^2) where n = #shares,
-- which should be fine for practical usage of screen partitions.
-- Using geometric algorithm this can be optimized to O(n log n), but
-- I don't think it is worth.
-- Returns two values:
-- 1. the (accumulative) result if it is possible to give every share its minimum size, otherwise nil.
-- 2. any spare space to adjust without capping any share.
local function fair_split(length, shares)
local ret = {}
local normalized_adj = nil
local sum_weight
local sum_adj
local remaining = #shares
local spare = nil
repeat
local need_recompute = false
sum_weight = 0
sum_adj = 0
for i = 1, #shares do
if ret[i] == nil then
sum_weight = sum_weight + shares[i][1]
if normalized_adj then
sum_adj = sum_adj + normalized_adj[i]
end
end
end
if normalized_adj == nil then
normalized_adj = {}
for i = 1, #shares do
if sum_weight > shares[i][1] then
normalized_adj[i] = ((shares[i][2] or 0) + (shares[i][3] or 0)) * sum_weight / (sum_weight - shares[i][1])
else
normalized_adj[i] = 0
end
sum_adj = sum_adj + normalized_adj[i]
end
for i = 1, #shares do
local required = (shares[i][4] - normalized_adj[i]) * sum_weight / shares[i][1] + sum_adj
if spare == nil or spare > length - required then
spare = length - required
end
end
end
local capped_length = 0
for i = 1, #shares do
if ret[i] == nil then
local split = (length - sum_adj) * shares[i][1] / sum_weight + normalized_adj[i]
if split < shares[i][4] then
ret[i] = shares[i][4]
capped_length = capped_length + shares[i][4]
need_recompute = true
end
end
end
length = length - capped_length
until not need_recompute
if #shares == 1 or spare < 0 then
spare = 0
end
if remaining == 0 then
return nil, spare
end
local acc_weight = 0
local acc_adj = 0
local acc_ret = 0
for i = 1, #shares do
if ret[i] == nil then
acc_weight = acc_weight + shares[i][1]
acc_adj = acc_adj + normalized_adj[i]
ret[i] = remaining == 1 and length - acc_ret or math.floor((length - sum_adj) / sum_weight * acc_weight + acc_adj - acc_ret + 0.5)
acc_ret = acc_ret + ret[i]
remaining = remaining - 1
end
end
ret[0] = 0
for i = 1, #shares do
ret[i] = ret[i - 1] + ret[i]
end
return ret, spare
end
-- Static data
-- Command character info
-- 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,
["t"] = 3,
["c"] = 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, [","] = 0,
}
local function parse_arg_str(arg_str, default)
local ret = {}
local current = {}
if #arg_str == 0 then return ret end
local index = 1
local split_mode = arg_str:find("[,_]") ~= nil
local p = index
while index <= #arg_str do
local ch = arg_str:sub(index, index)
if split_mode then
if ch == "_" then
local r = tonumber(arg_str:sub(p, index - 1))
if r == nil then
current[#current + 1] = default
else
current[#current + 1] = r
end
p = index + 1
elseif ch == "," then
local r = tonumber(arg_str:sub(p, index - 1))
if r == nil then
current[#current + 1] = default
else
current[#current + 1] = r
end
ret[#ret + 1] = current
current = {}
p = index + 1
end
else
local r = tonumber(ch)
if r == nil then
ret[#ret + 1] = {default}
else
ret[#ret + 1] = {r}
end
end
index = index + 1
end
if split_mode then
local r = tonumber(arg_str:sub(p, index - 1))
if r == nil then
current[#current + 1] = default
else
current[#current + 1] = r
end
ret[#ret + 1] = current
end
return ret
end
if not in_module then
print("Testing parse_arg_str")
local x = parse_arg_str("1234", 0)
assert(#x == 4)
assert(#x[1] == 1 and x[1][1] == 1)
assert(#x[2] == 1 and x[2][1] == 2)
assert(#x[3] == 1 and x[3][1] == 3)
assert(#x[4] == 1 and x[4][1] == 4)
local x = parse_arg_str("12_34_,", -1)
assert(#x == 2)
assert(#x[1] == 3 and x[1][1] == 12 and x[1][2] == 34 and x[1][3] == -1)
assert(#x[2] == 1 and x[2][1] == -1)
local x = parse_arg_str("12_34,56_,78_90_", -1)
assert(#x == 3)
assert(#x[1] == 2 and x[1][1] == 12 and x[1][2] == 34)
assert(#x[2] == 2 and x[2][1] == 56 and x[2][2] == -1)
assert(#x[3] == 3 and x[3][1] == 78 and x[3][2] == 90 and x[3][3] == -1)
print("Passed.")
end
local max_split = 1000
local max_areas = 10000
local default_expansion = 2
-- Execute a (partial) command, returns:
-- 1. Closed areas: areas that will not be further partitioned by further input.
-- 2. Open areas: areas that can be further partitioned.
-- 3. Pending: if the command can take more argument into the last command.
local function areas_from_command(command, workarea, minimum)
local pending_op = nil
local arg_str = ""
local closed_areas = {}
local open_areas
local root = {
expansion = default_expansion,
x = workarea.x,
y = workarea.y,
width = workarea.width,
height = workarea.height,
bl = true,
br = true,
bu = true,
bd = true,
}
local function close_area()
local a = open_areas[#open_areas]
table.remove(open_areas, #open_areas)
local i = #closed_areas + 1
closed_areas[i] = a
a.id = i
return a, i
end
local function push_open_areas(areas)
for i = #areas, 1, -1 do
open_areas[#open_areas + 1] = areas[i]
end
end
local function handle_op(method)
local l = method:lower()
local alt = method ~= l
method = l
if method == "h" or method == "v" then
local args = parse_arg_str(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[2] == 0 and arg[3] then arg[2], arg[3] = -arg[3], nil end
shares[i] = arg
end
if #shares > max_split then
return nil
end
local a, area_index = close_area()
a.inhabitable = true
a.split = {
method = method,
x_shares = method == "h" and shares or {{1}},
y_shares = method == "v" and shares or {{1}},
children = {}
}
local children = a.split.children
if method == "h" then
for i = 1, #a.split.x_shares do
local child = {
parent_id = area_index,
parent_cid = #children + 1,
parent_x_shares = #children + 1,
parent_y_shares = 1,
expansion = a.expansion - 1,
bl = i == 1 and a.bl or false,
br = i == #a.split.x_shares and a.br or false,
bu = a.bu,
bd = a.bd,
}
children[#children + 1] = child
end
else
for i = 1, #a.split.y_shares do
local child = {
parent_id = area_index,
parent_cid = #children + 1,
parent_x_shares = 1,
parent_y_shares = #children + 1,
expansion = a.expansion - 1,
bl = a.bl,
br = a.br,
bu = i == 1 and a.bu or false,
bd = i == #a.split.y_shares and a.bd or false,
}
children[#children + 1] = child
end
end
push_open_areas(children)
elseif method == "w" or method == "d" then
local args = parse_arg_str(arg_str, 0)
local x_shares = {}
local y_shares = {}
local m_start = #args + 1
if method == "w" then
if #args == 0 then
args = {{1}, {1}}
elseif #args == 1 then
args[2] = {1}
end
local x_shares_count, y_shares_count
if alt then
x_shares_count = args[2][1]
y_shares_count = args[1][1]
else
x_shares_count = args[1][1]
y_shares_count = args[2][1]
end
if x_shares_count < 1 then x_shares_count = 1 end
if y_shares_count < 1 then y_shares_count = 1 end
if x_shares_count * y_shares_count > max_split then
return nil
end
for i = 1, x_shares_count do x_shares[i] = {1} end
for i = 1, y_shares_count do y_shares[i] = {1} end
m_start = 3
else
local current = x_shares
for i = 1, #args do
if not alt then
arg = args[i]
else
arg = args[#args - i + 1]
end
if arg[1] == 0 then
if current == x_shares then current = y_shares else
m_start = i + 1
break
end
else
if arg[2] == 0 and arg[3] then arg[2], arg[3] = -arg[3], nil end
current[#current + 1] = arg
end
end
if #x_shares == 0 then
x_shares = {{1}}
end
if #y_shares == 0 then
y_shares = {{1}}
end
if #x_shares * #y_shares > max_split then
return nil
end
end
local a, area_index = close_area()
a.inhabitable = true
a.split = {
method = method,
x_shares = x_shares,
y_shares = y_shares,
children = {},
}
local children = {}
for y_index = 1, #a.split.y_shares do
for x_index = 1, #a.split.x_shares do
local r = {
parent_id = area_index,
-- parent_cid will be filled later.
parent_x_shares = x_index,
parent_y_shares = y_index,
expansion = a.expansion - 1
}
if x_index == 1 then r.bl = a.bl else r.bl = false end
if x_index == #a.split.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 == #a.split.y_shares 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 = m_start, #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) % #x_shares
local y = math.floor((start_index - 1) / #x_shares)
local w = args[i][1]
local h = args[i + 1][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 > #x_shares then w = #x_shares - x end
if y + h > #y_shares then h = #y_shares - 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 * #x_shares + tx + 1] == false then
succ = false
break
elseif ty == y then
end_index = ty * #x_shares + tx + 1
end
end
if not succ then
break
elseif ty > y then
end_index = ty * #x_shares + x + w
end
end
local function generate_range(s, e)
local r = {} for i = s, e do r[#r+1] = i end return r
end
local r = {
bu = children[start_index].bu, bl = children[start_index].bl,
bd = children[end_index].bd, br = children[end_index].br,
parent_id = area_index,
-- parent_cid will be filled later.
parent_x_shares = generate_range(children[start_index].parent_x_shares, children[end_index].parent_x_shares),
parent_y_shares = generate_range(children[start_index].parent_y_shares, children[end_index].parent_y_shares),
expansion = a.expansion - 1
}
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 * #x_shares + tx + 1
if index <= end_index then
children[index] = false
else
break
end
end
end
end
for i = 1, #merged_children do
a.split.children[#a.split.children + 1] = merged_children[i]
a.split.children[#a.split.children].parent_cid = #a.split.children
end
-- clean up children, remove all `false'
for i = 1, #children do
if children[i] ~= false then
a.split.children[#a.split.children + 1] = children[i]
a.split.children[#a.split.children].parent_cid = #a.split.children
end
end
push_open_areas(a.split.children)
elseif method == "s" then
if #open_areas > 0 then
local times = arg_str == "" and 1 or tonumber(arg_str)
local t = {}
local c = #open_areas
local p = open_areas[c].parent_id
while c > 0 and open_areas[c].parent_id == p do
t[#t + 1] = open_areas[c]
open_areas[c] = nil
c = c - 1
end
for i = #t, 1, -1 do
open_areas[c + 1] = t[(i + times - 1) % #t + 1]
c = c + 1
end
end
elseif method == "t" then
if #open_areas > 0 then
open_areas[#open_areas].expansion = tonumber(arg_str) or default_expansion
end
elseif method == "x" then
local a = close_area()
a.layout = arg_str
elseif method == "-" then
close_area()
elseif method == "." then
while #open_areas > 0 do
close_area()
end
elseif method == "c" then
local limit = tonumber(arg_str)
if limit == nil or limit > #open_areas then
limit = #open_areas
end
local p = open_areas[#open_areas].parent_id
while limit > 0 and open_areas[#open_areas].parent_id == p do
close_area()
limit = limit - 1
end
elseif method == "/" then
close_area().inhabitable = true
elseif method == ";" then
-- nothing
end
if #open_areas + #closed_areas > max_areas then
return nil
end
while #open_areas > 0 and open_areas[#open_areas].expansion <= 0 do
close_area()
end
arg_str = ""
return true
end
open_areas = {root}
for i = 1, #command do
local ch = command:sub(i, i)
local t = ch_info[ch]
local r = true
if t == nil then
return nil
elseif t == 3 then
if pending_op ~= nil then
r = handle_op(pending_op)
pending_op = nil
end
if #open_areas == 0 then return nil end
if arg_str == "" then
pending_op = ch
else
r = handle_op(ch)
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
r = handle_op(ch)
elseif t == 0 then
arg_str = arg_str..ch
end
if not r then return nil end
end
if pending_op ~= nil then
if not handle_op(pending_op) then
return nil
end
end
if #closed_areas == 0 then
return closed_areas, open_areas, pending_op ~= nil
end
local old_closed_areas = closed_areas
closed_areas = {}
local function reorder_and_fill_adj_min(old_id)
local a = old_closed_areas[old_id]
closed_areas[#closed_areas + 1] = a
a.id = #closed_areas
if a.split then
for i = 1, #a.split.x_shares do
a.split.x_shares[i][3] = 0
a.split.x_shares[i][4] = minimum
end
for i = 1, #a.split.y_shares do
a.split.y_shares[i][3] = 0
a.split.y_shares[i][4] = minimum
end
for _, c in ipairs(a.split.children) do
if c.id then
reorder_and_fill_adj_min(c.id)
end
local x_minimum, y_minimum
if c.split then
x_minimum, y_minimum = c.x_minimum, c.y_minimum
else
x_minimum, y_minimum =
minimum, minimum
end
if type(c.parent_x_shares) == "table" then
local x_minimum_split = math.ceil(x_minimum / #c.parent_x_shares)
for i = 1, #c.parent_x_shares do
if a.split.x_shares[c.parent_x_shares[i]][4] < x_minimum_split then
a.split.x_shares[c.parent_x_shares[i]][4] = x_minimum_split
end
end
else
if a.split.x_shares[c.parent_x_shares][4] < x_minimum then
a.split.x_shares[c.parent_x_shares][4] = x_minimum
end
end
if type(c.parent_y_shares) == "table" then
local y_minimum_split = math.ceil(y_minimum / #c.parent_y_shares)
for i = 1, #c.parent_y_shares do
if a.split.y_shares[c.parent_y_shares[i]][4] < y_minimum_split then
a.split.y_shares[c.parent_y_shares[i]][4] = y_minimum_split
end
end
else
if a.split.y_shares[c.parent_y_shares][4] < y_minimum then
a.split.y_shares[c.parent_y_shares][4] = y_minimum
end
end
end
a.x_minimum = 0
a.x_total_weight = 0
for i = 1, #a.split.x_shares do
a.x_minimum = a.x_minimum + a.split.x_shares[i][4]
a.x_total_weight = a.x_total_weight + (a.split.x_shares[i][2] or 0)
end
a.y_minimum = 0
a.y_total_weight = 0
for i = 1, #a.split.y_shares do
a.y_minimum = a.y_minimum + a.split.y_shares[i][4]
a.y_total_weight = a.y_total_weight + (a.split.y_shares[i][2] or 0)
end
end
end
reorder_and_fill_adj_min(1)
-- For debugging
-- for i = 1, #closed_areas do
-- print(i, closed_areas[i].parent_id, closed_areas[i].parent_x_shares, closed_areas[i].parent_y_shares)
-- if closed_areas[i].split then
-- print("/", closed_areas[i].split.method, #closed_areas[i].split.x_shares, #closed_areas[i].split.y_shares)
-- for j = 1, #closed_areas[i].split.children do
-- print("->", closed_areas[i].split.children[j].id)
-- end
-- end
-- end
local orig_width = root.width
if root.x_minimum and root.width < root.x_minimum then
root.width = root.x_minimum
end
local orig_height = root.height
if root.y_minimum and root.height < root.y_minimum then
root.height = root.y_minimum
end
function split(id)
local a = closed_areas[id]
if a.split then
local x_shares, y_shares
x_shares, a.split.x_spare = fair_split(a.width, a.split.x_shares)
y_shares, a.split.y_spare = fair_split(a.height, a.split.y_shares)
for _, c in ipairs(a.split.children) do
if type(c.parent_x_shares) == "table" then
c.x = a.x + x_shares[c.parent_x_shares[1] - 1]
c.width = 0
for i = 1, #c.parent_x_shares do
c.width = c.width + x_shares[c.parent_x_shares[i]] - x_shares[c.parent_x_shares[i] - 1]
end
else
c.x = a.x + x_shares[c.parent_x_shares - 1]
c.width = x_shares[c.parent_x_shares] - x_shares[c.parent_x_shares - 1]
end
if type(c.parent_y_shares) == "table" then
c.y = a.y + y_shares[c.parent_y_shares[1] - 1]
c.height = 0
for i = 1, #c.parent_y_shares do
c.height = c.height + y_shares[c.parent_y_shares[i]] - y_shares[c.parent_y_shares[i] - 1]
end
else
c.y = a.y + y_shares[c.parent_y_shares - 1]
c.height = y_shares[c.parent_y_shares] - y_shares[c.parent_y_shares - 1]
end
if c.id then
split(c.id)
end
end
end
end
split(1)
-- Compatibility workaround.
if command:sub(1, 1) == "d" then
root.draft_mode = true
end
for i = 1, #closed_areas do
if closed_areas[i].x + closed_areas[i].width > root.x + orig_width or
closed_areas[i].y + closed_areas[i].height > root.y + orig_height
then
closed_areas[i].inhabitable = true
end
end
for i = 1, #open_areas do
if open_areas[i].x + open_areas[i].width > root.x + orig_width or
open_areas[i].y + open_areas[i].height > root.y + orig_height
then
open_areas[i].inhabitable = true
end
end
return closed_areas, open_areas, pending_op ~= nil
end
local function areas_to_command(areas, to_embed, root_area)
root_area = root_area or 1
if #areas < root_area then return nil end
local function shares_to_arg_str(shares)
local arg_str = ""
for _, share in ipairs(shares) do
if #arg_str > 0 then arg_str = arg_str.."," end
arg_str = arg_str..tostring(share[1])
if not share[2] then
-- nothing
elseif share[2] > 0 then
arg_str = arg_str.."_"..tostring(share[2])
else
arg_str = arg_str.."__"..tostring(-share[2])
end
end
return arg_str
end
local function get_command(area_id)
local r
local handled_options = {}
local a = areas[area_id]
if a.hole then
return "|"
end
if a.split then
for i = 1, #a.split.children do
if a.split.children[i].hole then
a.expansion = default_expansion + 1
break
end
end
local method = a.split.method
if method == "h" then
r = shares_to_arg_str(a.split.x_shares)
r = "h"..r
elseif method == "v" then
r = shares_to_arg_str(a.split.y_shares)
r = "v"..r
elseif method == "d" or method == "w" then
local simple = true
for _, s in ipairs(a.split.x_shares) do
if s[1] ~= 1 or s[2] then simple = false break end
end
if simple then
for _, s in ipairs(a.split.y_shares) do
if s[1] ~= 1 or s[2] then simple = false break end
end
end
if method == "w" and simple then
r = tostring(#a.split.x_shares)..","..tostring(#a.split.y_shares)
else
r = shares_to_arg_str(a.split.x_shares)..",,"..shares_to_arg_str(a.split.y_shares)
method = "d"
end
local m = ""
for _, c in ipairs(a.split.children) do
if type(c.parent_x_shares) == "table" then
if #m > 0 then m = m.."," end
m = m..tostring(c.parent_x_shares[#c.parent_x_shares] - c.parent_x_shares[1] + 1)..","..
tostring(c.parent_y_shares[#c.parent_y_shares] - c.parent_y_shares[1] + 1)
end
end
if method == "d" and r == "1,,1" then
r = ""
end
if method == "d" and area_id == 1 then
r = (areas[1].draft_mode and "d" or ";d")..r
else
r = method..r..(#m == 0 and m or (method == "w" and "," or ",,"))..m
end
end
local acc_dashes = 0
if a.expansion > 1 then
for _, c in ipairs(a.split.children) do
local cr = get_command(c.id)
if cr == "-" then
acc_dashes = acc_dashes + 1
else
if acc_dashes == 0 then
elseif acc_dashes == 1 then
r = r.."-"
else
r = r.."c"..tonumber(acc_dashes)
end
acc_dashes = 0
r = r..cr
end
end
if acc_dashes > 0 then
r = r.."c"
end
end
if area_id ~= root_area then
if a.expansion ~= areas[a.parent_id].expansion - 1 then
r = "t"..tostring(a.expansion)..r
end
else
if a.expansion ~= default_expansion then
r = "t"..tostring(a.expansion)..r
end
end
elseif a.disabled then
r = "/"
elseif a.layout then
r = "x"..a.layout
else
r = "-"
end
return r
end
local r = get_command(root_area)
if not to_embed then
if r == "-" then
r = "."
else
-- The last . may be redundant, but it makes sure no pending op.
r = r:gsub("[\\c]+$", "").."."
end
end
return r
end
if not in_module then
print("Testing areas/command processing")
local function check_transcoded_command(command, expectation)
local areas, open_areas = areas_from_command(command, {x = 0, y = 0, width = 100, height = 100}, 0)
if #open_areas > 0 then
print("Found open areas after command "..command)
assert(false)
end
local transcoded = areas_to_command(areas)
if transcoded ~= expectation then
print("Mismatched transcoding for "..command..": got "..transcoded..", expected "..expectation)
assert(false)
end
end
check_transcoded_command(".", ".")
check_transcoded_command("3t.", ".")
check_transcoded_command("121h.", "h1,2,1.")
check_transcoded_command("1_10,2,1h1s131v.", "h1_10,2,1-v1,3,1.")
check_transcoded_command("332111w.", "w3,3,2,1,1,1.")
check_transcoded_command("1310111d.", ";d1,3,1,,1,1,1.")
check_transcoded_command("dw66.", "dw6,6.")
check_transcoded_command(";dw66.", ";dw6,6.")
check_transcoded_command("101dw66.", ";dw6,6.")
check_transcoded_command("3tdw66.", "t3;dw6,6.")
print("Passed.")
end
return {
areas_from_command = areas_from_command,
areas_to_command = areas_to_command,
}