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