feat(omnibox): add dynamic sizing, zoom indicator, and UI scale support
- commit
- 9134c7bf020d30d46ac84264386ca33d093b2bc3
- author
- brice · · 2025-12-15 19:49 UTC
- committer
- brice · · 2025-12-15 22:11 UTC
- parent
- 453b589
- signature
- unsigned
1 file · +76 -3
M internal/ui/component/omnibox.go +76 -3
| @@ -2,6 +2,7 @@ package component | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "time" | |
| @@ -17,7 +18,8 @@ import ( | |||
| 17 | 18 | ) | |
| 18 | 19 | ||
| 19 | 20 | const ( | |
| 20 | - | omniboxWidth = 600 | |
| 21 | + | omniboxWidthPct = 0.5 // 50% of parent window width | |
| 22 | + | omniboxHeightPct = 0.6 // 60% of parent window height | |
| 21 | 23 | omniboxMaxResults = 10 | |
| 22 | 24 | debounceDelayMs = 250 // Increased for stability | |
| 23 | 25 | ) | |
| @@ -54,6 +56,7 @@ type Omnibox struct { | |||
| 54 | 56 | headerBox *gtk.Box | |
| 55 | 57 | historyBtn *gtk.Button | |
| 56 | 58 | favoritesBtn *gtk.Button | |
| 59 | + | zoomLabel *gtk.Label | |
| 57 | 60 | entry *gtk.SearchEntry | |
| 58 | 61 | scrolledWin *gtk.ScrolledWindow | |
| 59 | 62 | listBox *gtk.ListBox | |
| @@ -86,6 +89,9 @@ type Omnibox struct { | |||
| 86 | 89 | debounceTimer *time.Timer | |
| 87 | 90 | debounceMu sync.Mutex | |
| 88 | 91 | lastQuery string // Prevent duplicate searches | |
| 92 | + | ||
| 93 | + | // Scaling | |
| 94 | + | uiScale float64 | |
| 89 | 95 | } | |
| 90 | 96 | ||
| 91 | 97 | // OmniboxConfig holds configuration for creating an Omnibox. | |
| @@ -96,12 +102,18 @@ type OmniboxConfig struct { | |||
| 96 | 102 | Shortcuts map[string]config.SearchShortcut | |
| 97 | 103 | DefaultSearch string | |
| 98 | 104 | InitialBehavior string | |
| 105 | + | UIScale float64 // UI scale for favicon sizing | |
| 99 | 106 | } | |
| 100 | 107 | ||
| 101 | 108 | // NewOmnibox creates a new native GTK4 omnibox widget. | |
| 102 | 109 | func NewOmnibox(ctx context.Context, parent *gtk.ApplicationWindow, cfg OmniboxConfig) *Omnibox { | |
| 103 | 110 | log := logging.FromContext(ctx) | |
| 104 | 111 | ||
| 112 | + | uiScale := cfg.UIScale | |
| 113 | + | if uiScale <= 0 { | |
| 114 | + | uiScale = 1.0 | |
| 115 | + | } | |
| 116 | + | ||
| 105 | 117 | o := &Omnibox{ | |
| 106 | 118 | parentWindow: parent, | |
| 107 | 119 | viewMode: ViewModeHistory, | |
| @@ -113,6 +125,7 @@ func NewOmnibox(ctx context.Context, parent *gtk.ApplicationWindow, cfg OmniboxC | |||
| 113 | 125 | defaultSearch: cfg.DefaultSearch, | |
| 114 | 126 | initialBehavior: cfg.InitialBehavior, | |
| 115 | 127 | ctx: ctx, | |
| 128 | + | uiScale: uiScale, | |
| 116 | 129 | } | |
| 117 | 130 | ||
| 118 | 131 | if err := o.createWidgets(); err != nil { | |
| @@ -127,6 +140,30 @@ func NewOmnibox(ctx context.Context, parent *gtk.ApplicationWindow, cfg OmniboxC | |||
| 127 | 140 | return o | |
| 128 | 141 | } | |
| 129 | 142 | ||
| 143 | + | // updateSize sets the omnibox size based on parent window dimensions. | |
| 144 | + | func (o *Omnibox) updateSize() { | |
| 145 | + | if o.parentWindow == nil || o.window == nil { | |
| 146 | + | return | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Use GetAllocatedWidth/Height for actual rendered size | |
| 150 | + | parentWidth := o.parentWindow.GetAllocatedWidth() | |
| 151 | + | parentHeight := o.parentWindow.GetAllocatedHeight() | |
| 152 | + | ||
| 153 | + | if parentWidth <= 0 || parentHeight <= 0 { | |
| 154 | + | log := logging.FromContext(o.ctx) | |
| 155 | + | log.Error(). | |
| 156 | + | Int("parent_width", parentWidth). | |
| 157 | + | Int("parent_height", parentHeight). | |
| 158 | + | Msg("omnibox: invalid parent window dimensions") | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | width := int(float64(parentWidth) * omniboxWidthPct) | |
| 163 | + | height := int(float64(parentHeight) * omniboxHeightPct) | |
| 164 | + | o.window.SetDefaultSize(width, height) | |
| 165 | + | } | |
| 166 | + | ||
| 130 | 167 | // createWidgets builds the GTK widget hierarchy. | |
| 131 | 168 | func (o *Omnibox) createWidgets() error { | |
| 132 | 169 | // Create the popup window | |
| @@ -137,12 +174,14 @@ func (o *Omnibox) createWidgets() error { | |||
| 137 | 174 | ||
| 138 | 175 | o.window.SetModal(true) | |
| 139 | 176 | o.window.SetDecorated(false) | |
| 140 | - | o.window.SetDefaultSize(omniboxWidth, 400) | |
| 141 | 177 | o.window.AddCssClass("omnibox-window") | |
| 142 | 178 | ||
| 143 | 179 | // Set transient for parent window (positioning) | |
| 144 | 180 | if o.parentWindow != nil { | |
| 145 | 181 | o.window.SetTransientFor(&o.parentWindow.Window) | |
| 182 | + | } else { | |
| 183 | + | log := logging.FromContext(o.ctx) | |
| 184 | + | log.Error().Msg("omnibox: parent window is nil, cannot set transient") | |
| 146 | 185 | } | |
| 147 | 186 | ||
| 148 | 187 | // Main vertical box | |
| @@ -186,8 +225,20 @@ func (o *Omnibox) createWidgets() error { | |||
| 186 | 225 | } | |
| 187 | 226 | o.favoritesBtn.ConnectClicked(&favoritesClickCb) | |
| 188 | 227 | ||
| 228 | + | // Zoom indicator label (shown when zoom != 100%) | |
| 229 | + | o.zoomLabel = gtk.NewLabel(nil) | |
| 230 | + | if o.zoomLabel != nil { | |
| 231 | + | o.zoomLabel.AddCssClass("omnibox-zoom-indicator") | |
| 232 | + | o.zoomLabel.SetVisible(false) | |
| 233 | + | o.zoomLabel.SetHalign(gtk.AlignEndValue) | |
| 234 | + | o.zoomLabel.SetHexpand(true) | |
| 235 | + | } | |
| 236 | + | ||
| 189 | 237 | o.headerBox.Append(&o.historyBtn.Widget) | |
| 190 | 238 | o.headerBox.Append(&o.favoritesBtn.Widget) | |
| 239 | + | if o.zoomLabel != nil { | |
| 240 | + | o.headerBox.Append(&o.zoomLabel.Widget) | |
| 241 | + | } | |
| 191 | 242 | ||
| 192 | 243 | // Search entry field | |
| 193 | 244 | o.entry = gtk.NewSearchEntry() | |
| @@ -546,7 +597,7 @@ func (o *Omnibox) createFaviconImage(url, fallbackIcon string) *gtk.Image { | |||
| 546 | 597 | return nil | |
| 547 | 598 | } | |
| 548 | 599 | favicon.SetFromIconName(&fallbackIcon) | |
| 549 | - | favicon.SetPixelSize(16) | |
| 600 | + | favicon.SetPixelSize(int(16 * o.uiScale)) | |
| 550 | 601 | favicon.AddCssClass("omnibox-favicon") | |
| 551 | 602 | ||
| 552 | 603 | // Async load favicon from cache | |
| @@ -892,6 +943,9 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 892 | 943 | // Load initial data | |
| 893 | 944 | o.performSearch() | |
| 894 | 945 | ||
| 946 | + | // Update size based on current parent window dimensions | |
| 947 | + | o.updateSize() | |
| 948 | + | ||
| 895 | 949 | // Present the window | |
| 896 | 950 | o.window.Present() | |
| 897 | 951 | ||
| @@ -962,6 +1016,25 @@ func (o *Omnibox) SetOnClose(fn func()) { | |||
| 962 | 1016 | o.onClose = fn | |
| 963 | 1017 | } | |
| 964 | 1018 | ||
| 1019 | + | // UpdateZoomIndicator updates the zoom percentage display. | |
| 1020 | + | // Shows the indicator when zoom != 100%, hides it when at 100%. | |
| 1021 | + | func (o *Omnibox) UpdateZoomIndicator(factor float64) { | |
| 1022 | + | var cb glib.SourceFunc = func(data uintptr) bool { | |
| 1023 | + | if o.zoomLabel == nil { | |
| 1024 | + | return false | |
| 1025 | + | } | |
| 1026 | + | if factor == 1.0 { | |
| 1027 | + | o.zoomLabel.SetVisible(false) | |
| 1028 | + | } else { | |
| 1029 | + | percentage := int(factor * 100) | |
| 1030 | + | o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage)) | |
| 1031 | + | o.zoomLabel.SetVisible(true) | |
| 1032 | + | } | |
| 1033 | + | return false | |
| 1034 | + | } | |
| 1035 | + | glib.IdleAdd(&cb, 0) | |
| 1036 | + | } | |
| 1037 | + | ||
| 965 | 1038 | // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread. | |
| 966 | 1039 | func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) { | |
| 967 | 1040 | var cb glib.SourceFunc = func(data uintptr) bool { | |
| @@ -2,6 +2,7 @@ package component | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "time" | 8 | "time" | ||
| @@ -17,7 +18,8 @@ import ( | |||||
| 17 | ) | 18 | ) | ||
| 18 | 19 | ||||
| 19 | const ( | 20 | const ( | ||
| 20 | - | omniboxWidth = 600 | 21 | + | omniboxWidthPct = 0.5 // 50% of parent window width |
| 22 | + | omniboxHeightPct = 0.6 // 60% of parent window height | |||
| 21 | omniboxMaxResults = 10 | 23 | omniboxMaxResults = 10 | ||
| 22 | debounceDelayMs = 250 // Increased for stability | 24 | debounceDelayMs = 250 // Increased for stability | ||
| 23 | ) | 25 | ) | ||
| @@ -54,6 +56,7 @@ type Omnibox struct { | |||||
| 54 | headerBox *gtk.Box | 56 | headerBox *gtk.Box | ||
| 55 | historyBtn *gtk.Button | 57 | historyBtn *gtk.Button | ||
| 56 | favoritesBtn *gtk.Button | 58 | favoritesBtn *gtk.Button | ||
| 59 | + | zoomLabel *gtk.Label | |||
| 57 | entry *gtk.SearchEntry | 60 | entry *gtk.SearchEntry | ||
| 58 | scrolledWin *gtk.ScrolledWindow | 61 | scrolledWin *gtk.ScrolledWindow | ||
| 59 | listBox *gtk.ListBox | 62 | listBox *gtk.ListBox | ||
| @@ -86,6 +89,9 @@ type Omnibox struct { | |||||
| 86 | debounceTimer *time.Timer | 89 | debounceTimer *time.Timer | ||
| 87 | debounceMu sync.Mutex | 90 | debounceMu sync.Mutex | ||
| 88 | lastQuery string // Prevent duplicate searches | 91 | lastQuery string // Prevent duplicate searches | ||
| 92 | + | ||||
| 93 | + | // Scaling | |||
| 94 | + | uiScale float64 | |||
| 89 | } | 95 | } | ||
| 90 | 96 | ||||
| 91 | // OmniboxConfig holds configuration for creating an Omnibox. | 97 | // OmniboxConfig holds configuration for creating an Omnibox. | ||
| @@ -96,12 +102,18 @@ type OmniboxConfig struct { | |||||
| 96 | Shortcuts map[string]config.SearchShortcut | 102 | Shortcuts map[string]config.SearchShortcut | ||
| 97 | DefaultSearch string | 103 | DefaultSearch string | ||
| 98 | InitialBehavior string | 104 | InitialBehavior string | ||
| 105 | + | UIScale float64 // UI scale for favicon sizing | |||
| 99 | } | 106 | } | ||
| 100 | 107 | ||||
| 101 | // NewOmnibox creates a new native GTK4 omnibox widget. | 108 | // NewOmnibox creates a new native GTK4 omnibox widget. | ||
| 102 | func NewOmnibox(ctx context.Context, parent *gtk.ApplicationWindow, cfg OmniboxConfig) *Omnibox { | 109 | func NewOmnibox(ctx context.Context, parent *gtk.ApplicationWindow, cfg OmniboxConfig) *Omnibox { | ||
| 103 | log := logging.FromContext(ctx) | 110 | log := logging.FromContext(ctx) | ||
| 104 | 111 | ||||
| 112 | + | uiScale := cfg.UIScale | |||
| 113 | + | if uiScale <= 0 { | |||
| 114 | + | uiScale = 1.0 | |||
| 115 | + | } | |||
| 116 | + | ||||
| 105 | o := &Omnibox{ | 117 | o := &Omnibox{ | ||
| 106 | parentWindow: parent, | 118 | parentWindow: parent, | ||
| 107 | viewMode: ViewModeHistory, | 119 | viewMode: ViewModeHistory, | ||
| @@ -113,6 +125,7 @@ func NewOmnibox(ctx context.Context, parent *gtk.ApplicationWindow, cfg OmniboxC | |||||
| 113 | defaultSearch: cfg.DefaultSearch, | 125 | defaultSearch: cfg.DefaultSearch, | ||
| 114 | initialBehavior: cfg.InitialBehavior, | 126 | initialBehavior: cfg.InitialBehavior, | ||
| 115 | ctx: ctx, | 127 | ctx: ctx, | ||
| 128 | + | uiScale: uiScale, | |||
| 116 | } | 129 | } | ||
| 117 | 130 | ||||
| 118 | if err := o.createWidgets(); err != nil { | 131 | if err := o.createWidgets(); err != nil { | ||
| @@ -127,6 +140,30 @@ func NewOmnibox(ctx context.Context, parent *gtk.ApplicationWindow, cfg OmniboxC | |||||
| 127 | return o | 140 | return o | ||
| 128 | } | 141 | } | ||
| 129 | 142 | ||||
| 143 | + | // updateSize sets the omnibox size based on parent window dimensions. | |||
| 144 | + | func (o *Omnibox) updateSize() { | |||
| 145 | + | if o.parentWindow == nil || o.window == nil { | |||
| 146 | + | return | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Use GetAllocatedWidth/Height for actual rendered size | |||
| 150 | + | parentWidth := o.parentWindow.GetAllocatedWidth() | |||
| 151 | + | parentHeight := o.parentWindow.GetAllocatedHeight() | |||
| 152 | + | ||||
| 153 | + | if parentWidth <= 0 || parentHeight <= 0 { | |||
| 154 | + | log := logging.FromContext(o.ctx) | |||
| 155 | + | log.Error(). | |||
| 156 | + | Int("parent_width", parentWidth). | |||
| 157 | + | Int("parent_height", parentHeight). | |||
| 158 | + | Msg("omnibox: invalid parent window dimensions") | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | width := int(float64(parentWidth) * omniboxWidthPct) | |||
| 163 | + | height := int(float64(parentHeight) * omniboxHeightPct) | |||
| 164 | + | o.window.SetDefaultSize(width, height) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 130 | // createWidgets builds the GTK widget hierarchy. | 167 | // createWidgets builds the GTK widget hierarchy. | ||
| 131 | func (o *Omnibox) createWidgets() error { | 168 | func (o *Omnibox) createWidgets() error { | ||
| 132 | // Create the popup window | 169 | // Create the popup window | ||
| @@ -137,12 +174,14 @@ func (o *Omnibox) createWidgets() error { | |||||
| 137 | 174 | ||||
| 138 | o.window.SetModal(true) | 175 | o.window.SetModal(true) | ||
| 139 | o.window.SetDecorated(false) | 176 | o.window.SetDecorated(false) | ||
| 140 | - | o.window.SetDefaultSize(omniboxWidth, 400) | |||
| 141 | o.window.AddCssClass("omnibox-window") | 177 | o.window.AddCssClass("omnibox-window") | ||
| 142 | 178 | ||||
| 143 | // Set transient for parent window (positioning) | 179 | // Set transient for parent window (positioning) | ||
| 144 | if o.parentWindow != nil { | 180 | if o.parentWindow != nil { | ||
| 145 | o.window.SetTransientFor(&o.parentWindow.Window) | 181 | o.window.SetTransientFor(&o.parentWindow.Window) | ||
| 182 | + | } else { | |||
| 183 | + | log := logging.FromContext(o.ctx) | |||
| 184 | + | log.Error().Msg("omnibox: parent window is nil, cannot set transient") | |||
| 146 | } | 185 | } | ||
| 147 | 186 | ||||
| 148 | // Main vertical box | 187 | // Main vertical box | ||
| @@ -186,8 +225,20 @@ func (o *Omnibox) createWidgets() error { | |||||
| 186 | } | 225 | } | ||
| 187 | o.favoritesBtn.ConnectClicked(&favoritesClickCb) | 226 | o.favoritesBtn.ConnectClicked(&favoritesClickCb) | ||
| 188 | 227 | ||||
| 228 | + | // Zoom indicator label (shown when zoom != 100%) | |||
| 229 | + | o.zoomLabel = gtk.NewLabel(nil) | |||
| 230 | + | if o.zoomLabel != nil { | |||
| 231 | + | o.zoomLabel.AddCssClass("omnibox-zoom-indicator") | |||
| 232 | + | o.zoomLabel.SetVisible(false) | |||
| 233 | + | o.zoomLabel.SetHalign(gtk.AlignEndValue) | |||
| 234 | + | o.zoomLabel.SetHexpand(true) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 189 | o.headerBox.Append(&o.historyBtn.Widget) | 237 | o.headerBox.Append(&o.historyBtn.Widget) | ||
| 190 | o.headerBox.Append(&o.favoritesBtn.Widget) | 238 | o.headerBox.Append(&o.favoritesBtn.Widget) | ||
| 239 | + | if o.zoomLabel != nil { | |||
| 240 | + | o.headerBox.Append(&o.zoomLabel.Widget) | |||
| 241 | + | } | |||
| 191 | 242 | ||||
| 192 | // Search entry field | 243 | // Search entry field | ||
| 193 | o.entry = gtk.NewSearchEntry() | 244 | o.entry = gtk.NewSearchEntry() | ||
| @@ -546,7 +597,7 @@ func (o *Omnibox) createFaviconImage(url, fallbackIcon string) *gtk.Image { | |||||
| 546 | return nil | 597 | return nil | ||
| 547 | } | 598 | } | ||
| 548 | favicon.SetFromIconName(&fallbackIcon) | 599 | favicon.SetFromIconName(&fallbackIcon) | ||
| 549 | - | favicon.SetPixelSize(16) | 600 | + | favicon.SetPixelSize(int(16 * o.uiScale)) |
| 550 | favicon.AddCssClass("omnibox-favicon") | 601 | favicon.AddCssClass("omnibox-favicon") | ||
| 551 | 602 | ||||
| 552 | // Async load favicon from cache | 603 | // Async load favicon from cache | ||
| @@ -892,6 +943,9 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 892 | // Load initial data | 943 | // Load initial data | ||
| 893 | o.performSearch() | 944 | o.performSearch() | ||
| 894 | 945 | ||||
| 946 | + | // Update size based on current parent window dimensions | |||
| 947 | + | o.updateSize() | |||
| 948 | + | ||||
| 895 | // Present the window | 949 | // Present the window | ||
| 896 | o.window.Present() | 950 | o.window.Present() | ||
| 897 | 951 | ||||
| @@ -962,6 +1016,25 @@ func (o *Omnibox) SetOnClose(fn func()) { | |||||
| 962 | o.onClose = fn | 1016 | o.onClose = fn | ||
| 963 | } | 1017 | } | ||
| 964 | 1018 | ||||
| 1019 | + | // UpdateZoomIndicator updates the zoom percentage display. | |||
| 1020 | + | // Shows the indicator when zoom != 100%, hides it when at 100%. | |||
| 1021 | + | func (o *Omnibox) UpdateZoomIndicator(factor float64) { | |||
| 1022 | + | var cb glib.SourceFunc = func(data uintptr) bool { | |||
| 1023 | + | if o.zoomLabel == nil { | |||
| 1024 | + | return false | |||
| 1025 | + | } | |||
| 1026 | + | if factor == 1.0 { | |||
| 1027 | + | o.zoomLabel.SetVisible(false) | |||
| 1028 | + | } else { | |||
| 1029 | + | percentage := int(factor * 100) | |||
| 1030 | + | o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage)) | |||
| 1031 | + | o.zoomLabel.SetVisible(true) | |||
| 1032 | + | } | |||
| 1033 | + | return false | |||
| 1034 | + | } | |||
| 1035 | + | glib.IdleAdd(&cb, 0) | |||
| 1036 | + | } | |||
| 1037 | + | ||||
| 965 | // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread. | 1038 | // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread. | ||
| 966 | func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) { | 1039 | func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) { | ||
| 967 | var cb glib.SourceFunc = func(data uintptr) bool { | 1040 | var cb glib.SourceFunc = func(data uintptr) bool { | ||