1package cef
2
3import (
4 "context"
5 "fmt"
6 "strconv"
7
8 purecef "github.com/bnema/purego-cef/cef"
9
10 "github.com/bnema/dumber/internal/application/dto"
11)
12
13// NavigateSemantic performs the initial live Vim structural navigation path.
14// It deliberately uses only stable HTML semantics and a static script: CEF's
15// raw accessibility payload is not yet a documented application contract.
16func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error {
17 if wv == nil || wv.destroyed.Load() {
18 return errDestroyed
19 }
20 if request.Target != dto.SemanticNavigationTargetHeading {
21 return fmt.Errorf("unsupported semantic navigation target: %d", request.Target)
22 }
23 if request.Direction != dto.SemanticNavigationForward && request.Direction != dto.SemanticNavigationBackward {
24 return fmt.Errorf("unsupported semantic navigation direction: %d", request.Direction)
25 }
26
27 count := request.Count
28 if count < 1 {
29 count = 1
30 }
31 wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor, wv.vimHeadingHighlightNamespace()))
32 return nil
33}
34
35// ActivateSemanticNavigationTarget follows the link associated with the most
36// recent Vim semantic navigation target, if that target has one.
37func (wv *WebView) ActivateSemanticNavigationTarget(_ context.Context) error {
38 if wv == nil || wv.destroyed.Load() {
39 return errDestroyed
40 }
41 return wv.scheduleJavaScript(activateSemanticNavigationTargetScript(wv.vimHeadingHighlightNamespace()))
42}
43
44// ClearSemanticNavigationHighlight removes the visual target left by the most
45// recent Vim semantic navigation in this WebView.
46func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error {
47 if wv == nil || wv.destroyed.Load() {
48 return errDestroyed
49 }
50 return wv.scheduleJavaScript(clearHeadingNavigationHighlightScript(wv.vimHeadingHighlightNamespace()))
51}
52
53func (wv *WebView) vimHeadingHighlightNamespace() string {
54 return fmt.Sprintf("dumber-vim-heading-%p", wv)
55}
56
57// scheduleJavaScript makes CEF UI-thread scheduling failures observable to
58// callers that need to guarantee a transient UI state is cleared.
59func (wv *WebView) scheduleJavaScript(script string) error {
60 if wv == nil || wv.destroyed.Load() {
61 return errDestroyed
62 }
63 wv.mu.RLock()
64 browser := wv.browser
65 wv.mu.RUnlock()
66 if browser == nil {
67 return fmt.Errorf("schedule JavaScript: browser unavailable")
68 }
69 if wv.engine == nil {
70 frame := browser.GetMainFrame()
71 if frame == nil {
72 return fmt.Errorf("schedule JavaScript: main frame unavailable")
73 }
74 frame.ExecuteJavaScript(script, "", 0)
75 return nil
76 }
77
78 task := cefNewTask(cefTaskFunc(func() {
79 wv.executeJavaScriptNow(script)
80 }))
81 if task == nil {
82 return fmt.Errorf("schedule JavaScript: create CEF task")
83 }
84 if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
85 return fmt.Errorf("schedule JavaScript: post CEF task: result %d", result)
86 }
87 return nil
88}
89
90func headingNavigationScript(direction, count int, highlightColor, namespace string) string {
91 if highlightColor == "" {
92 highlightColor = "#fbbf24"
93 }
94 return `(() => {
95 const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
96 const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
97 const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
98 const highlightColor = ` + strconv.Quote(highlightColor) + `;
99 const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))
100 .filter((heading) => {
101 if (heading.getClientRects().length === 0) return false;
102 if (heading.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false;
103 const style = window.getComputedStyle(heading);
104 return style.visibility !== "hidden" && style.visibility !== "collapse";
105 });
106 if (headings.length === 0) return;
107
108 const prior = window[stateKey];
109 let index = headings.indexOf(prior);
110 if (index < 0) {
111 const firstAtOrAfterViewportTop = headings.findIndex(
112 (heading) => heading.getBoundingClientRect().top >= 0,
113 );
114 if (` + strconv.Itoa(direction) + ` > 0) {
115 index = firstAtOrAfterViewportTop - 1;
116 if (index < -1) index = -1;
117 } else {
118 index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length;
119 }
120 }
121
122 const next = index + (` + strconv.Itoa(direction) + ` * ` + strconv.Itoa(count) + `);
123 if (next < 0 || next >= headings.length) return;
124 const target = headings[next];
125
126 const previous = document.querySelector("[" + targetAttribute + "]");
127 if (previous && previous !== target) previous.removeAttribute(targetAttribute);
128 let style = document.querySelector("style[" + styleAttribute + "]");
129 if (!style) {
130 // This visual cue is best-effort: strict page CSP can reject a DOM style
131 // element, but target selection and scrolling must stay independent of it.
132 style = document.createElement("style");
133 style.setAttribute(styleAttribute, "");
134 document.documentElement.appendChild(style);
135 }
136 // Refresh the outline on every navigation so an in-session accent change is
137 // reflected even when the target style element already exists.
138 style.textContent = "[" + targetAttribute + "]" +
139 " { outline: 3px solid " + highlightColor +
140 " !important; outline-offset: 5px !important; border-radius: 3px !important; }";
141 window[stateKey] = target;
142 target.setAttribute(targetAttribute, "");
143 target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
144})();`
145}
146
147func activateSemanticNavigationTargetScript(namespace string) string {
148 return `(() => {
149 const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
150 const target = window[stateKey];
151 if (!(target instanceof Element) || !target.isConnected) return;
152
153 let link = target.closest("a[href]");
154 if (!link) link = target.querySelector("a[href]");
155 if (!link) return;
156 link.click();
157})();`
158}
159
160func clearHeadingNavigationHighlightScript(namespace string) string {
161 return `(() => {
162 const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
163 const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
164 const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
165 document.querySelectorAll("[" + targetAttribute + "]").forEach((target) => {
166 target.removeAttribute(targetAttribute);
167 });
168 document.querySelectorAll("style[" + styleAttribute + "]").forEach((style) => {
169 style.remove();
170 });
171 delete window[stateKey];
172})();`
173}