detached 2391e76

Merge pull request #56 from bnema/feature/logo-favicon-skeleton

feat: app logo for loading skeleton and dumb:// favicons
commit
2391e76d4aab70190409b0b35da5b133c7138c2d
author
bnema · · 2025-12-28 17:02 UTC
committer
GitHub · · 2025-12-28 17:02 UTC
merge of
3b55bde + 3655ce7 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
19 files · +274 -140
M CHANGELOG.md +2 -0
@@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file.
66
77 ### Added
88 - **Omnibox bang shortcuts UI**: Typing `!` in the omnibox now shows all configured bangs (search shortcuts) with filtering and Enter-to-autocomplete, plus a header badge when a bang is recognized.
9+- **Loading skeleton with app logo**: New panes show a centered 512x512 faded app logo with a spinner while loading, replacing the blank/white flash. Uses `--muted` theme color for the spinner.
10+- **App logo favicon for internal pages**: `dumb://` URLs now display the dumber logo as favicon in history, favorites, omnibox suggestions, and dmenu/rofi. WebUI and CLI tools share consistent branding.
911 - **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup.
1012 - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true).
1113 - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.
@@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file.
6 6
7 ### Added7 ### Added
8 - **Omnibox bang shortcuts UI**: Typing `!` in the omnibox now shows all configured bangs (search shortcuts) with filtering and Enter-to-autocomplete, plus a header badge when a bang is recognized.8 - **Omnibox bang shortcuts UI**: Typing `!` in the omnibox now shows all configured bangs (search shortcuts) with filtering and Enter-to-autocomplete, plus a header badge when a bang is recognized.
9+- **Loading skeleton with app logo**: New panes show a centered 512x512 faded app logo with a spinner while loading, replacing the blank/white flash. Uses `--muted` theme color for the spinner.
10+- **App logo favicon for internal pages**: `dumb://` URLs now display the dumber logo as favicon in history, favorites, omnibox suggestions, and dmenu/rofi. WebUI and CLI tools share consistent branding.
9 - **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup.11 - **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup.
10 - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true).12 - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true).
11 - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.13 - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.
A assets/logo-32.png +0 -0 binary file: no text diff

binary file: no text diff

