feat(ui): show link URL in status overlay on hover
- commit
- a976d898bac731534c929abb54567fd3e7ae534c
- author
- bnema · · 2025-12-25 21:44 UTC
- parent
- 5738c09
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
6 files · +285 -4
M internal/application/port/webview.go +3 -0
| @@ -89,6 +89,9 @@ type WebViewCallbacks struct { | |||
| 89 | 89 | OnCreate func(request PopupRequest) WebView | |
| 90 | 90 | // OnReadyToShow is called when a popup WebView is ready to display. | |
| 91 | 91 | OnReadyToShow func() | |
| 92 | + | // OnLinkHover is called when hovering over a link, image, or media element. | |
| 93 | + | // The uri parameter contains the target URL, or empty string when leaving. | |
| 94 | + | OnLinkHover func(uri string) | |
| 92 | 95 | } | |
| 93 | 96 | ||
| 94 | 97 | // FindOptions configures search behavior. | |
| @@ -89,6 +89,9 @@ type WebViewCallbacks struct { | |||||
| 89 | OnCreate func(request PopupRequest) WebView | 89 | OnCreate func(request PopupRequest) WebView | ||
| 90 | // OnReadyToShow is called when a popup WebView is ready to display. | 90 | // OnReadyToShow is called when a popup WebView is ready to display. | ||
| 91 | OnReadyToShow func() | 91 | OnReadyToShow func() | ||
| 92 | + | // OnLinkHover is called when hovering over a link, image, or media element. | |||
| 93 | + | // The uri parameter contains the target URL, or empty string when leaving. | |||
| 94 | + | OnLinkHover func(uri string) | |||
| 92 | } | 95 | } | ||
| 93 | 96 | ||||
| 94 | // FindOptions configures search behavior. | 97 | // FindOptions configures search behavior. | ||
M internal/infrastructure/webkit/webview.go +32 -0
| @@ -118,6 +118,7 @@ type WebView struct { | |||
| 118 | 118 | OnEnterFullscreen func() bool // Return true to prevent fullscreen | |
| 119 | 119 | OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen | |
| 120 | 120 | OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops | |
| 121 | + | OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving) | |
| 121 | 122 | ||
| 122 | 123 | logger zerolog.Logger | |
| 123 | 124 | mu sync.RWMutex | |
| @@ -328,6 +329,7 @@ func (wv *WebView) connectSignals() { | |||
| 328 | 329 | wv.connectEnterFullscreenSignal() | |
| 329 | 330 | wv.connectLeaveFullscreenSignal() | |
| 330 | 331 | wv.connectAudioStateSignal() | |
| 332 | + | wv.connectMouseTargetChangedSignal() | |
| 331 | 333 | } | |
| 332 | 334 | ||
| 333 | 335 | func (wv *WebView) connectLoadChangedSignal() { | |
| @@ -593,6 +595,34 @@ func (wv *WebView) connectAudioStateSignal() { | |||
| 593 | 595 | wv.signalIDs = append(wv.signalIDs, sigID) | |
| 594 | 596 | } | |
| 595 | 597 | ||
| 598 | + | func (wv *WebView) connectMouseTargetChangedSignal() { | |
| 599 | + | mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) { | |
| 600 | + | if wv.OnLinkHover == nil { | |
| 601 | + | return | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | hitResult := webkit.HitTestResultNewFromInternalPtr(hitTestPtr) | |
| 605 | + | if hitResult == nil { | |
| 606 | + | wv.OnLinkHover("") | |
| 607 | + | return | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | var uri string | |
| 611 | + | switch { | |
| 612 | + | case hitResult.ContextIsLink(): | |
| 613 | + | uri = hitResult.GetLinkUri() | |
| 614 | + | case hitResult.ContextIsImage(): | |
| 615 | + | uri = hitResult.GetImageUri() | |
| 616 | + | case hitResult.ContextIsMedia(): | |
| 617 | + | uri = hitResult.GetMediaUri() | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | wv.OnLinkHover(uri) | |
| 621 | + | } | |
| 622 | + | sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb) | |
| 623 | + | wv.signalIDs = append(wv.signalIDs, sigID) | |
| 624 | + | } | |
| 625 | + | ||
| 596 | 626 | // ID returns the unique identifier for this WebView. | |
| 597 | 627 | func (wv *WebView) ID() WebViewID { | |
| 598 | 628 | return wv.id | |
| @@ -862,6 +892,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||
| 862 | 892 | return nil | |
| 863 | 893 | } | |
| 864 | 894 | } | |
| 895 | + | wv.OnLinkHover = callbacks.OnLinkHover | |
| 865 | 896 | } | |
| 866 | 897 | ||
| 867 | 898 | // ShowDevTools opens the WebKit inspector/developer tools. | |
| @@ -935,6 +966,7 @@ func (wv *WebView) Destroy() { | |||
| 935 | 966 | wv.OnEnterFullscreen = nil | |
| 936 | 967 | wv.OnLeaveFullscreen = nil | |
| 937 | 968 | wv.OnAudioStateChanged = nil | |
| 969 | + | wv.OnLinkHover = nil | |
| 938 | 970 | ||
| 939 | 971 | // 3. Clear async callback references | |
| 940 | 972 | wv.mu.Lock() | |
| @@ -118,6 +118,7 @@ type WebView struct { | |||||
| 118 | OnEnterFullscreen func() bool // Return true to prevent fullscreen | 118 | OnEnterFullscreen func() bool // Return true to prevent fullscreen | ||
| 119 | OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen | 119 | OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen | ||
| 120 | OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops | 120 | OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops | ||
| 121 | + | OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving) | |||
| 121 | 122 | ||||
| 122 | logger zerolog.Logger | 123 | logger zerolog.Logger | ||
| 123 | mu sync.RWMutex | 124 | mu sync.RWMutex | ||
| @@ -328,6 +329,7 @@ func (wv *WebView) connectSignals() { | |||||
| 328 | wv.connectEnterFullscreenSignal() | 329 | wv.connectEnterFullscreenSignal() | ||
| 329 | wv.connectLeaveFullscreenSignal() | 330 | wv.connectLeaveFullscreenSignal() | ||
| 330 | wv.connectAudioStateSignal() | 331 | wv.connectAudioStateSignal() | ||
| 332 | + | wv.connectMouseTargetChangedSignal() | |||
| 331 | } | 333 | } | ||
| 332 | 334 | ||||
| 333 | func (wv *WebView) connectLoadChangedSignal() { | 335 | func (wv *WebView) connectLoadChangedSignal() { | ||
| @@ -593,6 +595,34 @@ func (wv *WebView) connectAudioStateSignal() { | |||||
| 593 | wv.signalIDs = append(wv.signalIDs, sigID) | 595 | wv.signalIDs = append(wv.signalIDs, sigID) | ||
| 594 | } | 596 | } | ||
| 595 | 597 | ||||
| 598 | + | func (wv *WebView) connectMouseTargetChangedSignal() { | |||
| 599 | + | mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) { | |||
| 600 | + | if wv.OnLinkHover == nil { | |||
| 601 | + | return | |||
| 602 | + | } | |||
| 603 | + | ||||
| 604 | + | hitResult := webkit.HitTestResultNewFromInternalPtr(hitTestPtr) | |||
| 605 | + | if hitResult == nil { | |||
| 606 | + | wv.OnLinkHover("") | |||
| 607 | + | return | |||
| 608 | + | } | |||
| 609 | + | ||||
| 610 | + | var uri string | |||
| 611 | + | switch { | |||
| 612 | + | case hitResult.ContextIsLink(): | |||
| 613 | + | uri = hitResult.GetLinkUri() | |||
| 614 | + | case hitResult.ContextIsImage(): | |||
| 615 | + | uri = hitResult.GetImageUri() | |||
| 616 | + | case hitResult.ContextIsMedia(): | |||
| 617 | + | uri = hitResult.GetMediaUri() | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | wv.OnLinkHover(uri) | |||
| 621 | + | } | |||
| 622 | + | sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb) | |||
| 623 | + | wv.signalIDs = append(wv.signalIDs, sigID) | |||
| 624 | + | } | |||
| 625 | + | ||||
| 596 | // ID returns the unique identifier for this WebView. | 626 | // ID returns the unique identifier for this WebView. | ||
| 597 | func (wv *WebView) ID() WebViewID { | 627 | func (wv *WebView) ID() WebViewID { | ||
| 598 | return wv.id | 628 | return wv.id | ||
| @@ -862,6 +892,7 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||||
| 862 | return nil | 892 | return nil | ||
| 863 | } | 893 | } | ||
| 864 | } | 894 | } | ||
| 895 | + | wv.OnLinkHover = callbacks.OnLinkHover | |||
| 865 | } | 896 | } | ||
| 866 | 897 | ||||
| 867 | // ShowDevTools opens the WebKit inspector/developer tools. | 898 | // ShowDevTools opens the WebKit inspector/developer tools. | ||
| @@ -935,6 +966,7 @@ func (wv *WebView) Destroy() { | |||||
| 935 | wv.OnEnterFullscreen = nil | 966 | wv.OnEnterFullscreen = nil | ||
| 936 | wv.OnLeaveFullscreen = nil | 967 | wv.OnLeaveFullscreen = nil | ||
| 937 | wv.OnAudioStateChanged = nil | 968 | wv.OnAudioStateChanged = nil | ||
| 969 | + | wv.OnLinkHover = nil | |||
| 938 | 970 | ||||
| 939 | // 3. Clear async callback references | 971 | // 3. Clear async callback references | ||
| 940 | wv.mu.Lock() | 972 | wv.mu.Lock() | ||
A internal/ui/component/link_status.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | // Package component provides UI components for the browser. | |
| 2 | + | package component | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 9 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker. | |
| 16 | + | linkStatusShowDelayMs = 100 | |
| 17 | + | // linkStatusMaxChars is the maximum characters before truncation. | |
| 18 | + | linkStatusMaxChars = 80 | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // LinkStatusOverlay displays the destination URL when hovering over links. | |
| 22 | + | // It appears in the bottom-left corner with a fade-in/fade-out transition. | |
| 23 | + | type LinkStatusOverlay struct { | |
| 24 | + | factory layout.WidgetFactory | |
| 25 | + | container layout.BoxWidget | |
| 26 | + | label layout.LabelWidget | |
| 27 | + | ||
| 28 | + | pendingURI string // URI waiting to be shown after delay | |
| 29 | + | showTimer uint // GLib timer for delayed show | |
| 30 | + | visible bool // Whether overlay is currently visible (has .visible class) | |
| 31 | + | mu sync.Mutex | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // NewLinkStatusOverlay creates a new link status overlay component. | |
| 35 | + | // The overlay is positioned at bottom-left with fade transitions via CSS. | |
| 36 | + | func NewLinkStatusOverlay(factory layout.WidgetFactory) *LinkStatusOverlay { | |
| 37 | + | // Create container box for styling and positioning | |
| 38 | + | container := factory.NewBox(layout.OrientationHorizontal, 0) | |
| 39 | + | container.AddCssClass("link-status") | |
| 40 | + | ||
| 41 | + | // Position at bottom-left | |
| 42 | + | container.SetHalign(gtk.AlignStartValue) | |
| 43 | + | container.SetValign(gtk.AlignEndValue) | |
| 44 | + | ||
| 45 | + | // Don't expand to fill space | |
| 46 | + | container.SetHexpand(false) | |
| 47 | + | container.SetVexpand(false) | |
| 48 | + | ||
| 49 | + | // Don't intercept pointer events - let clicks pass through | |
| 50 | + | container.SetCanTarget(false) | |
| 51 | + | container.SetCanFocus(false) | |
| 52 | + | ||
| 53 | + | // Create label for URL text | |
| 54 | + | label := factory.NewLabel("") | |
| 55 | + | label.SetCanTarget(false) | |
| 56 | + | label.SetCanFocus(false) | |
| 57 | + | label.SetEllipsize(layout.EllipsizeMiddle) // Truncate in middle for URLs | |
| 58 | + | label.SetMaxWidthChars(linkStatusMaxChars) | |
| 59 | + | container.Append(label) | |
| 60 | + | ||
| 61 | + | return &LinkStatusOverlay{ | |
| 62 | + | factory: factory, | |
| 63 | + | container: container, | |
| 64 | + | label: label, | |
| 65 | + | visible: false, | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Show displays the link status overlay with the given URI. | |
| 70 | + | // If uri is empty, hides the overlay instead. | |
| 71 | + | // Uses a small delay to avoid flicker during rapid mouse movement. | |
| 72 | + | func (l *LinkStatusOverlay) Show(uri string) { | |
| 73 | + | l.mu.Lock() | |
| 74 | + | defer l.mu.Unlock() | |
| 75 | + | ||
| 76 | + | // Cancel any pending show timer | |
| 77 | + | if l.showTimer != 0 { | |
| 78 | + | glib.SourceRemove(l.showTimer) | |
| 79 | + | l.showTimer = 0 | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | if uri == "" { | |
| 83 | + | l.hide() | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | l.pendingURI = uri | |
| 88 | + | ||
| 89 | + | // Start delay timer before showing | |
| 90 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 91 | + | l.mu.Lock() | |
| 92 | + | defer l.mu.Unlock() | |
| 93 | + | ||
| 94 | + | if l.pendingURI != "" { | |
| 95 | + | l.label.SetText(l.pendingURI) | |
| 96 | + | if !l.visible { | |
| 97 | + | l.visible = true | |
| 98 | + | l.container.AddCssClass("visible") | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | l.showTimer = 0 | |
| 102 | + | return false // Don't repeat | |
| 103 | + | }) | |
| 104 | + | ||
| 105 | + | l.showTimer = glib.TimeoutAdd(uint(linkStatusShowDelayMs/time.Millisecond), &cb, 0) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Hide manually hides the link status overlay. | |
| 109 | + | func (l *LinkStatusOverlay) Hide() { | |
| 110 | + | l.mu.Lock() | |
| 111 | + | defer l.mu.Unlock() | |
| 112 | + | l.hide() | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // hide performs the actual hide operation (must be called with lock held). | |
| 116 | + | func (l *LinkStatusOverlay) hide() { | |
| 117 | + | // Cancel pending show timer | |
| 118 | + | if l.showTimer != 0 { | |
| 119 | + | glib.SourceRemove(l.showTimer) | |
| 120 | + | l.showTimer = 0 | |
| 121 | + | } | |
| 122 | + | l.pendingURI = "" | |
| 123 | + | ||
| 124 | + | if l.visible { | |
| 125 | + | l.visible = false | |
| 126 | + | l.container.RemoveCssClass("visible") | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // Widget returns the underlying widget for embedding in overlays. | |
| 131 | + | func (l *LinkStatusOverlay) Widget() layout.Widget { | |
| 132 | + | return l.container | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // IsVisible returns whether the overlay is currently visible. | |
| 136 | + | func (l *LinkStatusOverlay) IsVisible() bool { | |
| 137 | + | l.mu.Lock() | |
| 138 | + | defer l.mu.Unlock() | |
| 139 | + | return l.visible | |
| 140 | + | } | |
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | // Package component provides UI components for the browser. | |||
| 2 | + | package component | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "sync" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |||
| 9 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker. | |||
| 16 | + | linkStatusShowDelayMs = 100 | |||
| 17 | + | // linkStatusMaxChars is the maximum characters before truncation. | |||
| 18 | + | linkStatusMaxChars = 80 | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // LinkStatusOverlay displays the destination URL when hovering over links. | |||
| 22 | + | // It appears in the bottom-left corner with a fade-in/fade-out transition. | |||
| 23 | + | type LinkStatusOverlay struct { | |||
| 24 | + | factory layout.WidgetFactory | |||
| 25 | + | container layout.BoxWidget | |||
| 26 | + | label layout.LabelWidget | |||
| 27 | + | ||||
| 28 | + | pendingURI string // URI waiting to be shown after delay | |||
| 29 | + | showTimer uint // GLib timer for delayed show | |||
| 30 | + | visible bool // Whether overlay is currently visible (has .visible class) | |||
| 31 | + | mu sync.Mutex | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // NewLinkStatusOverlay creates a new link status overlay component. | |||
| 35 | + | // The overlay is positioned at bottom-left with fade transitions via CSS. | |||
| 36 | + | func NewLinkStatusOverlay(factory layout.WidgetFactory) *LinkStatusOverlay { | |||
| 37 | + | // Create container box for styling and positioning | |||
| 38 | + | container := factory.NewBox(layout.OrientationHorizontal, 0) | |||
| 39 | + | container.AddCssClass("link-status") | |||
| 40 | + | ||||
| 41 | + | // Position at bottom-left | |||
| 42 | + | container.SetHalign(gtk.AlignStartValue) | |||
| 43 | + | container.SetValign(gtk.AlignEndValue) | |||
| 44 | + | ||||
| 45 | + | // Don't expand to fill space | |||
| 46 | + | container.SetHexpand(false) | |||
| 47 | + | container.SetVexpand(false) | |||
| 48 | + | ||||
| 49 | + | // Don't intercept pointer events - let clicks pass through | |||
| 50 | + | container.SetCanTarget(false) | |||
| 51 | + | container.SetCanFocus(false) | |||
| 52 | + | ||||
| 53 | + | // Create label for URL text | |||
| 54 | + | label := factory.NewLabel("") | |||
| 55 | + | label.SetCanTarget(false) | |||
| 56 | + | label.SetCanFocus(false) | |||
| 57 | + | label.SetEllipsize(layout.EllipsizeMiddle) // Truncate in middle for URLs | |||
| 58 | + | label.SetMaxWidthChars(linkStatusMaxChars) | |||
| 59 | + | container.Append(label) | |||
| 60 | + | ||||
| 61 | + | return &LinkStatusOverlay{ | |||
| 62 | + | factory: factory, | |||
| 63 | + | container: container, | |||
| 64 | + | label: label, | |||
| 65 | + | visible: false, | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Show displays the link status overlay with the given URI. | |||
| 70 | + | // If uri is empty, hides the overlay instead. | |||
| 71 | + | // Uses a small delay to avoid flicker during rapid mouse movement. | |||
| 72 | + | func (l *LinkStatusOverlay) Show(uri string) { | |||
| 73 | + | l.mu.Lock() | |||
| 74 | + | defer l.mu.Unlock() | |||
| 75 | + | ||||
| 76 | + | // Cancel any pending show timer | |||
| 77 | + | if l.showTimer != 0 { | |||
| 78 | + | glib.SourceRemove(l.showTimer) | |||
| 79 | + | l.showTimer = 0 | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | if uri == "" { | |||
| 83 | + | l.hide() | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | l.pendingURI = uri | |||
| 88 | + | ||||
| 89 | + | // Start delay timer before showing | |||
| 90 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 91 | + | l.mu.Lock() | |||
| 92 | + | defer l.mu.Unlock() | |||
| 93 | + | ||||
| 94 | + | if l.pendingURI != "" { | |||
| 95 | + | l.label.SetText(l.pendingURI) | |||
| 96 | + | if !l.visible { | |||
| 97 | + | l.visible = true | |||
| 98 | + | l.container.AddCssClass("visible") | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | l.showTimer = 0 | |||
| 102 | + | return false // Don't repeat | |||
| 103 | + | }) | |||
| 104 | + | ||||
| 105 | + | l.showTimer = glib.TimeoutAdd(uint(linkStatusShowDelayMs/time.Millisecond), &cb, 0) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // Hide manually hides the link status overlay. | |||
| 109 | + | func (l *LinkStatusOverlay) Hide() { | |||
| 110 | + | l.mu.Lock() | |||
| 111 | + | defer l.mu.Unlock() | |||
| 112 | + | l.hide() | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // hide performs the actual hide operation (must be called with lock held). | |||
| 116 | + | func (l *LinkStatusOverlay) hide() { | |||
| 117 | + | // Cancel pending show timer | |||
| 118 | + | if l.showTimer != 0 { | |||
| 119 | + | glib.SourceRemove(l.showTimer) | |||
| 120 | + | l.showTimer = 0 | |||
| 121 | + | } | |||
| 122 | + | l.pendingURI = "" | |||
| 123 | + | ||||
| 124 | + | if l.visible { | |||
| 125 | + | l.visible = false | |||
| 126 | + | l.container.RemoveCssClass("visible") | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // Widget returns the underlying widget for embedding in overlays. | |||
| 131 | + | func (l *LinkStatusOverlay) Widget() layout.Widget { | |||
| 132 | + | return l.container | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // IsVisible returns whether the overlay is currently visible. | |||
| 136 | + | func (l *LinkStatusOverlay) IsVisible() bool { | |||
| 137 | + | l.mu.Lock() | |||
| 138 | + | defer l.mu.Unlock() | |||
| 139 | + | return l.visible | |||
| 140 | + | } | |||
M internal/ui/component/pane_view.go +45 -4
| @@ -20,10 +20,11 @@ const ( | |||
| 20 | 20 | type PaneView struct { | |
| 21 | 21 | factory layout.WidgetFactory | |
| 22 | 22 | overlay layout.OverlayWidget | |
| 23 | - | webViewWidget layout.Widget // The actual WebView widget | |
| 24 | - | borderBox layout.BoxWidget // Border overlay for active indication | |
| 25 | - | progressBar *ProgressBar // Loading progress indicator | |
| 26 | - | toaster *Toaster // Toast notification overlay | |
| 23 | + | webViewWidget layout.Widget // The actual WebView widget | |
| 24 | + | borderBox layout.BoxWidget // Border overlay for active indication | |
| 25 | + | progressBar *ProgressBar // Loading progress indicator | |
| 26 | + | toaster *Toaster // Toast notification overlay | |
| 27 | + | linkStatus *LinkStatusOverlay // Link hover URL overlay | |
| 27 | 28 | paneID entity.PaneID | |
| 28 | 29 | isActive bool | |
| 29 | 30 | ||
| @@ -350,6 +351,42 @@ func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) { | |||
| 350 | 351 | t.ShowZoom(ctx, zoomPercent) | |
| 351 | 352 | } | |
| 352 | 353 | ||
| 354 | + | // ensureLinkStatus creates the link status overlay lazily on first use. | |
| 355 | + | // Must be called with write lock held. | |
| 356 | + | func (pv *PaneView) ensureLinkStatus() *LinkStatusOverlay { | |
| 357 | + | if pv.linkStatus != nil { | |
| 358 | + | return pv.linkStatus | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | ls := NewLinkStatusOverlay(pv.factory) | |
| 362 | + | pv.overlay.AddOverlay(ls.Widget()) | |
| 363 | + | pv.overlay.SetClipOverlay(ls.Widget(), false) | |
| 364 | + | pv.overlay.SetMeasureOverlay(ls.Widget(), false) | |
| 365 | + | pv.linkStatus = ls | |
| 366 | + | return ls | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | // ShowLinkStatus displays the link status overlay with the given URI. | |
| 370 | + | // If uri is empty, hides the overlay instead. | |
| 371 | + | func (pv *PaneView) ShowLinkStatus(uri string) { | |
| 372 | + | pv.mu.Lock() | |
| 373 | + | ls := pv.ensureLinkStatus() | |
| 374 | + | pv.mu.Unlock() | |
| 375 | + | ||
| 376 | + | ls.Show(uri) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // HideLinkStatus hides the link status overlay. | |
| 380 | + | func (pv *PaneView) HideLinkStatus() { | |
| 381 | + | pv.mu.Lock() | |
| 382 | + | ls := pv.linkStatus | |
| 383 | + | pv.mu.Unlock() | |
| 384 | + | ||
| 385 | + | if ls != nil { | |
| 386 | + | ls.Hide() | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | ||
| 353 | 390 | // Cleanup removes the WebView widget from the overlay and clears references. | |
| 354 | 391 | // This must be called before destroying the WebView to ensure proper GTK cleanup. | |
| 355 | 392 | // After calling Cleanup, the PaneView should not be reused. | |
| @@ -383,4 +420,8 @@ func (pv *PaneView) Cleanup() { | |||
| 383 | 420 | pv.overlay.RemoveOverlay(pv.toaster.Widget()) | |
| 384 | 421 | pv.toaster = nil | |
| 385 | 422 | } | |
| 423 | + | if pv.linkStatus != nil { | |
| 424 | + | pv.overlay.RemoveOverlay(pv.linkStatus.Widget()) | |
| 425 | + | pv.linkStatus = nil | |
| 426 | + | } | |
| 386 | 427 | } | |
| @@ -20,10 +20,11 @@ const ( | |||||
| 20 | type PaneView struct { | 20 | type PaneView struct { | ||
| 21 | factory layout.WidgetFactory | 21 | factory layout.WidgetFactory | ||
| 22 | overlay layout.OverlayWidget | 22 | overlay layout.OverlayWidget | ||
| 23 | - | webViewWidget layout.Widget // The actual WebView widget | 23 | + | webViewWidget layout.Widget // The actual WebView widget |
| 24 | - | borderBox layout.BoxWidget // Border overlay for active indication | 24 | + | borderBox layout.BoxWidget // Border overlay for active indication |
| 25 | - | progressBar *ProgressBar // Loading progress indicator | 25 | + | progressBar *ProgressBar // Loading progress indicator |
| 26 | - | toaster *Toaster // Toast notification overlay | 26 | + | toaster *Toaster // Toast notification overlay |
| 27 | + | linkStatus *LinkStatusOverlay // Link hover URL overlay | |||
| 27 | paneID entity.PaneID | 28 | paneID entity.PaneID | ||
| 28 | isActive bool | 29 | isActive bool | ||
| 29 | 30 | ||||
| @@ -350,6 +351,42 @@ func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) { | |||||
| 350 | t.ShowZoom(ctx, zoomPercent) | 351 | t.ShowZoom(ctx, zoomPercent) | ||
| 351 | } | 352 | } | ||
| 352 | 353 | ||||
| 354 | + | // ensureLinkStatus creates the link status overlay lazily on first use. | |||
| 355 | + | // Must be called with write lock held. | |||
| 356 | + | func (pv *PaneView) ensureLinkStatus() *LinkStatusOverlay { | |||
| 357 | + | if pv.linkStatus != nil { | |||
| 358 | + | return pv.linkStatus | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | ls := NewLinkStatusOverlay(pv.factory) | |||
| 362 | + | pv.overlay.AddOverlay(ls.Widget()) | |||
| 363 | + | pv.overlay.SetClipOverlay(ls.Widget(), false) | |||
| 364 | + | pv.overlay.SetMeasureOverlay(ls.Widget(), false) | |||
| 365 | + | pv.linkStatus = ls | |||
| 366 | + | return ls | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | // ShowLinkStatus displays the link status overlay with the given URI. | |||
| 370 | + | // If uri is empty, hides the overlay instead. | |||
| 371 | + | func (pv *PaneView) ShowLinkStatus(uri string) { | |||
| 372 | + | pv.mu.Lock() | |||
| 373 | + | ls := pv.ensureLinkStatus() | |||
| 374 | + | pv.mu.Unlock() | |||
| 375 | + | ||||
| 376 | + | ls.Show(uri) | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | // HideLinkStatus hides the link status overlay. | |||
| 380 | + | func (pv *PaneView) HideLinkStatus() { | |||
| 381 | + | pv.mu.Lock() | |||
| 382 | + | ls := pv.linkStatus | |||
| 383 | + | pv.mu.Unlock() | |||
| 384 | + | ||||
| 385 | + | if ls != nil { | |||
| 386 | + | ls.Hide() | |||
| 387 | + | } | |||
| 388 | + | } | |||
| 389 | + | ||||
| 353 | // Cleanup removes the WebView widget from the overlay and clears references. | 390 | // Cleanup removes the WebView widget from the overlay and clears references. | ||
| 354 | // This must be called before destroying the WebView to ensure proper GTK cleanup. | 391 | // This must be called before destroying the WebView to ensure proper GTK cleanup. | ||
| 355 | // After calling Cleanup, the PaneView should not be reused. | 392 | // After calling Cleanup, the PaneView should not be reused. | ||
| @@ -383,4 +420,8 @@ func (pv *PaneView) Cleanup() { | |||||
| 383 | pv.overlay.RemoveOverlay(pv.toaster.Widget()) | 420 | pv.overlay.RemoveOverlay(pv.toaster.Widget()) | ||
| 384 | pv.toaster = nil | 421 | pv.toaster = nil | ||
| 385 | } | 422 | } | ||
| 423 | + | if pv.linkStatus != nil { | |||
| 424 | + | pv.overlay.RemoveOverlay(pv.linkStatus.Widget()) | |||
| 425 | + | pv.linkStatus = nil | |||
| 426 | + | } | |||
| 386 | } | 427 | } | ||
M internal/ui/coordinator/content.go +29 -0
| @@ -190,6 +190,11 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa | |||
| 190 | 190 | return c.handleLinkMiddleClick(ctx, paneID, uri) | |
| 191 | 191 | } | |
| 192 | 192 | ||
| 193 | + | // Set up link hover callback for status overlay | |
| 194 | + | wv.OnLinkHover = func(uri string) { | |
| 195 | + | c.onLinkHover(paneID, uri) | |
| 196 | + | } | |
| 197 | + | ||
| 193 | 198 | // Set up fullscreen handlers for idle inhibition | |
| 194 | 199 | c.setupIdleInhibitionHandlers(ctx, paneID, wv) | |
| 195 | 200 | ||
| @@ -764,6 +769,25 @@ func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress fl | |||
| 764 | 769 | } | |
| 765 | 770 | } | |
| 766 | 771 | ||
| 772 | + | // onLinkHover updates the link status overlay when hovering over links. | |
| 773 | + | func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) { | |
| 774 | + | _, wsView := c.getActiveWS() | |
| 775 | + | if wsView == nil { | |
| 776 | + | return | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | paneView := wsView.GetPaneView(paneID) | |
| 780 | + | if paneView == nil { | |
| 781 | + | return | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | if uri != "" { | |
| 785 | + | paneView.ShowLinkStatus(uri) | |
| 786 | + | } else { | |
| 787 | + | paneView.HideLinkStatus() | |
| 788 | + | } | |
| 789 | + | } | |
| 790 | + | ||
| 767 | 791 | // --- Popup Handling --- | |
| 768 | 792 | ||
| 769 | 793 | // SetPopupConfig configures popup handling. | |
| @@ -1050,6 +1074,11 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||
| 1050 | 1074 | return c.handleLinkMiddleClick(ctx, paneID, uri) | |
| 1051 | 1075 | } | |
| 1052 | 1076 | ||
| 1077 | + | // Link hover callback for status overlay | |
| 1078 | + | wv.OnLinkHover = func(uri string) { | |
| 1079 | + | c.onLinkHover(paneID, uri) | |
| 1080 | + | } | |
| 1081 | + | ||
| 1053 | 1082 | // Fullscreen handlers for idle inhibition | |
| 1054 | 1083 | c.setupIdleInhibitionHandlers(ctx, paneID, wv) | |
| 1055 | 1084 | } | |
| @@ -190,6 +190,11 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa | |||||
| 190 | return c.handleLinkMiddleClick(ctx, paneID, uri) | 190 | return c.handleLinkMiddleClick(ctx, paneID, uri) | ||
| 191 | } | 191 | } | ||
| 192 | 192 | ||||
| 193 | + | // Set up link hover callback for status overlay | |||
| 194 | + | wv.OnLinkHover = func(uri string) { | |||
| 195 | + | c.onLinkHover(paneID, uri) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 193 | // Set up fullscreen handlers for idle inhibition | 198 | // Set up fullscreen handlers for idle inhibition | ||
| 194 | c.setupIdleInhibitionHandlers(ctx, paneID, wv) | 199 | c.setupIdleInhibitionHandlers(ctx, paneID, wv) | ||
| 195 | 200 | ||||
| @@ -764,6 +769,25 @@ func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress fl | |||||
| 764 | } | 769 | } | ||
| 765 | } | 770 | } | ||
| 766 | 771 | ||||
| 772 | + | // onLinkHover updates the link status overlay when hovering over links. | |||
| 773 | + | func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) { | |||
| 774 | + | _, wsView := c.getActiveWS() | |||
| 775 | + | if wsView == nil { | |||
| 776 | + | return | |||
| 777 | + | } | |||
| 778 | + | ||||
| 779 | + | paneView := wsView.GetPaneView(paneID) | |||
| 780 | + | if paneView == nil { | |||
| 781 | + | return | |||
| 782 | + | } | |||
| 783 | + | ||||
| 784 | + | if uri != "" { | |||
| 785 | + | paneView.ShowLinkStatus(uri) | |||
| 786 | + | } else { | |||
| 787 | + | paneView.HideLinkStatus() | |||
| 788 | + | } | |||
| 789 | + | } | |||
| 790 | + | ||||
| 767 | // --- Popup Handling --- | 791 | // --- Popup Handling --- | ||
| 768 | 792 | ||||
| 769 | // SetPopupConfig configures popup handling. | 793 | // SetPopupConfig configures popup handling. | ||
| @@ -1050,6 +1074,11 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||||
| 1050 | return c.handleLinkMiddleClick(ctx, paneID, uri) | 1074 | return c.handleLinkMiddleClick(ctx, paneID, uri) | ||
| 1051 | } | 1075 | } | ||
| 1052 | 1076 | ||||
| 1077 | + | // Link hover callback for status overlay | |||
| 1078 | + | wv.OnLinkHover = func(uri string) { | |||
| 1079 | + | c.onLinkHover(paneID, uri) | |||
| 1080 | + | } | |||
| 1081 | + | ||||
| 1053 | // Fullscreen handlers for idle inhibition | 1082 | // Fullscreen handlers for idle inhibition | ||
| 1054 | c.setupIdleInhibitionHandlers(ctx, paneID, wv) | 1083 | c.setupIdleInhibitionHandlers(ctx, paneID, wv) | ||
| 1055 | } | 1084 | } | ||
M internal/ui/theme/css.go +36 -0
| @@ -108,6 +108,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st | |||
| 108 | 108 | sb.WriteString(generateToasterCSS(p)) | |
| 109 | 109 | sb.WriteString("\n") | |
| 110 | 110 | ||
| 111 | + | // Link status overlay styling | |
| 112 | + | sb.WriteString(generateLinkStatusCSS(p)) | |
| 113 | + | sb.WriteString("\n") | |
| 114 | + | ||
| 111 | 115 | // Session manager styling | |
| 112 | 116 | sb.WriteString(generateSessionManagerCSS(p)) | |
| 113 | 117 | ||
| @@ -761,3 +765,35 @@ func generateToasterCSS(p Palette) string { | |||
| 761 | 765 | } | |
| 762 | 766 | ` | |
| 763 | 767 | } | |
| 768 | + | ||
| 769 | + | // generateLinkStatusCSS creates link status overlay styles. | |
| 770 | + | // Uses CSS transitions for fade-in/fade-out effect. | |
| 771 | + | func generateLinkStatusCSS(p Palette) string { | |
| 772 | + | return `/* ===== Link Status Overlay Styling ===== */ | |
| 773 | + | ||
| 774 | + | /* Link status container - bottom left positioning */ | |
| 775 | + | .link-status { | |
| 776 | + | background-color: alpha(var(--surface-variant), 0.95); | |
| 777 | + | border-radius: 0.25em 0.25em 0 0; | |
| 778 | + | padding: 0.25em 0.5em; | |
| 779 | + | margin: 0; | |
| 780 | + | font-size: 0.75em; | |
| 781 | + | max-width: 50em; | |
| 782 | + | box-shadow: 0 -1px 4px alpha(black, 0.15); | |
| 783 | + | ||
| 784 | + | /* Fade transition - hidden by default */ | |
| 785 | + | opacity: 0; | |
| 786 | + | transition: opacity 150ms ease-in-out; | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | /* Visible state - triggered by adding .visible class */ | |
| 790 | + | .link-status.visible { | |
| 791 | + | opacity: 1; | |
| 792 | + | } | |
| 793 | + | ||
| 794 | + | /* Link status label text */ | |
| 795 | + | .link-status label { | |
| 796 | + | color: var(--muted); | |
| 797 | + | } | |
| 798 | + | ` | |
| 799 | + | } | |
| @@ -108,6 +108,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st | |||||
| 108 | sb.WriteString(generateToasterCSS(p)) | 108 | sb.WriteString(generateToasterCSS(p)) | ||
| 109 | sb.WriteString("\n") | 109 | sb.WriteString("\n") | ||
| 110 | 110 | ||||
| 111 | + | // Link status overlay styling | |||
| 112 | + | sb.WriteString(generateLinkStatusCSS(p)) | |||
| 113 | + | sb.WriteString("\n") | |||
| 114 | + | ||||
| 111 | // Session manager styling | 115 | // Session manager styling | ||
| 112 | sb.WriteString(generateSessionManagerCSS(p)) | 116 | sb.WriteString(generateSessionManagerCSS(p)) | ||
| 113 | 117 | ||||
| @@ -761,3 +765,35 @@ func generateToasterCSS(p Palette) string { | |||||
| 761 | } | 765 | } | ||
| 762 | ` | 766 | ` | ||
| 763 | } | 767 | } | ||
| 768 | + | ||||
| 769 | + | // generateLinkStatusCSS creates link status overlay styles. | |||
| 770 | + | // Uses CSS transitions for fade-in/fade-out effect. | |||
| 771 | + | func generateLinkStatusCSS(p Palette) string { | |||
| 772 | + | return `/* ===== Link Status Overlay Styling ===== */ | |||
| 773 | + | ||||
| 774 | + | /* Link status container - bottom left positioning */ | |||
| 775 | + | .link-status { | |||
| 776 | + | background-color: alpha(var(--surface-variant), 0.95); | |||
| 777 | + | border-radius: 0.25em 0.25em 0 0; | |||
| 778 | + | padding: 0.25em 0.5em; | |||
| 779 | + | margin: 0; | |||
| 780 | + | font-size: 0.75em; | |||
| 781 | + | max-width: 50em; | |||
| 782 | + | box-shadow: 0 -1px 4px alpha(black, 0.15); | |||
| 783 | + | ||||
| 784 | + | /* Fade transition - hidden by default */ | |||
| 785 | + | opacity: 0; | |||
| 786 | + | transition: opacity 150ms ease-in-out; | |||
| 787 | + | } | |||
| 788 | + | ||||
| 789 | + | /* Visible state - triggered by adding .visible class */ | |||
| 790 | + | .link-status.visible { | |||
| 791 | + | opacity: 1; | |||
| 792 | + | } | |||
| 793 | + | ||||
| 794 | + | /* Link status label text */ | |||
| 795 | + | .link-status label { | |||
| 796 | + | color: var(--muted); | |||
| 797 | + | } | |||
| 798 | + | ` | |||
| 799 | + | } | |||