1--- Select a page element with a visual interface. 2-- 3-- This web module allows other Lua modules to select page elements with the 4-- same interface as that used by the follow mode plugin: a visual overlay is 5-- shown that allows the users to type to filter visible hints. For example, 6-- this module is used by the `formfiller` module when selecting a form to add. 7-- 8-- @module select_wm 9-- @copyright 2017 Aidan Holm <aidanholm@gmail.com> 10 11local ceil, floor, max = math.ceil, math.floor, math.max 12 13local _M = {} 14 15local ui = ipc_channel("select_wm") 16 17local has_client_rects_api = tonumber(luakit.webkit_version:match("^2%.(%d+)%.")) > 16 18 19-- Label making 20 21-- Calculates the minimum number of characters needed in a hint given a 22-- charset of a certain length (I.e. the base) 23local function max_hint_len(size, base) 24 local len = 0 25 if base == 1 then return size end 26 while size > 0 do size, len = floor(size / base), len + 1 end 27 return len 28end 29 30-- Reverse a UTF8 string: multibyte sequences are reversed twice 31local function utf8_rev (s) 32 s = s:gsub(utf8.charpattern, function (ch) return #ch > 1 and ch:reverse() end) 33 return s:reverse() 34end 35 36local function charset(seq, size) 37 local base, digits, labels = utf8.len(seq), {}, {} 38 for ch in seq:gmatch(utf8.charpattern) do digits[#digits+1] = ch end 39 40 local maxlen = max_hint_len(size, base) 41 42 for n = 1, size do 43 local t, i, j, d = {}, 1, n 44 repeat 45 d, n = (n % base) + 1, floor(n / base) 46 rawset(t, i, rawget(digits, d)) 47 i = i + 1 48 until n == 0 49 50 rawset(labels, j, string.rep(digits[1], maxlen-i+1) .. utf8_rev(table.concat(t, ""))) 51 end 52 return labels 53end 54 55-- Different hint label styles 56local label_styles = { 57 charset = function (seq) 58 assert(type(seq) == "string" and #seq > 0, "invalid sequence") 59 return function (size) return charset(seq, size) end 60 end, 61 62 numbers = function () 63 return function (size) return charset("0123456789", size) end 64 end, 65 66 -- Interleave style 67 interleave = function (left, right) 68 assert(type(left) == "string" and type(right) == "string", 69 "left and right parameters must be strings") 70 assert(#left > 1 or #right > 1, 71 "either left or right parameters' length must be greater than 1") 72 local cmap = {} 73 for ch in (left..right):gmatch(utf8.charpattern) do 74 if cmap[ch] then 75 error("duplicate characters %s in hint strings %s, %s", ch, left, right) 76 else 77 cmap[ch] = 1 78 end 79 end 80 return function (size) 81 local function allstrings(n, t, k, s) 82 k, s = k or 1, s or {} 83 if k > n then 84 coroutine.yield(table.concat(s)) 85 else 86 for i = 1, #t do 87 s[k] = t[i] 88 allstrings(n, t, k+1, s) 89 end 90 end 91 end 92 local function permute(n, t) 93 return coroutine.wrap(allstrings), n, t 94 end 95 96 -- calculate the hinting length 97 local hint_len = 1 98 while true do 99 local lo, hi = floor(hint_len/2), ceil(hint_len/2) 100 if (#left)^lo * (#right)^hi + (#left)^hi * (#right)^lo >= size then break end 101 hint_len = hint_len + 1 102 end 103 104 local tleft, tright = {}, {} 105 left:gsub(utf8.charpattern, function(c) table.insert(tleft, c) end) 106 right:gsub(utf8.charpattern, function(c) table.insert(tright, c) end) 107 local labels = {} 108 local lo, hi = floor(hint_len/2), ceil(hint_len/2) 109 for a in permute(hi, tleft) do 110 for b in permute(lo, tright) do 111 rawset(labels, size, a:gsub('()('..utf8.charpattern..')', 112 function(p, c) return c..b:sub(p, p+#c-1) end)) 113 size = size - 1 114 if size == 0 then return labels end 115 end 116 end 117 for a in permute(hi, tright) do 118 for b in permute(lo, tleft) do 119 rawset(labels, size, a:gsub('()('..utf8.charpattern..')', 120 function(p, c) return c..b:sub(p, p+#c-1) end)) 121 size = size - 1 122 if size == 0 then return labels end 123 end 124 end 125 return labels 126 end 127 end, 128 129 -- Chainable style: sorts labels 130 sort = function (make_labels) 131 return function (size) 132 local labels = make_labels(size) 133 table.sort(labels) 134 return labels 135 end 136 end, 137 138 -- Chainable style: reverses label strings 139 reverse = function (make_labels) 140 return function (size) 141 local labels = make_labels(size) 142 for i = 1, #labels do 143 rawset(labels, i, utf8_rev(rawget(labels, i))) 144 end 145 return labels 146 end 147 end, 148 149 trim = function (make_labels) 150 return function (size) 151 local labels = make_labels(size) 152 local P = {} 153 for _, l in ipairs(labels) do 154 local p = l:gsub(utf8.charpattern.."$", "") 155 if #p > 0 then P[p] = (P[p] or 0) + 1 end 156 end 157 for p, count in pairs(P) do 158 if count == 1 then 159 for i, l in ipairs(labels) do 160 if l:sub(1, #p) == p then labels[i] = p end 161 end 162 end 163 end 164 return labels 165 end 166 end, 167} 168 169-- Default label style 170local label_maker 171do 172 local s = label_styles 173 label_maker = s.trim(s.sort(s.interleave("12345", "67890"))) 174end 175 176local function bounding_boxes_intersect(a, b) 177 if a.x + a.w < b.x then return false end 178 if b.x + b.w < a.x then return false end 179 if a.y + a.h < b.y then return false end 180 if b.y + b.h < a.y then return false end 181 return true 182end 183 184local function get_element_bb_if_visible(element, wbb, page) 185 -- Find the element bounding box 186 local r 187 188 if has_client_rects_api and not element.first_child then 189 r = element:client_rects() 190 for i=#r,1,-1 do 191 if r[i].width == 0 or r[i].height == 0 then table.remove(r, i) end 192 end 193 if #r == 0 then return nil end 194 r = r[1] 195 else 196 local client_rects = page:wrap_js([=[ 197 var rects = element.getClientRects(); 198 if (rects.length == 0) 199 return undefined; 200 var rect = { 201 "top": rects[0].top, 202 "bottom": rects[0].bottom, 203 "left": rects[0].left, 204 "right": rects[0].right, 205 }; 206 for (var i = 1; i < rects.length; i++) { 207 rect.top = Math.min(rect.top, rects[i].top); 208 rect.bottom = Math.max(rect.bottom, rects[i].bottom); 209 rect.left = Math.min(rect.left, rects[i].left); 210 rect.right = Math.max(rect.right, rects[i].right); 211 } 212 rect.width = rect.right - rect.left; 213 rect.height = rect.bottom - rect.top; 214 return rect; 215 ]=], {"element"}) 216 r = client_rects(element) or element.rect 217 end 218 219 local rbb = { 220 x = wbb.x + r.left, 221 y = wbb.y + r.top, 222 w = r.width, 223 h = r.height, 224 } 225 226 if rbb.w == 0 or rbb.h == 0 then return nil end 227 228 local style = element.style 229 local display = style.display 230 local visibility = style.visibility 231 232 if display == 'none' or visibility == 'hidden' then return nil end 233 234 -- Clip bounding box! 235 if display == "inline" then 236 local parent = element.parent 237 local pd = parent.style.display 238 if pd == "block" or pd == "inline-block" then 239 local w = parent.rect.width 240 w = w - (r.left - parent.rect.left) 241 if rbb.w > w then rbb.w = w end 242 end 243 end 244 245 if not bounding_boxes_intersect(wbb, rbb) then return nil end 246 247 -- If a link element contains one image, use the image dimensions 248 if element.tag_name == "A" then 249 local first = element.first_child 250 if first and first.tag_name == "IMG" and not first.next_sibling then 251 return get_element_bb_if_visible(first, wbb, page) or rbb 252 end 253 end 254 255 return rbb 256end 257 258local function frame_find_hints(page, frame, elements) 259 local hints = {} 260 261 if type(elements) == "string" then 262 elements = frame.body:query(elements) 263 else 264 local elems = {} 265 for _, e in ipairs(elements) do 266 if e.owner_document == frame.doc then 267 elems[#elems + 1] = e 268 end 269 end 270 elements = elems 271 end 272 273 -- Find the visible bounding box 274 local w = frame.doc.window 275 local wbb = { 276 x = w.scroll_x, 277 y = w.scroll_y, 278 w = w.inner_width, 279 h = w.inner_height, 280 } 281 282 for _, element in ipairs(elements) do 283 local rbb = get_element_bb_if_visible(element,wbb, page) 284 285 if rbb then 286 local text = element.text_content 287 if text == "" then text = element.value or "" end 288 if text == "" then text = element.attr.placeholder or "" end 289 hints[#hints+1] = { elem = element, bb = rbb, text = text } 290 end 291 end 292 293 return hints 294end 295 296local function sort_hints_top_left(a, b) 297 local dtop = a.bb.y - b.bb.y 298 if dtop ~= 0 then 299 return dtop < 0 300 else 301 return a.bb.x - b.bb.x < 0 302 end 303end 304 305local function make_labels(num) 306 return label_maker(num) 307end 308 309local function find_frames(root_frame) 310 if not root_frame.body then 311 return {} 312 end 313 314 local subframes = root_frame.body:query("frame, iframe") 315 local frames = { root_frame } 316 317 -- For each frame/iframe element, recurse 318 for _, frame in ipairs(subframes) do 319 local f = { doc = frame.document, body = frame.document.body } 320 local s = find_frames(f) 321 for _, sf in ipairs(s) do 322 frames[#frames + 1] = sf 323 end 324 end 325 326 return frames 327end 328 329local page_states = {} 330 331local function init_frame(frame, stylesheet) 332 assert(frame.doc) 333 assert(frame.body) 334 335 frame.overlay = frame.doc:create_element("div", { id = "luakit_select_overlay" }) 336 frame.stylesheet = frame.doc:create_element("style", { id = "luakit_select_stylesheet" }, stylesheet) 337 338 frame.body.parent:append(frame.overlay) 339 frame.body.parent:append(frame.stylesheet) 340end 341 342local function cleanup_frame(frame) 343 if frame.overlay then 344 frame.overlay:remove() 345 frame.overlay = nil 346 end 347 if frame.stylesheet then 348 frame.stylesheet:remove() 349 frame.stylesheet = nil 350 end 351end 352 353local function hint_matches(hint, hint_pat, text_pat) 354 if hint_pat ~= nil and string.find(hint.label, hint_pat) then return true end 355 if text_pat ~= nil and string.find(hint.text, text_pat) then return true end 356 return false 357end 358 359local function filter(state, hint_pat, text_pat) 360 state.num_visible_hints = 0 361 for _, hint in pairs(state.hints) do 362 local old_hidden = hint.hidden 363 hint.hidden = not hint_matches(hint, hint_pat, text_pat) 364 365 if not hint.hidden then 366 state.num_visible_hints = state.num_visible_hints + 1 367 end 368 369 if not old_hidden and hint.hidden then 370 -- Save old style, set new style to "display: none" 371 hint.overlay_style = hint.overlay_elem.attr.style 372 hint.label_style = hint.label_elem.attr.style 373 hint.overlay_elem.attr.style = "display: none;" 374 hint.label_elem.attr.style = "display: none;" 375 elseif old_hidden and not hint.hidden then 376 -- Restore saved style 377 hint.overlay_elem.attr.style = hint.overlay_style 378 hint.label_elem.attr.style = hint.label_style 379 end 380 end 381end 382 383local function focus(state, step) 384 local last = state.focused 385 local index 386 387 local function sign(n) return n > 0 and 1 or n < 0 and -1 or 0 end 388 389 if state.num_visible_hints == 0 then return end 390 391 -- Advance index to the first non-hidden item 392 if step == 0 then 393 index = last and last or 1 394 while state.hints[index].hidden do 395 index = index + 1 396 if index > #state.hints then index = 1 end 397 end 398 if index == last then return end 399 end 400 401 -- Which hint to focus? 402 if step ~= 0 and last then 403 index = last 404 while step ~= 0 do 405 repeat 406 index = index + sign(step) 407 if index < 1 then index = #state.hints end 408 if index > #state.hints then index = 1 end 409 until not state.hints[index].hidden 410 step = step - sign(step) 411 end 412 end 413 414 local new_hint = state.hints[index] 415 416 -- Save and update class for the new hint 417 new_hint.orig_class = new_hint.overlay_elem.attr.class 418 new_hint.overlay_elem.attr.class = new_hint.orig_class .. " hint_selected" 419 420 -- Restore the original class for the old hint 421 if last then 422 local old_hint = state.hints[last] 423 old_hint.overlay_elem.attr.class = old_hint.orig_class 424 old_hint.orig_class = nil 425 end 426 427 state.focused = index 428 429 return new_hint 430end 431 432--- Enter element selection mode on a web page. 433-- 434-- The web page must not already be in element selection mode. 435-- 436-- @tparam page page The web page in which to enter element selection. 437-- @tparam string|{dom_element} elements A selector to filter elements, or an array of elements. 438-- @tparam string stylesheet The stylesheet to apply. 439-- @tparam boolean ignore_case `true` if text case should be ignored. 440-- @treturn {...} Table with data for the currently focused hint. 441-- @treturn number The number of currently visible hints. 442function _M.enter(page, elements, stylesheet, ignore_case) 443 assert(type(page) == "page") 444 assert(type(elements) == "string" or type(elements) == "table") 445 assert(type(stylesheet) == "string") 446 local page_id = page.id 447 assert(page_states[page_id] == nil) 448 449 local root = page.document 450 local root_frame = { doc = root, body = root.body } 451 452 local state = {} 453 page_states[page_id] = state 454 455 state.frames = find_frames(root_frame) 456 state.focused = nil 457 state.hints = {} 458 state.ignore_case = ignore_case or false 459 460 -- Find all hints in the viewport 461 for _, frame in ipairs(state.frames) do 462 -- Set up the frame, and find hints 463 init_frame(frame, stylesheet) 464 frame.hints = frame_find_hints(page, frame, elements) 465 -- Build an array of all hints 466 for _, hint in ipairs(frame.hints) do 467 state.hints[#state.hints+1] = hint 468 end 469 end 470 471 -- Sort them by on-screen position, and assign labels 472 local labels = make_labels(#state.hints) 473 assert(#state.hints == #labels) 474 475 table.sort(state.hints, sort_hints_top_left) 476 477 for i, hint in ipairs(state.hints) do 478 hint.label = labels[i] 479 end 480 481 for _, frame in ipairs(state.frames) do 482 local fwr = frame.doc.window 483 local fsx, fsy = fwr.scroll_x, fwr.scroll_y 484 for _, hint in ipairs(frame.hints) do 485 -- Append hint elements to overlay 486 local e = hint.elem 487 local r = hint.bb 488 489 local overlay_style = string.format("left: %dpx; top: %dpx; width: %dpx; height: %dpx;", r.x, r.y, r.w, r.h) 490 local label_style = string.format("left: %dpx; top: %dpx;", max(r.x-10, fsx), max(r.y-10, fsy), r.w, r.h) 491 492 local overlay_class = "hint_overlay hint_overlay_" .. e.tag_name 493 local label_class = "hint_label hint_label_" .. e.tag_name 494 hint.overlay_elem = frame.doc:create_element("span", {class = overlay_class, style = overlay_style}) 495 hint.label_elem = frame.doc:create_element("span", {class = label_class, style = label_style}, hint.label) 496 497 frame.overlay:append(hint.overlay_elem) 498 frame.overlay:append(hint.label_elem) 499 end 500 end 501 502 for _, frame in ipairs(state.frames) do 503 frame.doc:add_signal("destroy", function () 504 cleanup_frame(frame) 505 end) 506 end 507 508 filter(state, "", "") 509 return focus(state, 0), state.num_visible_hints 510end 511 512--- Leave element selection mode on a web page. 513-- 514-- The web page must be in element selection mode. 515-- 516-- @tparam page|number page The web page (or the web page id) in which to 517-- leave element selection. 518function _M.leave(page) 519 if type(page) == "page" then page = page.id end 520 assert(type(page) == "number") 521 522 local state = page_states[page] 523 if not state then return end 524 for _, frame in ipairs(state.frames) do 525 cleanup_frame(frame) 526 end 527 page_states[page] = nil 528end 529 530--- Update the element selection interface when user selection text changes. 531-- 532-- The web page must be in element selection mode. 533-- 534-- @tparam page page The web page. 535-- @tparam string hint_pat The hint pattern filter. 536-- @tparam string text_pat The text pattern filter. 537-- @tparam string text The full text. 538-- @treturn table The currently focused hint. 539-- @treturn number The number of currently visible hints. 540function _M.changed(page, hint_pat, text_pat, text) 541 assert(type(page) == "page") 542 assert(hint_pat == nil or type(hint_pat) == "string") 543 assert(text_pat == nil or type(text_pat) == "string") 544 assert(type(text) == "string") 545 546 local state = assert(page_states[page.id]) 547 548 if state.ignore_case then 549 local convert = function(pat) 550 if pat == nil then return nil end 551 local converter = function (ch) return '[' .. string.upper(ch) .. string.lower(ch) .. ']' end 552 return string.gsub(pat, '(%a)', converter) 553 end 554 hint_pat = convert(hint_pat) 555 text_pat = convert(text_pat) 556 end 557 558 filter(state, hint_pat, text_pat) 559 return focus(state, 0), state.num_visible_hints 560end 561 562--- Update the element selection interface when the user moves the focus. 563-- 564-- The web page must be in element selection mode. 565-- 566-- @usage 567-- 568-- function handle_next (page) 569-- select_wm.focus(page, 1) 570-- end 571-- function handle_prev (page) 572-- select_wm.focus(page, -1) 573-- end 574-- 575-- @tparam page page The web page. 576-- @tparam number step Relative number of tags to shift focus by. 577-- @treturn table The currently focused hint. 578-- @treturn number The number of currently visible hints. 579function _M.focus(page, step) 580 assert(type(page) == "page") 581 assert(type(step) == "number") 582 local state = assert(page_states[page.id]) 583 return focus(state, step), state.num_visible_hints 584end 585 586--- Get the current state of element hints on a web page. 587-- 588-- The web page must be in element selection mode. 589-- 590-- @tparam page page The web page. 591-- @treturn table The current hint state for `page`. 592function _M.hints(page) 593 assert(type(page) == "page") 594 local state = assert(page_states[page.id]) 595 return state.hints 596end 597 598--- Get the currently focused element hint on a web page. 599-- 600-- The web page must be in element selection mode. 601-- 602-- @tparam page page The web page. 603-- @treturn table The currently focused hint. 604function _M.focused_hint(page) 605 assert(type(page) == "page") 606 local state = assert(page_states[page.id]) 607 return state.hints[state.focused] 608end 609 610ui:add_signal("set_label_maker", function (_, _, f) 611 setfenv(f, label_styles) 612 label_maker = f(label_styles) 613end) 614 615return _M 616 617-- vim: et:sw=4:ts=8:sts=4:tw=80 618