M assets/logo.svg +1 -1
@@ -1,5 +1,5 @@
11 <?xml version="1.0" encoding="UTF-8"?>
2-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512">
33 <defs>
44 <!-- Background gradient - deeper dark -->
55 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
@@ -1,5 +1,5 @@
1 <?xml version="1.0" encoding="UTF-8"?>1 <?xml version="1.0" encoding="UTF-8"?>
2-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">2+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512">
3 <defs>3 <defs>
4 <!-- Background gradient - deeper dark -->4 <!-- Background gradient - deeper dark -->
5 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">5 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
M assets/webui_embed.go +5 -0
@@ -14,3 +14,8 @@ var WebUIAssets embed.FS
1414 //
1515 //go:embed logo.svg
1616 var LogoSVG []byte
17+
18+// LogoPNG32 contains the dumber logo as 32x32 PNG for CLI tools like dmenu/rofi.
19+//
20+//go:embed logo-32.png
21+var LogoPNG32 []byte
@@ -14,3 +14,8 @@ var WebUIAssets embed.FS
14 //14 //
15 //go:embed logo.svg15 //go:embed logo.svg
16 var LogoSVG []byte16 var LogoSVG []byte
17+
18+// LogoPNG32 contains the dumber logo as 32x32 PNG for CLI tools like dmenu/rofi.
19+//
20+//go:embed logo-32.png
21+var LogoPNG32 []byte
M internal/cli/cmd/dmenu.go +9 -1
@@ -11,9 +11,11 @@ import (
1111 tea "github.com/charmbracelet/bubbletea"
1212 "github.com/spf13/cobra"
1313
14+ "github.com/bnema/dumber/assets"
1415 "github.com/bnema/dumber/internal/cli/model"
1516 domainurl "github.com/bnema/dumber/internal/domain/url"
1617 "github.com/bnema/dumber/internal/infrastructure/config"
18+ "github.com/bnema/dumber/internal/infrastructure/favicon"
1719 "github.com/bnema/dumber/internal/logging"
1820 )
1921
@@ -87,6 +89,13 @@ func getFaviconPath(rawURL string) string {
8789 return ""
8890 }
8991
92+ const normalizedSize = 32
93+
94+ // Handle internal dumb:// URLs - use embedded app logo
95+ if favicon.IsInternalURL(rawURL) {
96+ return app.FaviconService.EnsureInternalFaviconPNG(assets.LogoPNG32, normalizedSize)
97+ }
98+
9099 domain := domainurl.ExtractDomain(rawURL)
91100 if domain == "" {
92101 return ""
@@ -94,7 +103,6 @@ func getFaviconPath(rawURL string) string {
94103
95104 ctx := app.Ctx()
96105 log := logging.FromContext(ctx)
97- const normalizedSize = 32
98106
99107 // Check if sized PNG already exists
100108 if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) {
@@ -11,9 +11,11 @@ import (
11 tea "github.com/charmbracelet/bubbletea"11 tea "github.com/charmbracelet/bubbletea"
12 "github.com/spf13/cobra"12 "github.com/spf13/cobra"
13 13
14+ "github.com/bnema/dumber/assets"
14 "github.com/bnema/dumber/internal/cli/model"15 "github.com/bnema/dumber/internal/cli/model"
15 domainurl "github.com/bnema/dumber/internal/domain/url"16 domainurl "github.com/bnema/dumber/internal/domain/url"
16 "github.com/bnema/dumber/internal/infrastructure/config"17 "github.com/bnema/dumber/internal/infrastructure/config"
18+ "github.com/bnema/dumber/internal/infrastructure/favicon"
17 "github.com/bnema/dumber/internal/logging"19 "github.com/bnema/dumber/internal/logging"
18 )20 )
19 21
@@ -87,6 +89,13 @@ func getFaviconPath(rawURL string) string {
87 return ""89 return ""
88 }90 }
89 91
92+ const normalizedSize = 32
93+
94+ // Handle internal dumb:// URLs - use embedded app logo
95+ if favicon.IsInternalURL(rawURL) {
96+ return app.FaviconService.EnsureInternalFaviconPNG(assets.LogoPNG32, normalizedSize)
97+ }
98+
90 domain := domainurl.ExtractDomain(rawURL)99 domain := domainurl.ExtractDomain(rawURL)
91 if domain == "" {100 if domain == "" {
92 return ""101 return ""
@@ -94,7 +103,6 @@ func getFaviconPath(rawURL string) string {
94 103
95 ctx := app.Ctx()104 ctx := app.Ctx()
96 log := logging.FromContext(ctx)105 log := logging.FromContext(ctx)
97- const normalizedSize = 32
98 106
99 // Check if sized PNG already exists107 // Check if sized PNG already exists
100 if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) {108 if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) {
M internal/infrastructure/favicon/cache.go +27 -0
@@ -172,6 +172,33 @@ func (c *Cache) WritePNG(domain string, pngData []byte) {
172172 }
173173 }
174174
175+// WritePNGSized writes raw PNG data to disk for a domain at a specific size.
176+// Used for internal favicons that are pre-sized (e.g., embedded app logo).
177+func (c *Cache) WritePNGSized(domain string, pngData []byte, size int) {
178+ if c.diskDir == "" || len(pngData) == 0 || domain == "" {
179+ return
180+ }
181+
182+ // Ensure directory exists
183+ if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
184+ return
185+ }
186+
187+ filename := domainurl.SanitizeDomainForPNGSized(domain, size)
188+ finalPath := filepath.Join(c.diskDir, filename)
189+ tempPath := finalPath + ".tmp"
190+
191+ // Write to temp file
192+ if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil {
193+ return
194+ }
195+
196+ // Atomic rename
197+ if err := os.Rename(tempPath, finalPath); err != nil {
198+ _ = os.Remove(tempPath)
199+ }
200+}
201+
175202 // HasOnDisk checks if a favicon exists on disk for the given domain.
176203 func (c *Cache) HasOnDisk(domain string) bool {
177204 path := c.DiskPath(domain)
@@ -172,6 +172,33 @@ func (c *Cache) WritePNG(domain string, pngData []byte) {
172 }172 }
173 }173 }
174 174
175+// WritePNGSized writes raw PNG data to disk for a domain at a specific size.
176+// Used for internal favicons that are pre-sized (e.g., embedded app logo).
177+func (c *Cache) WritePNGSized(domain string, pngData []byte, size int) {
178+ if c.diskDir == "" || len(pngData) == 0 || domain == "" {
179+ return
180+ }
181+
182+ // Ensure directory exists
183+ if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
184+ return
185+ }
186+
187+ filename := domainurl.SanitizeDomainForPNGSized(domain, size)
188+ finalPath := filepath.Join(c.diskDir, filename)
189+ tempPath := finalPath + ".tmp"
190+
191+ // Write to temp file
192+ if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil {
193+ return
194+ }
195+
196+ // Atomic rename
197+ if err := os.Rename(tempPath, finalPath); err != nil {
198+ _ = os.Remove(tempPath)
199+ }
200+}
201+
175 // HasOnDisk checks if a favicon exists on disk for the given domain.202 // HasOnDisk checks if a favicon exists on disk for the given domain.
176 func (c *Cache) HasOnDisk(domain string) bool {203 func (c *Cache) HasOnDisk(domain string) bool {
177 path := c.DiskPath(domain)204 path := c.DiskPath(domain)
M internal/infrastructure/favicon/service.go +36 -0
@@ -3,10 +3,18 @@ package favicon
33 import (
44 "context"
55 "fmt"
6+ "strings"
67
8+ "github.com/bnema/dumber/assets"
79 "github.com/bnema/dumber/internal/logging"
810 )
911
12+// InternalScheme is the URL scheme used for internal browser pages.
13+const InternalScheme = "dumb://"
14+
15+// InternalDomain is the pseudo-domain used for caching internal page favicons.
16+const InternalDomain = "dumb"
17+
1018 // Service implements the domain FaviconService interface.
1119 // It coordinates between the cache and fetcher components.
1220 type Service struct {
@@ -30,6 +38,11 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
3038 return nil, nil
3139 }
3240
41+ // Internal domain returns the app logo
42+ if domain == InternalDomain {
43+ return assets.LogoSVG, nil
44+ }
45+
3346 // Check cache first (memory + disk)
3447 if data, ok := s.cache.Get(domain); ok {
3548 return data, nil
@@ -49,6 +62,17 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
4962 return data, nil
5063 }
5164
65+// IsInternalURL checks if a URL uses the internal dumb:// scheme.
66+func IsInternalURL(url string) bool {
67+ return strings.HasPrefix(url, InternalScheme)
68+}
69+
70+// GetLogoBytes returns the raw SVG bytes of the app logo.
71+// Used for internal pages that need the logo as a favicon.
72+func GetLogoBytes() []byte {
73+ return assets.LogoSVG
74+}
75+
5276 // GetCached returns favicon bytes only if already cached (no external fetch).
5377 func (s *Service) GetCached(domain string) ([]byte, bool) {
5478 return s.cache.Get(domain)
@@ -149,3 +173,15 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
149173
150174 return ResizePNG(srcPath, dstPath, size)
151175 }
176+
177+// EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk.
178+// Returns the path to the sized PNG file. Used by dmenu to get a filesystem
179+// path to the internal favicon for rofi/fuzzel display.
180+func (s *Service) EnsureInternalFaviconPNG(pngData []byte, size int) string {
181+ if s.HasPNGSizedOnDisk(InternalDomain, size) {
182+ return s.DiskPathPNGSized(InternalDomain, size)
183+ }
184+
185+ s.cache.WritePNGSized(InternalDomain, pngData, size)
186+ return s.DiskPathPNGSized(InternalDomain, size)
187+}
@@ -3,10 +3,18 @@ package favicon
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "strings"
6 7
8+ "github.com/bnema/dumber/assets"
7 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
8 )10 )
9 11
12+// InternalScheme is the URL scheme used for internal browser pages.
13+const InternalScheme = "dumb://"
14+
15+// InternalDomain is the pseudo-domain used for caching internal page favicons.
16+const InternalDomain = "dumb"
17+
10 // Service implements the domain FaviconService interface.18 // Service implements the domain FaviconService interface.
11 // It coordinates between the cache and fetcher components.19 // It coordinates between the cache and fetcher components.
12 type Service struct {20 type Service struct {
@@ -30,6 +38,11 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
30 return nil, nil38 return nil, nil
31 }39 }
32 40
41+ // Internal domain returns the app logo
42+ if domain == InternalDomain {
43+ return assets.LogoSVG, nil
44+ }
45+
33 // Check cache first (memory + disk)46 // Check cache first (memory + disk)
34 if data, ok := s.cache.Get(domain); ok {47 if data, ok := s.cache.Get(domain); ok {
35 return data, nil48 return data, nil
@@ -49,6 +62,17 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
49 return data, nil62 return data, nil
50 }63 }
51 64
65+// IsInternalURL checks if a URL uses the internal dumb:// scheme.
66+func IsInternalURL(url string) bool {
67+ return strings.HasPrefix(url, InternalScheme)
68+}
69+
70+// GetLogoBytes returns the raw SVG bytes of the app logo.
71+// Used for internal pages that need the logo as a favicon.
72+func GetLogoBytes() []byte {
73+ return assets.LogoSVG
74+}
75+
52 // GetCached returns favicon bytes only if already cached (no external fetch).76 // GetCached returns favicon bytes only if already cached (no external fetch).
53 func (s *Service) GetCached(domain string) ([]byte, bool) {77 func (s *Service) GetCached(domain string) ([]byte, bool) {
54 return s.cache.Get(domain)78 return s.cache.Get(domain)
@@ -149,3 +173,15 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
149 173
150 return ResizePNG(srcPath, dstPath, size)174 return ResizePNG(srcPath, dstPath, size)
151 }175 }
176+
177+// EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk.
178+// Returns the path to the sized PNG file. Used by dmenu to get a filesystem
179+// path to the internal favicon for rofi/fuzzel display.
180+func (s *Service) EnsureInternalFaviconPNG(pngData []byte, size int) string {
181+ if s.HasPNGSizedOnDisk(InternalDomain, size) {
182+ return s.DiskPathPNGSized(InternalDomain, size)
183+ }
184+
185+ s.cache.WritePNGSized(InternalDomain, pngData, size)
186+ return s.DiskPathPNGSized(InternalDomain, size)
187+}
M internal/ui/adapter/favicon.go +31 -0
@@ -46,19 +46,33 @@ func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
4646 }
4747
4848 // GetTextureByURL returns a cached texture by extracting domain from URL.
49+// For internal dumb:// URLs, returns the app logo texture.
4950 func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
51+ // Handle internal dumb:// scheme URLs
52+ if favicon.IsInternalURL(pageURL) {
53+ return a.GetTexture(favicon.InternalDomain)
54+ }
5055 domain := domainurl.ExtractDomain(pageURL)
5156 return a.GetTexture(domain)
5257 }
5358
5459 // GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
5560 // The callback is invoked on the GTK main thread with the texture (or nil).
61+// For internal dumb:// URLs, returns the app logo texture.
5662 func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
5763 if callback == nil {
5864 return
5965 }
6066
6167 log := logging.FromContext(ctx)
68+
69+ // Handle internal dumb:// scheme URLs - use app logo
70+ if favicon.IsInternalURL(pageURL) {
71+ texture := a.getOrCreateLogoTexture()
72+ callback(texture)
73+ return
74+ }
75+
6276 domain := domainurl.ExtractDomain(pageURL)
6377
6478 // Check texture cache first
@@ -311,6 +325,23 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
311325 }
312326 }
313327
328+// getOrCreateLogoTexture returns the app logo texture, creating it if needed.
329+// The texture is cached under the InternalDomain key.
330+func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
331+ // Check cache first
332+ if texture := a.GetTexture(favicon.InternalDomain); texture != nil {
333+ return texture
334+ }
335+
336+ // Create texture from logo bytes
337+ logoBytes := favicon.GetLogoBytes()
338+ texture := bytesToTexture(logoBytes)
339+ if texture != nil {
340+ a.setTexture(favicon.InternalDomain, texture)
341+ }
342+ return texture
343+}
344+
314345 // bytesToTexture converts raw favicon bytes to a GDK texture.
315346 func bytesToTexture(data []byte) *gdk.Texture {
316347 if len(data) == 0 {
@@ -46,19 +46,33 @@ func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
46 }46 }
47 47
48 // GetTextureByURL returns a cached texture by extracting domain from URL.48 // GetTextureByURL returns a cached texture by extracting domain from URL.
49+// For internal dumb:// URLs, returns the app logo texture.
49 func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {50 func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
51+ // Handle internal dumb:// scheme URLs
52+ if favicon.IsInternalURL(pageURL) {
53+ return a.GetTexture(favicon.InternalDomain)
54+ }
50 domain := domainurl.ExtractDomain(pageURL)55 domain := domainurl.ExtractDomain(pageURL)
51 return a.GetTexture(domain)56 return a.GetTexture(domain)
52 }57 }
53 58
54 // GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.59 // GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
55 // The callback is invoked on the GTK main thread with the texture (or nil).60 // The callback is invoked on the GTK main thread with the texture (or nil).
61+// For internal dumb:// URLs, returns the app logo texture.
56 func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {62 func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
57 if callback == nil {63 if callback == nil {
58 return64 return
59 }65 }
60 66
61 log := logging.FromContext(ctx)67 log := logging.FromContext(ctx)
68+
69+ // Handle internal dumb:// scheme URLs - use app logo
70+ if favicon.IsInternalURL(pageURL) {
71+ texture := a.getOrCreateLogoTexture()
72+ callback(texture)
73+ return
74+ }
75+
62 domain := domainurl.ExtractDomain(pageURL)76 domain := domainurl.ExtractDomain(pageURL)
63 77
64 // Check texture cache first78 // Check texture cache first
@@ -311,6 +325,23 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
311 }325 }
312 }326 }
313 327
328+// getOrCreateLogoTexture returns the app logo texture, creating it if needed.
329+// The texture is cached under the InternalDomain key.
330+func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
331+ // Check cache first
332+ if texture := a.GetTexture(favicon.InternalDomain); texture != nil {
333+ return texture
334+ }
335+
336+ // Create texture from logo bytes
337+ logoBytes := favicon.GetLogoBytes()
338+ texture := bytesToTexture(logoBytes)
339+ if texture != nil {
340+ a.setTexture(favicon.InternalDomain, texture)
341+ }
342+ return texture
343+}
344+
314 // bytesToTexture converts raw favicon bytes to a GDK texture.345 // bytesToTexture converts raw favicon bytes to a GDK texture.
315 func bytesToTexture(data []byte) *gdk.Texture {346 func bytesToTexture(data []byte) *gdk.Texture {
316 if len(data) == 0 {347 if len(data) == 0 {
M internal/ui/component/loading_skeleton.go +23 -24
@@ -1,22 +1,24 @@
11 package component
22
33 import (
4- "strings"
5-
64 "github.com/bnema/dumber/internal/ui/layout"
75 "github.com/jwijenbergh/puregotk/v4/gtk"
86 )
97
108 // LoadingSkeleton displays a themed loading placeholder.
119 // It is intended to be embedded as an overlay while primary content is loading.
10+// Shows a faded app logo and spinner.
1211 type LoadingSkeleton struct {
1312 container layout.BoxWidget
1413 content layout.BoxWidget
1514 spinner layout.SpinnerWidget
16- label layout.LabelWidget
15+ logo layout.ImageWidget
1716 }
1817
19-const loadingSkeletonSpacing = 6
18+const (
19+ loadingSkeletonSpacing = 6
20+ loadingSkeletonLogoSize = 512
21+)
2022
2123 func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
2224 container := factory.NewBox(layout.OrientationVertical, 0)
@@ -43,23 +45,31 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
4345 spinner.SetSizeRequest(32, 32)
4446 spinner.AddCssClass("loading-skeleton-spinner")
4547
46- text := "Loading..."
47- label := factory.NewLabel(text)
48- label.SetHalign(gtk.AlignCenterValue)
49- label.SetValign(gtk.AlignCenterValue)
50- label.SetCanFocus(false)
51- label.SetCanTarget(false)
52- label.AddCssClass("loading-skeleton-text")
48+ // Faded app logo
49+ logo := factory.NewImage()
50+ logo.SetHalign(gtk.AlignCenterValue)
51+ logo.SetValign(gtk.AlignCenterValue)
52+ logo.SetCanFocus(false)
53+ logo.SetCanTarget(false)
54+ logo.SetSizeRequest(loadingSkeletonLogoSize, loadingSkeletonLogoSize)
55+ logo.SetPixelSize(loadingSkeletonLogoSize)
56+ logo.AddCssClass("loading-skeleton-logo")
5357
58+ // Set logo texture if available
59+ if logoTexture := GetLogoTexture(); logoTexture != nil {
60+ logo.SetFromPaintable(logoTexture)
61+ }
62+
63+ // Layout: logo, spinner (vertically centered)
64+ content.Append(logo)
5465 content.Append(spinner)
55- content.Append(label)
5666 container.Append(content)
5767
5868 ls := &LoadingSkeleton{
5969 container: container,
6070 content: content,
6171 spinner: spinner,
62- label: label,
72+ logo: logo,
6373 }
6474 ls.SetVisible(true)
6575 return ls
@@ -72,17 +82,6 @@ func (ls *LoadingSkeleton) Widget() layout.Widget {
7282 return ls.container
7383 }
7484
75-func (ls *LoadingSkeleton) SetText(text string) {
76- if ls == nil || ls.label == nil {
77- return
78- }
79- trimmed := strings.TrimSpace(text)
80- if trimmed == "" {
81- trimmed = "Loading..."
82- }
83- ls.label.SetText(trimmed)
84-}
85-
8685 func (ls *LoadingSkeleton) SetVisible(visible bool) {
8786 if ls == nil || ls.container == nil {
8887 return
@@ -1,22 +1,24 @@
1 package component1 package component
2 2
3 import (3 import (
4- "strings"
5-
6 "github.com/bnema/dumber/internal/ui/layout"4 "github.com/bnema/dumber/internal/ui/layout"
7 "github.com/jwijenbergh/puregotk/v4/gtk"5 "github.com/jwijenbergh/puregotk/v4/gtk"
8 )6 )
9 7
10 // LoadingSkeleton displays a themed loading placeholder.8 // LoadingSkeleton displays a themed loading placeholder.
11 // It is intended to be embedded as an overlay while primary content is loading.9 // It is intended to be embedded as an overlay while primary content is loading.
10+// Shows a faded app logo and spinner.
12 type LoadingSkeleton struct {11 type LoadingSkeleton struct {
13 container layout.BoxWidget12 container layout.BoxWidget
14 content layout.BoxWidget13 content layout.BoxWidget
15 spinner layout.SpinnerWidget14 spinner layout.SpinnerWidget
16- label layout.LabelWidget15+ logo layout.ImageWidget
17 }16 }
18 17
19-const loadingSkeletonSpacing = 618+const (
19+ loadingSkeletonSpacing = 6
20+ loadingSkeletonLogoSize = 512
21+)
20 22
21 func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {23 func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
22 container := factory.NewBox(layout.OrientationVertical, 0)24 container := factory.NewBox(layout.OrientationVertical, 0)
@@ -43,23 +45,31 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
43 spinner.SetSizeRequest(32, 32)45 spinner.SetSizeRequest(32, 32)
44 spinner.AddCssClass("loading-skeleton-spinner")46 spinner.AddCssClass("loading-skeleton-spinner")
45 47
46- text := "Loading..."48+ // Faded app logo
47- label := factory.NewLabel(text)49+ logo := factory.NewImage()
48- label.SetHalign(gtk.AlignCenterValue)50+ logo.SetHalign(gtk.AlignCenterValue)
49- label.SetValign(gtk.AlignCenterValue)51+ logo.SetValign(gtk.AlignCenterValue)
50- label.SetCanFocus(false)52+ logo.SetCanFocus(false)
51- label.SetCanTarget(false)53+ logo.SetCanTarget(false)
52- label.AddCssClass("loading-skeleton-text")54+ logo.SetSizeRequest(loadingSkeletonLogoSize, loadingSkeletonLogoSize)
55+ logo.SetPixelSize(loadingSkeletonLogoSize)
56+ logo.AddCssClass("loading-skeleton-logo")
53 57
58+ // Set logo texture if available
59+ if logoTexture := GetLogoTexture(); logoTexture != nil {
60+ logo.SetFromPaintable(logoTexture)
61+ }
62+
63+ // Layout: logo, spinner (vertically centered)
64+ content.Append(logo)
54 content.Append(spinner)65 content.Append(spinner)
55- content.Append(label)
56 container.Append(content)66 container.Append(content)
57 67
58 ls := &LoadingSkeleton{68 ls := &LoadingSkeleton{
59 container: container,69 container: container,
60 content: content,70 content: content,
61 spinner: spinner,71 spinner: spinner,
62- label: label,72+ logo: logo,
63 }73 }
64 ls.SetVisible(true)74 ls.SetVisible(true)
65 return ls75 return ls
@@ -72,17 +82,6 @@ func (ls *LoadingSkeleton) Widget() layout.Widget {
72 return ls.container82 return ls.container
73 }83 }
74 84
75-func (ls *LoadingSkeleton) SetText(text string) {
76- if ls == nil || ls.label == nil {
77- return
78- }
79- trimmed := strings.TrimSpace(text)
80- if trimmed == "" {
81- trimmed = "Loading..."
82- }
83- ls.label.SetText(trimmed)
84-}
85-
86 func (ls *LoadingSkeleton) SetVisible(visible bool) {85 func (ls *LoadingSkeleton) SetVisible(visible bool) {
87 if ls == nil || ls.container == nil {86 if ls == nil || ls.container == nil {
88 return87 return
M internal/ui/component/loading_skeleton_test.go +24 -73
@@ -16,7 +16,7 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) {
1616 mockContainer := mocks.NewMockBoxWidget(t)
1717 mockContent := mocks.NewMockBoxWidget(t)
1818 mockSpinner := mocks.NewMockSpinnerWidget(t)
19- mockLabel := mocks.NewMockLabelWidget(t)
19+ mockLogo := mocks.NewMockImageWidget(t)
2020
2121 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
2222 mockContainer.EXPECT().SetHexpand(true).Once()
@@ -42,15 +42,18 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) {
4242 mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
4343 mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
4444
45- mockFactory.EXPECT().NewLabel("Loading...").Return(mockLabel).Once()
46- mockLabel.EXPECT().SetHalign(mock.Anything).Once()
47- mockLabel.EXPECT().SetValign(mock.Anything).Once()
48- mockLabel.EXPECT().SetCanFocus(false).Once()
49- mockLabel.EXPECT().SetCanTarget(false).Once()
50- mockLabel.EXPECT().AddCssClass("loading-skeleton-text").Once()
51-
45+ mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
46+ mockLogo.EXPECT().SetHalign(mock.Anything).Once()
47+ mockLogo.EXPECT().SetValign(mock.Anything).Once()
48+ mockLogo.EXPECT().SetCanFocus(false).Once()
49+ mockLogo.EXPECT().SetCanTarget(false).Once()
50+ mockLogo.EXPECT().SetSizeRequest(512, 512).Once()
51+ mockLogo.EXPECT().SetPixelSize(512).Once()
52+ mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
53+ mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
54+
55+ mockContent.EXPECT().Append(mockLogo).Once()
5256 mockContent.EXPECT().Append(mockSpinner).Once()
53- mockContent.EXPECT().Append(mockLabel).Once()
5457 mockContainer.EXPECT().Append(mockContent).Once()
5558
5659 // NewLoadingSkeleton ends by calling SetVisible(true).
@@ -61,67 +64,12 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) {
6164 require.NotNil(t, ls)
6265 }
6366
64-func TestLoadingSkeleton_SetText_TrimsAndDefaults(t *testing.T) {
65- mockFactory := mocks.NewMockWidgetFactory(t)
66- mockContainer := mocks.NewMockBoxWidget(t)
67- mockContent := mocks.NewMockBoxWidget(t)
68- mockSpinner := mocks.NewMockSpinnerWidget(t)
69- mockLabel := mocks.NewMockLabelWidget(t)
70-
71- mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
72- mockContainer.EXPECT().SetHexpand(true).Once()
73- mockContainer.EXPECT().SetVexpand(true).Once()
74- mockContainer.EXPECT().SetHalign(mock.Anything).Once()
75- mockContainer.EXPECT().SetValign(mock.Anything).Once()
76- mockContainer.EXPECT().SetCanFocus(false).Once()
77- mockContainer.EXPECT().SetCanTarget(false).Once()
78- mockContainer.EXPECT().AddCssClass("loading-skeleton").Once()
79-
80- mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockContent).Once()
81- mockContent.EXPECT().SetHalign(mock.Anything).Once()
82- mockContent.EXPECT().SetValign(mock.Anything).Once()
83- mockContent.EXPECT().SetCanFocus(false).Once()
84- mockContent.EXPECT().SetCanTarget(false).Once()
85- mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
86-
87- mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once()
88- mockSpinner.EXPECT().SetHalign(mock.Anything).Once()
89- mockSpinner.EXPECT().SetValign(mock.Anything).Once()
90- mockSpinner.EXPECT().SetCanFocus(false).Once()
91- mockSpinner.EXPECT().SetCanTarget(false).Once()
92- mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
93- mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
94-
95- mockFactory.EXPECT().NewLabel("Loading...").Return(mockLabel).Once()
96- mockLabel.EXPECT().SetHalign(mock.Anything).Once()
97- mockLabel.EXPECT().SetValign(mock.Anything).Once()
98- mockLabel.EXPECT().SetCanFocus(false).Once()
99- mockLabel.EXPECT().SetCanTarget(false).Once()
100- mockLabel.EXPECT().AddCssClass("loading-skeleton-text").Once()
101-
102- mockContent.EXPECT().Append(mockSpinner).Once()
103- mockContent.EXPECT().Append(mockLabel).Once()
104- mockContainer.EXPECT().Append(mockContent).Once()
105-
106- mockContainer.EXPECT().SetVisible(true).Once()
107- mockSpinner.EXPECT().Start().Once()
108-
109- ls := component.NewLoadingSkeleton(mockFactory)
110- require.NotNil(t, ls)
111-
112- mockLabel.EXPECT().SetText("Hello").Once()
113- ls.SetText(" Hello\n")
114-
115- mockLabel.EXPECT().SetText("Loading...").Once()
116- ls.SetText(" \t ")
117-}
118-
11967 func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) {
12068 mockFactory := mocks.NewMockWidgetFactory(t)
12169 mockContainer := mocks.NewMockBoxWidget(t)
12270 mockContent := mocks.NewMockBoxWidget(t)
12371 mockSpinner := mocks.NewMockSpinnerWidget(t)
124- mockLabel := mocks.NewMockLabelWidget(t)
72+ mockLogo := mocks.NewMockImageWidget(t)
12573
12674 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
12775 mockContainer.EXPECT().SetHexpand(true).Once()
@@ -147,15 +95,18 @@ func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) {
14795 mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
14896 mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
14997
150- mockFactory.EXPECT().NewLabel("Loading...").Return(mockLabel).Once()
151- mockLabel.EXPECT().SetHalign(mock.Anything).Once()
152- mockLabel.EXPECT().SetValign(mock.Anything).Once()
153- mockLabel.EXPECT().SetCanFocus(false).Once()
154- mockLabel.EXPECT().SetCanTarget(false).Once()
155- mockLabel.EXPECT().AddCssClass("loading-skeleton-text").Once()
156-
98+ mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
99+ mockLogo.EXPECT().SetHalign(mock.Anything).Once()
100+ mockLogo.EXPECT().SetValign(mock.Anything).Once()
101+ mockLogo.EXPECT().SetCanFocus(false).Once()
102+ mockLogo.EXPECT().SetCanTarget(false).Once()
103+ mockLogo.EXPECT().SetSizeRequest(512, 512).Once()
104+ mockLogo.EXPECT().SetPixelSize(512).Once()
105+ mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
106+ mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
107+
108+ mockContent.EXPECT().Append(mockLogo).Once()
157109 mockContent.EXPECT().Append(mockSpinner).Once()
158- mockContent.EXPECT().Append(mockLabel).Once()
159110 mockContainer.EXPECT().Append(mockContent).Once()
160111
161112 mockContainer.EXPECT().SetVisible(true).Once()
@@ -16,7 +16,7 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) {
16 mockContainer := mocks.NewMockBoxWidget(t)16 mockContainer := mocks.NewMockBoxWidget(t)
17 mockContent := mocks.NewMockBoxWidget(t)17 mockContent := mocks.NewMockBoxWidget(t)
18 mockSpinner := mocks.NewMockSpinnerWidget(t)18 mockSpinner := mocks.NewMockSpinnerWidget(t)
19- mockLabel := mocks.NewMockLabelWidget(t)19+ mockLogo := mocks.NewMockImageWidget(t)
20 20
21 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()21 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
22 mockContainer.EXPECT().SetHexpand(true).Once()22 mockContainer.EXPECT().SetHexpand(true).Once()
@@ -42,15 +42,18 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) {
42 mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()42 mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
43 mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()43 mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
44 44
45- mockFactory.EXPECT().NewLabel("Loading...").Return(mockLabel).Once()45+ mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
46- mockLabel.EXPECT().SetHalign(mock.Anything).Once()46+ mockLogo.EXPECT().SetHalign(mock.Anything).Once()
47- mockLabel.EXPECT().SetValign(mock.Anything).Once()47+ mockLogo.EXPECT().SetValign(mock.Anything).Once()
48- mockLabel.EXPECT().SetCanFocus(false).Once()48+ mockLogo.EXPECT().SetCanFocus(false).Once()
49- mockLabel.EXPECT().SetCanTarget(false).Once()49+ mockLogo.EXPECT().SetCanTarget(false).Once()
50- mockLabel.EXPECT().AddCssClass("loading-skeleton-text").Once()50+ mockLogo.EXPECT().SetSizeRequest(512, 512).Once()
51-51+ mockLogo.EXPECT().SetPixelSize(512).Once()
52+ mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
53+ mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
54+
55+ mockContent.EXPECT().Append(mockLogo).Once()
52 mockContent.EXPECT().Append(mockSpinner).Once()56 mockContent.EXPECT().Append(mockSpinner).Once()
53- mockContent.EXPECT().Append(mockLabel).Once()
54 mockContainer.EXPECT().Append(mockContent).Once()57 mockContainer.EXPECT().Append(mockContent).Once()
55 58
56 // NewLoadingSkeleton ends by calling SetVisible(true).59 // NewLoadingSkeleton ends by calling SetVisible(true).
@@ -61,67 +64,12 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) {
61 require.NotNil(t, ls)64 require.NotNil(t, ls)
62 }65 }
63 66
64-func TestLoadingSkeleton_SetText_TrimsAndDefaults(t *testing.T) {
65- mockFactory := mocks.NewMockWidgetFactory(t)
66- mockContainer := mocks.NewMockBoxWidget(t)
67- mockContent := mocks.NewMockBoxWidget(t)
68- mockSpinner := mocks.NewMockSpinnerWidget(t)
69- mockLabel := mocks.NewMockLabelWidget(t)
70-
71- mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
72- mockContainer.EXPECT().SetHexpand(true).Once()
73- mockContainer.EXPECT().SetVexpand(true).Once()
74- mockContainer.EXPECT().SetHalign(mock.Anything).Once()
75- mockContainer.EXPECT().SetValign(mock.Anything).Once()
76- mockContainer.EXPECT().SetCanFocus(false).Once()
77- mockContainer.EXPECT().SetCanTarget(false).Once()
78- mockContainer.EXPECT().AddCssClass("loading-skeleton").Once()
79-
80- mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockContent).Once()
81- mockContent.EXPECT().SetHalign(mock.Anything).Once()
82- mockContent.EXPECT().SetValign(mock.Anything).Once()
83- mockContent.EXPECT().SetCanFocus(false).Once()
84- mockContent.EXPECT().SetCanTarget(false).Once()
85- mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
86-
87- mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once()
88- mockSpinner.EXPECT().SetHalign(mock.Anything).Once()
89- mockSpinner.EXPECT().SetValign(mock.Anything).Once()
90- mockSpinner.EXPECT().SetCanFocus(false).Once()
91- mockSpinner.EXPECT().SetCanTarget(false).Once()
92- mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
93- mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
94-
95- mockFactory.EXPECT().NewLabel("Loading...").Return(mockLabel).Once()
96- mockLabel.EXPECT().SetHalign(mock.Anything).Once()
97- mockLabel.EXPECT().SetValign(mock.Anything).Once()
98- mockLabel.EXPECT().SetCanFocus(false).Once()
99- mockLabel.EXPECT().SetCanTarget(false).Once()
100- mockLabel.EXPECT().AddCssClass("loading-skeleton-text").Once()
101-
102- mockContent.EXPECT().Append(mockSpinner).Once()
103- mockContent.EXPECT().Append(mockLabel).Once()
104- mockContainer.EXPECT().Append(mockContent).Once()
105-
106- mockContainer.EXPECT().SetVisible(true).Once()
107- mockSpinner.EXPECT().Start().Once()
108-
109- ls := component.NewLoadingSkeleton(mockFactory)
110- require.NotNil(t, ls)
111-
112- mockLabel.EXPECT().SetText("Hello").Once()
113- ls.SetText(" Hello\n")
114-
115- mockLabel.EXPECT().SetText("Loading...").Once()
116- ls.SetText(" \t ")
117-}
118-
119 func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) {67 func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) {
120 mockFactory := mocks.NewMockWidgetFactory(t)68 mockFactory := mocks.NewMockWidgetFactory(t)
121 mockContainer := mocks.NewMockBoxWidget(t)69 mockContainer := mocks.NewMockBoxWidget(t)
122 mockContent := mocks.NewMockBoxWidget(t)70 mockContent := mocks.NewMockBoxWidget(t)
123 mockSpinner := mocks.NewMockSpinnerWidget(t)71 mockSpinner := mocks.NewMockSpinnerWidget(t)
124- mockLabel := mocks.NewMockLabelWidget(t)72+ mockLogo := mocks.NewMockImageWidget(t)
125 73
126 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()74 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
127 mockContainer.EXPECT().SetHexpand(true).Once()75 mockContainer.EXPECT().SetHexpand(true).Once()
@@ -147,15 +95,18 @@ func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) {
147 mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()95 mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
148 mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()96 mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
149 97
150- mockFactory.EXPECT().NewLabel("Loading...").Return(mockLabel).Once()98+ mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
151- mockLabel.EXPECT().SetHalign(mock.Anything).Once()99+ mockLogo.EXPECT().SetHalign(mock.Anything).Once()
152- mockLabel.EXPECT().SetValign(mock.Anything).Once()100+ mockLogo.EXPECT().SetValign(mock.Anything).Once()
153- mockLabel.EXPECT().SetCanFocus(false).Once()101+ mockLogo.EXPECT().SetCanFocus(false).Once()
154- mockLabel.EXPECT().SetCanTarget(false).Once()102+ mockLogo.EXPECT().SetCanTarget(false).Once()
155- mockLabel.EXPECT().AddCssClass("loading-skeleton-text").Once()103+ mockLogo.EXPECT().SetSizeRequest(512, 512).Once()
156-104+ mockLogo.EXPECT().SetPixelSize(512).Once()
105+ mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
106+ mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
107+
108+ mockContent.EXPECT().Append(mockLogo).Once()
157 mockContent.EXPECT().Append(mockSpinner).Once()109 mockContent.EXPECT().Append(mockSpinner).Once()
158- mockContent.EXPECT().Append(mockLabel).Once()
159 mockContainer.EXPECT().Append(mockContent).Once()110 mockContainer.EXPECT().Append(mockContent).Once()
160 111
161 mockContainer.EXPECT().SetVisible(true).Once()112 mockContainer.EXPECT().SetVisible(true).Once()
A internal/ui/component/logo.go +37 -0
@@ -0,0 +1,37 @@
1+// Package component provides reusable GTK UI components.
2+package component
3+
4+import (
5+ "sync"
6+
7+ "github.com/bnema/dumber/assets"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/jwijenbergh/puregotk/v4/glib"
10+)
11+
12+var (
13+ logoTexture *gdk.Texture
14+ logoTextureOnce sync.Once
15+)
16+
17+// GetLogoTexture returns a cached GDK texture of the dumber logo.
18+// Safe to call from any goroutine; lazily initializes on first call.
19+// Returns nil if the logo cannot be loaded.
20+func GetLogoTexture() *gdk.Texture {
21+ logoTextureOnce.Do(func() {
22+ data := assets.LogoSVG
23+ if len(data) == 0 {
24+ return
25+ }
26+ bytes := glib.NewBytes(data, uint(len(data)))
27+ if bytes == nil {
28+ return
29+ }
30+ texture, err := gdk.NewTextureFromBytes(bytes)
31+ if err != nil {
32+ return
33+ }
34+ logoTexture = texture
35+ })
36+ return logoTexture
37+}
@@ -0,0 +1,37 @@
1+// Package component provides reusable GTK UI components.
2+package component
3+
4+import (
5+ "sync"
6+
7+ "github.com/bnema/dumber/assets"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/jwijenbergh/puregotk/v4/glib"
10+)
11+
12+var (
13+ logoTexture *gdk.Texture
14+ logoTextureOnce sync.Once
15+)
16+
17+// GetLogoTexture returns a cached GDK texture of the dumber logo.
18+// Safe to call from any goroutine; lazily initializes on first call.
19+// Returns nil if the logo cannot be loaded.
20+func GetLogoTexture() *gdk.Texture {
21+ logoTextureOnce.Do(func() {
22+ data := assets.LogoSVG
23+ if len(data) == 0 {
24+ return
25+ }
26+ bytes := glib.NewBytes(data, uint(len(data)))
27+ if bytes == nil {
28+ return
29+ }
30+ texture, err := gdk.NewTextureFromBytes(bytes)
31+ if err != nil {
32+ return
33+ }
34+ logoTexture = texture
35+ })
36+ return logoTexture
37+}
M internal/ui/component/pane_view_internal_test.go +11 -8
@@ -14,7 +14,7 @@ func TestPaneView_HideLoadingSkeleton_HidesWhenPresent(t *testing.T) {
1414 mockContainer := mocks.NewMockBoxWidget(t)
1515 mockContent := mocks.NewMockBoxWidget(t)
1616 mockSpinner := mocks.NewMockSpinnerWidget(t)
17- mockLabel := mocks.NewMockLabelWidget(t)
17+ mockLogo := mocks.NewMockImageWidget(t)
1818
1919 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
2020 mockContainer.EXPECT().SetHexpand(true).Once()
@@ -40,15 +40,18 @@ func TestPaneView_HideLoadingSkeleton_HidesWhenPresent(t *testing.T) {
4040 mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
4141 mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
4242
43- mockFactory.EXPECT().NewLabel("Loading...").Return(mockLabel).Once()
44- mockLabel.EXPECT().SetHalign(mock.Anything).Once()
45- mockLabel.EXPECT().SetValign(mock.Anything).Once()
46- mockLabel.EXPECT().SetCanFocus(false).Once()
47- mockLabel.EXPECT().SetCanTarget(false).Once()
48- mockLabel.EXPECT().AddCssClass("loading-skeleton-text").Once()
43+ mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
44+ mockLogo.EXPECT().SetHalign(mock.Anything).Once()
45+ mockLogo.EXPECT().SetValign(mock.Anything).Once()
46+ mockLogo.EXPECT().SetCanFocus(false).Once()
47+ mockLogo.EXPECT().SetCanTarget(false).Once()
48+ mockLogo.EXPECT().SetSizeRequest(512, 512).Once()
49+ mockLogo.EXPECT().SetPixelSize(512).Once()
50+ mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
51+ mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
4952
53+ mockContent.EXPECT().Append(mockLogo).Once()
5054 mockContent.EXPECT().Append(mockSpinner).Once()
51- mockContent.EXPECT().Append(mockLabel).Once()
5255 mockContainer.EXPECT().Append(mockContent).Once()
5356
5457 mockContainer.EXPECT().SetVisible(true).Once()
@@ -14,7 +14,7 @@ func TestPaneView_HideLoadingSkeleton_HidesWhenPresent(t *testing.T) {
14 mockContainer := mocks.NewMockBoxWidget(t)14 mockContainer := mocks.NewMockBoxWidget(t)
15 mockContent := mocks.NewMockBoxWidget(t)15 mockContent := mocks.NewMockBoxWidget(t)
16 mockSpinner := mocks.NewMockSpinnerWidget(t)16 mockSpinner := mocks.NewMockSpinnerWidget(t)
17- mockLabel := mocks.NewMockLabelWidget(t)17+ mockLogo := mocks.NewMockImageWidget(t)
18 18
19 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()19 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
20 mockContainer.EXPECT().SetHexpand(true).Once()20 mockContainer.EXPECT().SetHexpand(true).Once()
@@ -40,15 +40,18 @@ func TestPaneView_HideLoadingSkeleton_HidesWhenPresent(t *testing.T) {
40 mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()40 mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
41 mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()41 mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
42 42
43- mockFactory.EXPECT().NewLabel("Loading...").Return(mockLabel).Once()43+ mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
44- mockLabel.EXPECT().SetHalign(mock.Anything).Once()44+ mockLogo.EXPECT().SetHalign(mock.Anything).Once()
45- mockLabel.EXPECT().SetValign(mock.Anything).Once()45+ mockLogo.EXPECT().SetValign(mock.Anything).Once()
46- mockLabel.EXPECT().SetCanFocus(false).Once()46+ mockLogo.EXPECT().SetCanFocus(false).Once()
47- mockLabel.EXPECT().SetCanTarget(false).Once()47+ mockLogo.EXPECT().SetCanTarget(false).Once()
48- mockLabel.EXPECT().AddCssClass("loading-skeleton-text").Once()48+ mockLogo.EXPECT().SetSizeRequest(512, 512).Once()
49+ mockLogo.EXPECT().SetPixelSize(512).Once()
50+ mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
51+ mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
49 52
53+ mockContent.EXPECT().Append(mockLogo).Once()
50 mockContent.EXPECT().Append(mockSpinner).Once()54 mockContent.EXPECT().Append(mockSpinner).Once()
51- mockContent.EXPECT().Append(mockLabel).Once()
52 mockContainer.EXPECT().Append(mockContent).Once()55 mockContainer.EXPECT().Append(mockContent).Once()
53 56
54 mockContainer.EXPECT().SetVisible(true).Once()57 mockContainer.EXPECT().SetVisible(true).Once()
M internal/ui/component/pane_view_test.go +20 -17
@@ -23,7 +23,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
2323 mockLoadingContainer := mocks.NewMockBoxWidget(t)
2424 mockLoadingContent := mocks.NewMockBoxWidget(t)
2525 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
26- mockLoadingLabel := mocks.NewMockLabelWidget(t)
26+ mockLoadingLogo := mocks.NewMockImageWidget(t)
2727
2828 paneID := entity.PaneID("pane-1")
2929
@@ -34,7 +34,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
3434 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
3535 mockOverlay.EXPECT().SetChild(mockWebView).Once()
3636
37- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLabel)
37+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
3838
3939 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
4040 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -64,7 +64,7 @@ func TestNewPaneView_NilWebView(t *testing.T) {
6464 mockLoadingContainer := mocks.NewMockBoxWidget(t)
6565 mockLoadingContent := mocks.NewMockBoxWidget(t)
6666 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
67- mockLoadingLabel := mocks.NewMockLabelWidget(t)
67+ mockLoadingLogo := mocks.NewMockImageWidget(t)
6868
6969 paneID := entity.PaneID("pane-1")
7070
@@ -75,7 +75,7 @@ func TestNewPaneView_NilWebView(t *testing.T) {
7575 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
7676 // SetChild should NOT be called when webview is nil
7777
78- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLabel)
78+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
7979
8080 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
8181 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -514,7 +514,7 @@ func setupLoadingSkeletonMocks(
514514 mockLoadingContainer *mocks.MockBoxWidget,
515515 mockLoadingContent *mocks.MockBoxWidget,
516516 mockLoadingSpinner *mocks.MockSpinnerWidget,
517- mockLoadingLabel *mocks.MockLabelWidget,
517+ mockLoadingLogo *mocks.MockImageWidget,
518518 ) {
519519 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
520520 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
@@ -542,15 +542,18 @@ func setupLoadingSkeletonMocks(
542542 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
543543 mockLoadingSpinner.EXPECT().Start().Maybe()
544544
545- mockFactory.EXPECT().NewLabel("Loading...").Return(mockLoadingLabel).Once()
546- mockLoadingLabel.EXPECT().SetHalign(mock.Anything).Maybe()
547- mockLoadingLabel.EXPECT().SetValign(mock.Anything).Maybe()
548- mockLoadingLabel.EXPECT().SetCanFocus(false).Maybe()
549- mockLoadingLabel.EXPECT().SetCanTarget(false).Maybe()
550- mockLoadingLabel.EXPECT().AddCssClass("loading-skeleton-text").Maybe()
551-
545+ mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()
546+ mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()
547+ mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()
548+ mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()
549+ mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()
550+ mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe()
551+ mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe()
552+ mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()
553+ mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
554+
555+ mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
552556 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
553- mockLoadingContent.EXPECT().Append(mockLoadingLabel).Maybe()
554557 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
555558
556559 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
@@ -569,7 +572,7 @@ func setupPaneViewMocks(
569572 mockLoadingContainer := mocks.NewMockBoxWidget(t)
570573 mockLoadingContent := mocks.NewMockBoxWidget(t)
571574 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
572- mockLoadingLabel := mocks.NewMockLabelWidget(t)
575+ mockLoadingLogo := mocks.NewMockImageWidget(t)
573576
574577 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
575578 mockOverlay.EXPECT().SetHexpand(true).Once()
@@ -578,7 +581,7 @@ func setupPaneViewMocks(
578581 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
579582 mockOverlay.EXPECT().SetChild(mockWebView).Once()
580583
581- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLabel)
584+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
582585
583586 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
584587 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -600,7 +603,7 @@ func setupPaneViewMocksNoWebView(
600603 mockLoadingContainer := mocks.NewMockBoxWidget(t)
601604 mockLoadingContent := mocks.NewMockBoxWidget(t)
602605 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
603- mockLoadingLabel := mocks.NewMockLabelWidget(t)
606+ mockLoadingLogo := mocks.NewMockImageWidget(t)
604607
605608 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
606609 mockOverlay.EXPECT().SetHexpand(true).Once()
@@ -608,7 +611,7 @@ func setupPaneViewMocksNoWebView(
608611 mockOverlay.EXPECT().SetVisible(true).Once()
609612 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
610613
611- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLabel)
614+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
612615
613616 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
614617 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -23,7 +23,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
23 mockLoadingContainer := mocks.NewMockBoxWidget(t)23 mockLoadingContainer := mocks.NewMockBoxWidget(t)
24 mockLoadingContent := mocks.NewMockBoxWidget(t)24 mockLoadingContent := mocks.NewMockBoxWidget(t)
25 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)25 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
26- mockLoadingLabel := mocks.NewMockLabelWidget(t)26+ mockLoadingLogo := mocks.NewMockImageWidget(t)
27 27
28 paneID := entity.PaneID("pane-1")28 paneID := entity.PaneID("pane-1")
29 29
@@ -34,7 +34,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
34 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background34 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
35 mockOverlay.EXPECT().SetChild(mockWebView).Once()35 mockOverlay.EXPECT().SetChild(mockWebView).Once()
36 36
37- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLabel)37+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
38 38
39 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()39 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
40 mockBorderBox.EXPECT().SetCanFocus(false).Once()40 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -64,7 +64,7 @@ func TestNewPaneView_NilWebView(t *testing.T) {
64 mockLoadingContainer := mocks.NewMockBoxWidget(t)64 mockLoadingContainer := mocks.NewMockBoxWidget(t)
65 mockLoadingContent := mocks.NewMockBoxWidget(t)65 mockLoadingContent := mocks.NewMockBoxWidget(t)
66 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)66 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
67- mockLoadingLabel := mocks.NewMockLabelWidget(t)67+ mockLoadingLogo := mocks.NewMockImageWidget(t)
68 68
69 paneID := entity.PaneID("pane-1")69 paneID := entity.PaneID("pane-1")
70 70
@@ -75,7 +75,7 @@ func TestNewPaneView_NilWebView(t *testing.T) {
75 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background75 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
76 // SetChild should NOT be called when webview is nil76 // SetChild should NOT be called when webview is nil
77 77
78- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLabel)78+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
79 79
80 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()80 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
81 mockBorderBox.EXPECT().SetCanFocus(false).Once()81 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -514,7 +514,7 @@ func setupLoadingSkeletonMocks(
514 mockLoadingContainer *mocks.MockBoxWidget,514 mockLoadingContainer *mocks.MockBoxWidget,
515 mockLoadingContent *mocks.MockBoxWidget,515 mockLoadingContent *mocks.MockBoxWidget,
516 mockLoadingSpinner *mocks.MockSpinnerWidget,516 mockLoadingSpinner *mocks.MockSpinnerWidget,
517- mockLoadingLabel *mocks.MockLabelWidget,517+ mockLoadingLogo *mocks.MockImageWidget,
518 ) {518 ) {
519 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()519 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
520 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()520 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
@@ -542,15 +542,18 @@ func setupLoadingSkeletonMocks(
542 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()542 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
543 mockLoadingSpinner.EXPECT().Start().Maybe()543 mockLoadingSpinner.EXPECT().Start().Maybe()
544 544
545- mockFactory.EXPECT().NewLabel("Loading...").Return(mockLoadingLabel).Once()545+ mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()
546- mockLoadingLabel.EXPECT().SetHalign(mock.Anything).Maybe()546+ mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()
547- mockLoadingLabel.EXPECT().SetValign(mock.Anything).Maybe()547+ mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()
548- mockLoadingLabel.EXPECT().SetCanFocus(false).Maybe()548+ mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()
549- mockLoadingLabel.EXPECT().SetCanTarget(false).Maybe()549+ mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()
550- mockLoadingLabel.EXPECT().AddCssClass("loading-skeleton-text").Maybe()550+ mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe()
551-551+ mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe()
552+ mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()
553+ mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
554+
555+ mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
552 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()556 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
553- mockLoadingContent.EXPECT().Append(mockLoadingLabel).Maybe()
554 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()557 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
555 558
556 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()559 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
@@ -569,7 +572,7 @@ func setupPaneViewMocks(
569 mockLoadingContainer := mocks.NewMockBoxWidget(t)572 mockLoadingContainer := mocks.NewMockBoxWidget(t)
570 mockLoadingContent := mocks.NewMockBoxWidget(t)573 mockLoadingContent := mocks.NewMockBoxWidget(t)
571 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)574 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
572- mockLoadingLabel := mocks.NewMockLabelWidget(t)575+ mockLoadingLogo := mocks.NewMockImageWidget(t)
573 576
574 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()577 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
575 mockOverlay.EXPECT().SetHexpand(true).Once()578 mockOverlay.EXPECT().SetHexpand(true).Once()
@@ -578,7 +581,7 @@ func setupPaneViewMocks(
578 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background581 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
579 mockOverlay.EXPECT().SetChild(mockWebView).Once()582 mockOverlay.EXPECT().SetChild(mockWebView).Once()
580 583
581- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLabel)584+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
582 585
583 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()586 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
584 mockBorderBox.EXPECT().SetCanFocus(false).Once()587 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -600,7 +603,7 @@ func setupPaneViewMocksNoWebView(
600 mockLoadingContainer := mocks.NewMockBoxWidget(t)603 mockLoadingContainer := mocks.NewMockBoxWidget(t)
601 mockLoadingContent := mocks.NewMockBoxWidget(t)604 mockLoadingContent := mocks.NewMockBoxWidget(t)
602 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)605 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
603- mockLoadingLabel := mocks.NewMockLabelWidget(t)606+ mockLoadingLogo := mocks.NewMockImageWidget(t)
604 607
605 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()608 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
606 mockOverlay.EXPECT().SetHexpand(true).Once()609 mockOverlay.EXPECT().SetHexpand(true).Once()
@@ -608,7 +611,7 @@ func setupPaneViewMocksNoWebView(
608 mockOverlay.EXPECT().SetVisible(true).Once()611 mockOverlay.EXPECT().SetVisible(true).Once()
609 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background612 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
610 613
611- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLabel)614+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
612 615
613 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()616 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
614 mockBorderBox.EXPECT().SetCanFocus(false).Once()617 mockBorderBox.EXPECT().SetCanFocus(false).Once()
M internal/ui/component/workspace_view_test.go +14 -11
@@ -20,7 +20,7 @@ func setupLoadingSkeletonMocksWorkspace(
2020 mockLoadingContainer *mocks.MockBoxWidget,
2121 mockLoadingContent *mocks.MockBoxWidget,
2222 mockLoadingSpinner *mocks.MockSpinnerWidget,
23- mockLoadingLabel *mocks.MockLabelWidget,
23+ mockLoadingLogo *mocks.MockImageWidget,
2424 ) {
2525 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
2626 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
@@ -48,15 +48,18 @@ func setupLoadingSkeletonMocksWorkspace(
4848 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
4949 mockLoadingSpinner.EXPECT().Start().Maybe()
5050
51- mockFactory.EXPECT().NewLabel("Loading...").Return(mockLoadingLabel).Once()
52- mockLoadingLabel.EXPECT().SetHalign(mock.Anything).Maybe()
53- mockLoadingLabel.EXPECT().SetValign(mock.Anything).Maybe()
54- mockLoadingLabel.EXPECT().SetCanFocus(false).Maybe()
55- mockLoadingLabel.EXPECT().SetCanTarget(false).Maybe()
56- mockLoadingLabel.EXPECT().AddCssClass("loading-skeleton-text").Maybe()
57-
51+ mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()
52+ mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()
53+ mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()
54+ mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()
55+ mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()
56+ mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe()
57+ mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe()
58+ mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()
59+ mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
60+
61+ mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
5862 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
59- mockLoadingContent.EXPECT().Append(mockLoadingLabel).Maybe()
6063 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
6164
6265 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
@@ -90,7 +93,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact
9093 mockLoadingContainer := mocks.NewMockBoxWidget(t)
9194 mockLoadingContent := mocks.NewMockBoxWidget(t)
9295 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
93- mockLoadingLabel := mocks.NewMockLabelWidget(t)
96+ mockLoadingLogo := mocks.NewMockImageWidget(t)
9497
9598 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
9699 mockOverlay.EXPECT().SetHexpand(true).Once()
@@ -98,7 +101,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact
98101 mockOverlay.EXPECT().SetVisible(true).Once()
99102 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()
100103
101- setupLoadingSkeletonMocksWorkspace(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLabel)
104+ setupLoadingSkeletonMocksWorkspace(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
102105
103106 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
104107 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -20,7 +20,7 @@ func setupLoadingSkeletonMocksWorkspace(
20 mockLoadingContainer *mocks.MockBoxWidget,20 mockLoadingContainer *mocks.MockBoxWidget,
21 mockLoadingContent *mocks.MockBoxWidget,21 mockLoadingContent *mocks.MockBoxWidget,
22 mockLoadingSpinner *mocks.MockSpinnerWidget,22 mockLoadingSpinner *mocks.MockSpinnerWidget,
23- mockLoadingLabel *mocks.MockLabelWidget,23+ mockLoadingLogo *mocks.MockImageWidget,
24 ) {24 ) {
25 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()25 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
26 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()26 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
@@ -48,15 +48,18 @@ func setupLoadingSkeletonMocksWorkspace(
48 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()48 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
49 mockLoadingSpinner.EXPECT().Start().Maybe()49 mockLoadingSpinner.EXPECT().Start().Maybe()
50 50
51- mockFactory.EXPECT().NewLabel("Loading...").Return(mockLoadingLabel).Once()51+ mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()
52- mockLoadingLabel.EXPECT().SetHalign(mock.Anything).Maybe()52+ mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()
53- mockLoadingLabel.EXPECT().SetValign(mock.Anything).Maybe()53+ mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()
54- mockLoadingLabel.EXPECT().SetCanFocus(false).Maybe()54+ mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()
55- mockLoadingLabel.EXPECT().SetCanTarget(false).Maybe()55+ mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()
56- mockLoadingLabel.EXPECT().AddCssClass("loading-skeleton-text").Maybe()56+ mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe()
57-57+ mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe()
58+ mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()
59+ mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
60+
61+ mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
58 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()62 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
59- mockLoadingContent.EXPECT().Append(mockLoadingLabel).Maybe()
60 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()63 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
61 64
62 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()65 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
@@ -90,7 +93,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact
90 mockLoadingContainer := mocks.NewMockBoxWidget(t)93 mockLoadingContainer := mocks.NewMockBoxWidget(t)
91 mockLoadingContent := mocks.NewMockBoxWidget(t)94 mockLoadingContent := mocks.NewMockBoxWidget(t)
92 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)95 mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
93- mockLoadingLabel := mocks.NewMockLabelWidget(t)96+ mockLoadingLogo := mocks.NewMockImageWidget(t)
94 97
95 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()98 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
96 mockOverlay.EXPECT().SetHexpand(true).Once()99 mockOverlay.EXPECT().SetHexpand(true).Once()
@@ -98,7 +101,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact
98 mockOverlay.EXPECT().SetVisible(true).Once()101 mockOverlay.EXPECT().SetVisible(true).Once()
99 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()102 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()
100 103
101- setupLoadingSkeletonMocksWorkspace(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLabel)104+ setupLoadingSkeletonMocksWorkspace(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
102 105
103 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()106 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
104 mockBorderBox.EXPECT().SetCanFocus(false).Once()107 mockBorderBox.EXPECT().SetCanFocus(false).Once()
M internal/ui/theme/css.go +5 -5
@@ -512,13 +512,13 @@ func generatePaneCSS(p Palette) string {
512512 -gtk-icon-size: 32px;
513513 min-width: 32px;
514514 min-height: 32px;
515+ color: var(--muted);
515516 }
516517
517-.loading-skeleton-text {
518- font-family: var(--font-mono);
519- font-size: 1.5em;
520- letter-spacing: 0.1em;
521- color: var(--muted);
518+.loading-skeleton-logo {
519+ opacity: 0.08;
520+ min-width: 512px;
521+ min-height: 512px;
522522 }
523523
524524 /* Pane border - default transparent */
@@ -512,13 +512,13 @@ func generatePaneCSS(p Palette) string {
512 -gtk-icon-size: 32px;512 -gtk-icon-size: 32px;
513 min-width: 32px;513 min-width: 32px;
514 min-height: 32px;514 min-height: 32px;
515+ color: var(--muted);
515 }516 }
516 517
517-.loading-skeleton-text {518+.loading-skeleton-logo {
518- font-family: var(--font-mono);519+ opacity: 0.08;
519- font-size: 1.5em;520+ min-width: 512px;
520- letter-spacing: 0.1em;521+ min-height: 512px;
521- color: var(--muted);
522 }522 }
523 523
524 /* Pane border - default transparent */524 /* Pane border - default transparent */
A webui/public/favicon.png +0 -0 binary file: no text diff

binary file: no text diff

M webui/src/pages/homepage/favorites/FavoriteCard.svelte +4 -0
@@ -21,6 +21,10 @@
2121 const getFaviconUrl = (fav: Favorite): string => {
2222 // Use provided favicon_url if available
2323 if (fav.favicon_url) return fav.favicon_url;
24+ // Handle internal dumb:// URLs - use embedded favicon
25+ if (fav.url.startsWith('dumb://')) {
26+ return 'favicon.png';
27+ }
2428 // Construct DuckDuckGo favicon URL from domain
2529 const domain = getDomain(fav.url);
2630 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
@@ -21,6 +21,10 @@
21 const getFaviconUrl = (fav: Favorite): string => {21 const getFaviconUrl = (fav: Favorite): string => {
22 // Use provided favicon_url if available22 // Use provided favicon_url if available
23 if (fav.favicon_url) return fav.favicon_url;23 if (fav.favicon_url) return fav.favicon_url;
24+ // Handle internal dumb:// URLs - use embedded favicon
25+ if (fav.url.startsWith('dumb://')) {
26+ return 'favicon.png';
27+ }
24 // Construct DuckDuckGo favicon URL from domain28 // Construct DuckDuckGo favicon URL from domain
25 const domain = getDomain(fav.url);29 const domain = getDomain(fav.url);
26 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;30 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
M webui/src/pages/homepage/history/HistoryItem.svelte +4 -0
@@ -23,6 +23,10 @@
2323 const getFaviconUrl = (item: HistoryEntry): string => {
2424 // Use provided favicon_url if available
2525 if (item.favicon_url) return item.favicon_url;
26+ // Handle internal dumb:// URLs - use embedded favicon
27+ if (item.url.startsWith('dumb://')) {
28+ return 'favicon.png';
29+ }
2630 // Construct DuckDuckGo favicon URL from domain
2731 const domain = getDomain(item.url).replace(/^www\./, '');
2832 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
@@ -23,6 +23,10 @@
23 const getFaviconUrl = (item: HistoryEntry): string => {23 const getFaviconUrl = (item: HistoryEntry): string => {
24 // Use provided favicon_url if available24 // Use provided favicon_url if available
25 if (item.favicon_url) return item.favicon_url;25 if (item.favicon_url) return item.favicon_url;
26+ // Handle internal dumb:// URLs - use embedded favicon
27+ if (item.url.startsWith('dumb://')) {
28+ return 'favicon.png';
29+ }
26 // Construct DuckDuckGo favicon URL from domain30 // Construct DuckDuckGo favicon URL from domain
27 const domain = getDomain(item.url).replace(/^www\./, '');31 const domain = getDomain(item.url).replace(/^www\./, '');
28 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;32 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
M webui/vite.config.ts +21 -0
@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
22 import { svelte } from "@sveltejs/vite-plugin-svelte";
33 import { resolve } from "path";
44 import { pageGenerator } from "./vite-plugin-pages";
5+import { copyFileSync, existsSync, mkdirSync } from "fs";
56
67 // Page configurations for dumb:// protocol pages
78 const pages = {
@@ -46,6 +47,20 @@ const pages = {
4647 // Get page from VITE_PAGE env var, default to building all
4748 const targetPage = process.env.VITE_PAGE as keyof typeof pages | undefined;
4849
50+// Copy public assets to output directory
51+function copyPublicAssets() {
52+ const outDir = resolve(__dirname, "../assets/webui");
53+ const publicDir = resolve(__dirname, "public");
54+ if (!existsSync(outDir)) {
55+ mkdirSync(outDir, { recursive: true });
56+ }
57+ // Copy favicon.png for dumb:// URL favicons
58+ const faviconSrc = resolve(publicDir, "favicon.png");
59+ if (existsSync(faviconSrc)) {
60+ copyFileSync(faviconSrc, resolve(outDir, "favicon.png"));
61+ }
62+}
63+
4964 // Build single page config
5065 function buildPageConfig(pageName: keyof typeof pages) {
5166 const page = pages[pageName];
@@ -55,6 +70,12 @@ function buildPageConfig(pageName: keyof typeof pages) {
5570 emitCss: false, // Inline component CSS for shadow DOM compatibility
5671 }),
5772 pageGenerator([page.html]),
73+ {
74+ name: "copy-public-assets",
75+ closeBundle() {
76+ copyPublicAssets();
77+ },
78+ },
5879 ],
5980 build: {
6081 rollupOptions: {
@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
2 import { svelte } from "@sveltejs/vite-plugin-svelte";2 import { svelte } from "@sveltejs/vite-plugin-svelte";
3 import { resolve } from "path";3 import { resolve } from "path";
4 import { pageGenerator } from "./vite-plugin-pages";4 import { pageGenerator } from "./vite-plugin-pages";
5+import { copyFileSync, existsSync, mkdirSync } from "fs";
5 6
6 // Page configurations for dumb:// protocol pages7 // Page configurations for dumb:// protocol pages
7 const pages = {8 const pages = {
@@ -46,6 +47,20 @@ const pages = {
46 // Get page from VITE_PAGE env var, default to building all47 // Get page from VITE_PAGE env var, default to building all
47 const targetPage = process.env.VITE_PAGE as keyof typeof pages | undefined;48 const targetPage = process.env.VITE_PAGE as keyof typeof pages | undefined;
48 49
50+// Copy public assets to output directory
51+function copyPublicAssets() {
52+ const outDir = resolve(__dirname, "../assets/webui");
53+ const publicDir = resolve(__dirname, "public");
54+ if (!existsSync(outDir)) {
55+ mkdirSync(outDir, { recursive: true });
56+ }
57+ // Copy favicon.png for dumb:// URL favicons
58+ const faviconSrc = resolve(publicDir, "favicon.png");
59+ if (existsSync(faviconSrc)) {
60+ copyFileSync(faviconSrc, resolve(outDir, "favicon.png"));
61+ }
62+}
63+
49 // Build single page config64 // Build single page config
50 function buildPageConfig(pageName: keyof typeof pages) {65 function buildPageConfig(pageName: keyof typeof pages) {
51 const page = pages[pageName];66 const page = pages[pageName];
@@ -55,6 +70,12 @@ function buildPageConfig(pageName: keyof typeof pages) {
55 emitCss: false, // Inline component CSS for shadow DOM compatibility70 emitCss: false, // Inline component CSS for shadow DOM compatibility
56 }),71 }),
57 pageGenerator([page.html]),72 pageGenerator([page.html]),
73+ {
74+ name: "copy-public-assets",
75+ closeBundle() {
76+ copyPublicAssets();
77+ },
78+ },
58 ],79 ],
59 build: {80 build: {
60 rollupOptions: {81 rollupOptions: {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close