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// ClearSemanticNavigationHighlight removes the visual target left by the most
36// recent Vim semantic navigation in this WebView.
37func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error {
38 if wv == nil || wv.destroyed.Load() {
39 return errDestroyed
40 }
41 return wv.scheduleJavaScript(clearHeadingNavigationHighlightScript(wv.vimHeadingHighlightNamespace()))
42}
43
44func (wv *WebView) vimHeadingHighlightNamespace() string {
45 return fmt.Sprintf("dumber-vim-heading-%p", wv)
46}
47
48// scheduleJavaScript makes CEF UI-thread scheduling failures observable to
49// callers that need to guarantee a transient UI state is cleared.
50func (wv *WebView) scheduleJavaScript(script string) error {
51 if wv == nil || wv.destroyed.Load() {
52 return errDestroyed
53 }
54 wv.mu.RLock()
55 browser := wv.browser
56 wv.mu.RUnlock()
57 if browser == nil {
58 return fmt.Errorf("schedule JavaScript: browser unavailable")
59 }
60 if wv.engine == nil {
61 frame := browser.GetMainFrame()
62 if frame == nil {
63 return fmt.Errorf("schedule JavaScript: main frame unavailable")
64 }
65 frame.ExecuteJavaScript(script, "", 0)
66 return nil
67 }
68
69 task := cefNewTask(cefTaskFunc(func() {
70 wv.executeJavaScriptNow(script)
71 }))
72 if task == nil {
73 return fmt.Errorf("schedule JavaScript: create CEF task")
74 }
75 if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
76 return fmt.Errorf("schedule JavaScript: post CEF task: result %d", result)
77 }
78 return nil
79}
80
81func headingNavigationScript(direction, count int, highlightColor, namespace string) string {
82 if highlightColor == "" {
83 highlightColor = "#fbbf24"
84 }
85 return `(() => {
86 const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
87 const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
88 const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
89 const highlightColor = ` + strconv.Quote(highlightColor) + `;
90 const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))
91 .filter((heading) => {
92 if (heading.getClientRects().length === 0) return false;
93 if (heading.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false;
94 const style = window.getComputedStyle(heading);
95 return style.visibility !== "hidden" && style.visibility !== "collapse";
96 });
97 if (headings.length === 0) return;
98
99 const prior = window[stateKey];
100 let index = headings.indexOf(prior);
101 if (index < 0) {
102 const firstAtOrAfterViewportTop = headings.findIndex(
103 (heading) => heading.getBoundingClientRect().top >= 0,
104 );
105 if (` + strconv.Itoa(direction) + ` > 0) {
106 index = firstAtOrAfterViewportTop - 1;
107 if (index < -1) index = -1;
108 } else {
109 index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length;
110 }
111 }
112
113 const next = index + (` + strconv.Itoa(direction) + ` * ` + strconv.Itoa(count) + `);
114 if (next < 0 || next >= headings.length) return;
115 const target = headings[next];
116
117 const previous = document.querySelector("[" + targetAttribute + "]");
118 if (previous && previous !== target) previous.removeAttribute(targetAttribute);
119 let style = document.querySelector("style[" + styleAttribute + "]");
120 if (!style) {
121 // This visual cue is best-effort: strict page CSP can reject a DOM style
122 // element, but target selection and scrolling must stay independent of it.
123 style = document.createElement("style");
124 style.setAttribute(styleAttribute, "");
125 document.documentElement.appendChild(style);
126 }
127 // Refresh the outline on every navigation so an in-session accent change is
128 // reflected even when the target style element already exists.
129 style.textContent = "[" + targetAttribute + "]" +
130 " { outline: 3px solid " + highlightColor +
131 " !important; outline-offset: 5px !important; border-radius: 3px !important; }";
132 window[stateKey] = target;
133 target.setAttribute(targetAttribute, "");
134 target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
135})();`
136}
137
138func clearHeadingNavigationHighlightScript(namespace string) string {
139 return `(() => {
140 const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
141 const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
142 const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
143 document.querySelectorAll("[" + targetAttribute + "]").forEach((target) => {
144 target.removeAttribute(targetAttribute);
145 });
146 document.querySelectorAll("style[" + styleAttribute + "]").forEach((style) => {
147 style.remove();
148 });
149 delete window[stateKey];
150})();`
151}