input_focus.go RawHistoryFolder
1package cef
2
3import (
4 "context"
5
6 "github.com/bnema/dumber/internal/application/port"
7)
8
9var (
10 _ port.PageInputFocuser = (*WebView)(nil)
11 _ port.PageFocusNavigator = (*WebView)(nil)
12)
13
14// FocusNextInput moves focus to the next visible, editable page input. When
15// no eligible input is active it focuses the first one; movement wraps at the
16// end so repeated gi commands continue to cycle through the page inputs.
17func (wv *WebView) FocusNextInput() {
18 if wv == nil || wv.destroyed.Load() {
19 return
20 }
21 wv.RunJavaScript(context.Background(), focusNextInputScript())
22}
23
24// NavigatePageFocus keeps reverse and forward focus traversal inside the live
25// page when GTK would otherwise move focus out of the WebView.
26func (wv *WebView) NavigatePageFocus(backward bool) {
27 if wv == nil || wv.destroyed.Load() {
28 return
29 }
30 wv.RunJavaScript(context.Background(), pageFocusNavigationScript(backward))
31}
32
33func focusNextInputScript() string {
34 return `(() => {
35 const candidates = Array.from(document.querySelectorAll("input,textarea,[contenteditable]"));
36 const hiddenInputTypes = new Set([
37 "button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"
38 ]);
39 const isVisible = (element) => {
40 if (!(element instanceof HTMLElement) || !element.isConnected) return false;
41 if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false;
42 if (element.getClientRects().length === 0) return false;
43 const style = window.getComputedStyle(element);
44 return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse";
45 };
46 const isEligible = (element) => {
47 if (!isVisible(element)) return false;
48 if (element instanceof HTMLInputElement) {
49 return !element.matches(":disabled") && !element.readOnly && !hiddenInputTypes.has(element.type.toLowerCase());
50 }
51 if (element instanceof HTMLTextAreaElement) {
52 return !element.matches(":disabled") && !element.readOnly;
53 }
54 return element.isContentEditable && element.getAttribute("contenteditable") !== "false";
55 };
56 const eligible = candidates.filter(isEligible);
57 if (eligible.length === 0) return;
58 const active = document.activeElement;
59 const activeIndex = eligible.findIndex((element) => element === active || element.contains(active));
60 const target = eligible[(activeIndex + 1 + eligible.length) % eligible.length];
61 target.focus();
62})();`
63}
64
65func pageFocusNavigationScript(backward bool) string {
66 direction := "1"
67 if backward {
68 direction = "-1"
69 }
70 return `(() => {
71 const direction = ` + direction + `;
72 const selector = [
73 "a[href]", "area[href]", "button:not(:disabled)",
74 "input:not(:disabled):not([type=\"hidden\"])", "select:not(:disabled)",
75 "textarea:not(:disabled)", "[contenteditable]:not([contenteditable=\"false\"])",
76 "[tabindex]:not([tabindex=\"-1\"])"
77 ].join(",");
78 const isVisible = (element) => {
79 if (!(element instanceof HTMLElement) || !element.isConnected) return false;
80 if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false;
81 if (element.getClientRects().length === 0) return false;
82 const style = window.getComputedStyle(element);
83 return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse";
84 };
85 const candidates = Array.from(document.querySelectorAll(selector)).filter((element) => {
86 return isVisible(element) && !element.matches(":disabled") && element.tabIndex >= 0;
87 });
88 if (candidates.length === 0) return;
89 const active = document.activeElement;
90 const activeIndex = candidates.findIndex((element) => element === active || element.contains(active));
91 const targetIndex = activeIndex < 0
92 ? (direction < 0 ? candidates.length - 1 : 0)
93 : activeIndex + direction;
94 if (targetIndex < 0 || targetIndex >= candidates.length) return;
95 candidates[targetIndex].focus();
96})();`
97}