detached 457d11f

feat(favicon): add normalized 32x32 PNG icons for dmenu/fuzzel

commit
457d11f43e2a46ce8a3f85d11dee382b6e18a96e
author
brice · · 2025-12-23 06:53 UTC
parent
5ed6481
signature
unsigned
10 files · +290 -17
M go.mod +3 -2
@@ -15,6 +15,7 @@ require (
1515 github.com/spf13/cobra v1.10.2
1616 github.com/spf13/viper v1.21.0
1717 github.com/stretchr/testify v1.11.1
18+ golang.org/x/image v0.34.0
1819 )
1920
2021 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3
@@ -57,8 +58,8 @@ require (
5758 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
5859 go.uber.org/multierr v1.11.0 // indirect
5960 go.yaml.in/yaml/v3 v3.0.4 // indirect
60- golang.org/x/sync v0.18.0 // indirect
61+ golang.org/x/sync v0.19.0 // indirect
6162 golang.org/x/sys v0.38.0 // indirect
62- golang.org/x/text v0.31.0 // indirect
63+ golang.org/x/text v0.32.0 // indirect
6364 gopkg.in/yaml.v3 v3.0.1 // indirect
6465 )
@@ -15,6 +15,7 @@ require (
15 github.com/spf13/cobra v1.10.215 github.com/spf13/cobra v1.10.2
16 github.com/spf13/viper v1.21.016 github.com/spf13/viper v1.21.0
17 github.com/stretchr/testify v1.11.117 github.com/stretchr/testify v1.11.1
18+ golang.org/x/image v0.34.0
18 )19 )
19 20
20 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa321 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3
@@ -57,8 +58,8 @@ require (
57 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect58 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
58 go.uber.org/multierr v1.11.0 // indirect59 go.uber.org/multierr v1.11.0 // indirect
59 go.yaml.in/yaml/v3 v3.0.4 // indirect60 go.yaml.in/yaml/v3 v3.0.4 // indirect
60- golang.org/x/sync v0.18.0 // indirect61+ golang.org/x/sync v0.19.0 // indirect
61 golang.org/x/sys v0.38.0 // indirect62 golang.org/x/sys v0.38.0 // indirect
62- golang.org/x/text v0.31.0 // indirect63+ golang.org/x/text v0.32.0 // indirect
63 gopkg.in/yaml.v3 v3.0.1 // indirect64 gopkg.in/yaml.v3 v3.0.1 // indirect
64 )65 )
M go.sum +6 -4 generated file: collapsed
M internal/cli/app.go +12 -0
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/dumber/internal/domain/build"
1515 "github.com/bnema/dumber/internal/domain/repository"
1616 "github.com/bnema/dumber/internal/infrastructure/config"
17+ "github.com/bnema/dumber/internal/infrastructure/favicon"
1718 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
1819 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
1920 "github.com/bnema/dumber/internal/logging"
@@ -31,6 +32,9 @@ type App struct {
3132 SearchHistoryUC *usecase.SearchHistoryUseCase
3233 SessionUC *usecase.ManageSessionUseCase
3334
35+ // Services
36+ FaviconService *favicon.Service
37+
3438 // Context with logger
3539 ctx context.Context
3640 logCleanup func()
@@ -105,6 +109,10 @@ func NewApp() (*App, error) {
105109 // Create use cases
106110 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
107111
112+ // Create favicon service for CLI (path resolution for dmenu/fuzzel)
113+ faviconCacheDir, _ := config.GetFaviconCacheDir()
114+ faviconService := favicon.NewService(faviconCacheDir)
115+
108116 return &App{
109117 Config: cfg,
110118 Theme: theme,
@@ -112,6 +120,7 @@ func NewApp() (*App, error) {
112120 History: historyRepo,
113121 SearchHistoryUC: searchHistoryUC,
114122 SessionUC: sessionUC,
123+ FaviconService: faviconService,
115124 ctx: ctx,
116125 logCleanup: logCleanup,
117126 }, nil
@@ -122,6 +131,9 @@ func (a *App) Close() error {
122131 if a.logCleanup != nil {
123132 a.logCleanup()
124133 }
134+ if a.FaviconService != nil {
135+ a.FaviconService.Close()
136+ }
125137 if a.db != nil {
126138 return a.db.Close()
127139 }
@@ -14,6 +14,7 @@ import (
14 "github.com/bnema/dumber/internal/domain/build"14 "github.com/bnema/dumber/internal/domain/build"
15 "github.com/bnema/dumber/internal/domain/repository"15 "github.com/bnema/dumber/internal/domain/repository"
16 "github.com/bnema/dumber/internal/infrastructure/config"16 "github.com/bnema/dumber/internal/infrastructure/config"
17+ "github.com/bnema/dumber/internal/infrastructure/favicon"
17 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"18 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
18 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"19 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
19 "github.com/bnema/dumber/internal/logging"20 "github.com/bnema/dumber/internal/logging"
@@ -31,6 +32,9 @@ type App struct {
31 SearchHistoryUC *usecase.SearchHistoryUseCase32 SearchHistoryUC *usecase.SearchHistoryUseCase
32 SessionUC *usecase.ManageSessionUseCase33 SessionUC *usecase.ManageSessionUseCase
33 34
35+ // Services
36+ FaviconService *favicon.Service
37+
34 // Context with logger38 // Context with logger
35 ctx context.Context39 ctx context.Context
36 logCleanup func()40 logCleanup func()
@@ -105,6 +109,10 @@ func NewApp() (*App, error) {
105 // Create use cases109 // Create use cases
106 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)110 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
107 111
112+ // Create favicon service for CLI (path resolution for dmenu/fuzzel)
113+ faviconCacheDir, _ := config.GetFaviconCacheDir()
114+ faviconService := favicon.NewService(faviconCacheDir)
115+
108 return &App{116 return &App{
109 Config: cfg,117 Config: cfg,
110 Theme: theme,118 Theme: theme,
@@ -112,6 +120,7 @@ func NewApp() (*App, error) {
112 History: historyRepo,120 History: historyRepo,
113 SearchHistoryUC: searchHistoryUC,121 SearchHistoryUC: searchHistoryUC,
114 SessionUC: sessionUC,122 SessionUC: sessionUC,
123+ FaviconService: faviconService,
115 ctx: ctx,124 ctx: ctx,
116 logCleanup: logCleanup,125 logCleanup: logCleanup,
117 }, nil126 }, nil
@@ -122,6 +131,9 @@ func (a *App) Close() error {
122 if a.logCleanup != nil {131 if a.logCleanup != nil {
123 a.logCleanup()132 a.logCleanup()
124 }133 }
134+ if a.FaviconService != nil {
135+ a.FaviconService.Close()
136+ }
125 if a.db != nil {137 if a.db != nil {
126 return a.db.Close()138 return a.db.Close()
127 }139 }
M internal/cli/cmd/dmenu.go +29 -9
@@ -6,7 +6,6 @@ import (
66 "fmt"
77 "os"
88 "os/exec"
9- "path/filepath"
109 "strings"
1110
1211 tea "github.com/charmbracelet/bubbletea"
@@ -15,6 +14,7 @@ import (
1514 "github.com/bnema/dumber/internal/cli/model"
1615 domainurl "github.com/bnema/dumber/internal/domain/url"
1716 "github.com/bnema/dumber/internal/infrastructure/config"
17+ "github.com/bnema/dumber/internal/logging"
1818 )
1919
2020 var (
@@ -79,22 +79,41 @@ func getDmenuConfig() config.DmenuConfig {
7979
8080 // getFaviconPath returns the path to a cached PNG favicon for the given URL.
8181 // Returns empty string if the PNG favicon doesn't exist in cache.
82-// PNG format is required by rofi/fuzzel launchers.
82+// Prefers sized (32x32) PNG for consistent display in fuzzel/rofi.
83+// Will create the sized PNG on-demand if only the original exists.
8384 func getFaviconPath(rawURL string) string {
84- cacheDir, err := config.GetFaviconCacheDir()
85- if err != nil {
85+ app := GetApp()
86+ if app == nil || app.FaviconService == nil {
8687 return ""
8788 }
89+
8890 domain := domainurl.ExtractDomain(rawURL)
8991 if domain == "" {
9092 return ""
9193 }
92- // Use PNG format for fuzzel/rofi compatibility
93- filename := domainurl.SanitizeDomainForPNG(domain)
94- path := filepath.Join(cacheDir, filename)
95- if _, err := os.Stat(path); err == nil {
96- return path
94+
95+ ctx := app.Ctx()
96+ log := logging.FromContext(ctx)
97+ const normalizedSize = 32
98+
99+ // Check if sized PNG already exists
100+ if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) {
101+ return app.FaviconService.DiskPathPNGSized(domain, normalizedSize)
97102 }
103+
104+ // If original PNG exists but sized doesn't, create it on-demand
105+ if app.FaviconService.HasPNGOnDisk(domain) {
106+ log.Debug().Str("domain", domain).Msg("creating sized PNG on-demand from dmenu")
107+ if err := app.FaviconService.EnsureSizedPNG(ctx, domain, normalizedSize); err != nil {
108+ log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG")
109+ return ""
110+ }
111+ log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
112+ return app.FaviconService.DiskPathPNGSized(domain, normalizedSize)
113+ }
114+
115+ // No PNG at all for this domain
116+ log.Debug().Str("domain", domain).Msg("no favicon PNG found for domain")
98117 return ""
99118 }
100119
@@ -127,6 +146,7 @@ func runDmenuPipe() error {
127146 faviconPath := getFaviconPath(entry.URL)
128147
129148 // Format: "URL\0icon\x1f/path\n" (simple, selection returns URL directly)
149+ // Note: Icon size is controlled by rofi theme (element-icon { size: X; })
130150 if faviconPath != "" {
131151 fmt.Printf("%s\x00icon\x1f%s\n", display, faviconPath)
132152 } else {
@@ -6,7 +6,6 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "os/exec"8 "os/exec"
9- "path/filepath"
10 "strings"9 "strings"
11 10
12 tea "github.com/charmbracelet/bubbletea"11 tea "github.com/charmbracelet/bubbletea"
@@ -15,6 +14,7 @@ import (
15 "github.com/bnema/dumber/internal/cli/model"14 "github.com/bnema/dumber/internal/cli/model"
16 domainurl "github.com/bnema/dumber/internal/domain/url"15 domainurl "github.com/bnema/dumber/internal/domain/url"
17 "github.com/bnema/dumber/internal/infrastructure/config"16 "github.com/bnema/dumber/internal/infrastructure/config"
17+ "github.com/bnema/dumber/internal/logging"
18 )18 )
19 19
20 var (20 var (
@@ -79,22 +79,41 @@ func getDmenuConfig() config.DmenuConfig {
79 79
80 // getFaviconPath returns the path to a cached PNG favicon for the given URL.80 // getFaviconPath returns the path to a cached PNG favicon for the given URL.
81 // Returns empty string if the PNG favicon doesn't exist in cache.81 // Returns empty string if the PNG favicon doesn't exist in cache.
82-// PNG format is required by rofi/fuzzel launchers.82+// Prefers sized (32x32) PNG for consistent display in fuzzel/rofi.
83+// Will create the sized PNG on-demand if only the original exists.
83 func getFaviconPath(rawURL string) string {84 func getFaviconPath(rawURL string) string {
84- cacheDir, err := config.GetFaviconCacheDir()85+ app := GetApp()
85- if err != nil {86+ if app == nil || app.FaviconService == nil {
86 return ""87 return ""
87 }88 }
89+
88 domain := domainurl.ExtractDomain(rawURL)90 domain := domainurl.ExtractDomain(rawURL)
89 if domain == "" {91 if domain == "" {
90 return ""92 return ""
91 }93 }
92- // Use PNG format for fuzzel/rofi compatibility94+
93- filename := domainurl.SanitizeDomainForPNG(domain)95+ ctx := app.Ctx()
94- path := filepath.Join(cacheDir, filename)96+ log := logging.FromContext(ctx)
95- if _, err := os.Stat(path); err == nil {97+ const normalizedSize = 32
96- return path98+
99+ // Check if sized PNG already exists
100+ if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) {
101+ return app.FaviconService.DiskPathPNGSized(domain, normalizedSize)
97 }102 }
103+
104+ // If original PNG exists but sized doesn't, create it on-demand
105+ if app.FaviconService.HasPNGOnDisk(domain) {
106+ log.Debug().Str("domain", domain).Msg("creating sized PNG on-demand from dmenu")
107+ if err := app.FaviconService.EnsureSizedPNG(ctx, domain, normalizedSize); err != nil {
108+ log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG")
109+ return ""
110+ }
111+ log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
112+ return app.FaviconService.DiskPathPNGSized(domain, normalizedSize)
113+ }
114+
115+ // No PNG at all for this domain
116+ log.Debug().Str("domain", domain).Msg("no favicon PNG found for domain")
98 return ""117 return ""
99 }118 }
100 119
@@ -127,6 +146,7 @@ func runDmenuPipe() error {
127 faviconPath := getFaviconPath(entry.URL)146 faviconPath := getFaviconPath(entry.URL)
128 147
129 // Format: "URL\0icon\x1f/path\n" (simple, selection returns URL directly)148 // Format: "URL\0icon\x1f/path\n" (simple, selection returns URL directly)
149+ // Note: Icon size is controlled by rofi theme (element-icon { size: X; })
130 if faviconPath != "" {150 if faviconPath != "" {
131 fmt.Printf("%s\x00icon\x1f%s\n", display, faviconPath)151 fmt.Printf("%s\x00icon\x1f%s\n", display, faviconPath)
132 } else {152 } else {
M internal/domain/service/favicon.go +11 -0
@@ -31,6 +31,17 @@ type FaviconService interface {
3131 // Used by UI layer to export WebKit textures for CLI tools.
3232 WritePNG(domain string, pngData []byte)
3333
34+ // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
35+ // Used for normalized favicon export (e.g., 32x32) for CLI tools.
36+ DiskPathPNGSized(domain string, size int) string
37+
38+ // HasPNGSizedOnDisk checks if a sized PNG favicon exists on disk.
39+ HasPNGSizedOnDisk(domain string, size int) bool
40+
41+ // EnsureSizedPNG creates a resized PNG from the original if it doesn't exist.
42+ // This is used to generate normalized icons for dmenu/fuzzel.
43+ EnsureSizedPNG(ctx context.Context, domain string, size int) error
44+
3445 // Close shuts down background workers and releases resources.
3546 Close()
3647 }
@@ -31,6 +31,17 @@ type FaviconService interface {
31 // Used by UI layer to export WebKit textures for CLI tools.31 // Used by UI layer to export WebKit textures for CLI tools.
32 WritePNG(domain string, pngData []byte)32 WritePNG(domain string, pngData []byte)
33 33
34+ // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
35+ // Used for normalized favicon export (e.g., 32x32) for CLI tools.
36+ DiskPathPNGSized(domain string, size int) string
37+
38+ // HasPNGSizedOnDisk checks if a sized PNG favicon exists on disk.
39+ HasPNGSizedOnDisk(domain string, size int) bool
40+
41+ // EnsureSizedPNG creates a resized PNG from the original if it doesn't exist.
42+ // This is used to generate normalized icons for dmenu/fuzzel.
43+ EnsureSizedPNG(ctx context.Context, domain string, size int) error
44+
34 // Close shuts down background workers and releases resources.45 // Close shuts down background workers and releases resources.
35 Close()46 Close()
36 }47 }
M internal/domain/url/normalize.go +8 -0
@@ -2,6 +2,7 @@
22 package url
33
44 import (
5+ "fmt"
56 "net/url"
67 "strings"
78 )
@@ -102,3 +103,10 @@ func sanitizeDomain(domain string) string {
102103 )
103104 return replacer.Replace(domain)
104105 }
106+
107+// SanitizeDomainForPNGSized converts a domain to a safe filename with size suffix.
108+// Example: "google.com" with size 32 -> "google.com.32.png"
109+// Used for normalized favicon export for tools like rofi/fuzzel.
110+func SanitizeDomainForPNGSized(domain string, size int) string {
111+ return fmt.Sprintf("%s.%d.png", sanitizeDomain(domain), size)
112+}
@@ -2,6 +2,7 @@
2 package url2 package url
3 3
4 import (4 import (
5+ "fmt"
5 "net/url"6 "net/url"
6 "strings"7 "strings"
7 )8 )
@@ -102,3 +103,10 @@ func sanitizeDomain(domain string) string {
102 )103 )
103 return replacer.Replace(domain)104 return replacer.Replace(domain)
104 }105 }
106+
107+// SanitizeDomainForPNGSized converts a domain to a safe filename with size suffix.
108+// Example: "google.com" with size 32 -> "google.com.32.png"
109+// Used for normalized favicon export for tools like rofi/fuzzel.
110+func SanitizeDomainForPNGSized(domain string, size int) string {
111+ return fmt.Sprintf("%s.%d.png", sanitizeDomain(domain), size)
112+}
M internal/infrastructure/favicon/cache.go +21 -0
@@ -124,6 +124,27 @@ func (c *Cache) HasPNGOnDisk(domain string) bool {
124124 return err == nil
125125 }
126126
127+// DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
128+// Used for normalized favicon export (e.g., 32x32) for CLI tools like rofi/fuzzel.
129+// Returns empty string if disk caching is disabled or domain is empty.
130+func (c *Cache) DiskPathPNGSized(domain string, size int) string {
131+ if c.diskDir == "" || domain == "" {
132+ return ""
133+ }
134+ filename := domainurl.SanitizeDomainForPNGSized(domain, size)
135+ return filepath.Join(c.diskDir, filename)
136+}
137+
138+// HasPNGSizedOnDisk checks if a sized PNG favicon exists on disk for the given domain.
139+func (c *Cache) HasPNGSizedOnDisk(domain string, size int) bool {
140+ path := c.DiskPathPNGSized(domain, size)
141+ if path == "" {
142+ return false
143+ }
144+ _, err := os.Stat(path)
145+ return err == nil
146+}
147+
127148 // WritePNG writes raw PNG data to disk for a domain.
128149 // Used by UI layer to export WebKit textures for CLI tools.
129150 func (c *Cache) WritePNG(domain string, pngData []byte) {
@@ -124,6 +124,27 @@ func (c *Cache) HasPNGOnDisk(domain string) bool {
124 return err == nil124 return err == nil
125 }125 }
126 126
127+// DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
128+// Used for normalized favicon export (e.g., 32x32) for CLI tools like rofi/fuzzel.
129+// Returns empty string if disk caching is disabled or domain is empty.
130+func (c *Cache) DiskPathPNGSized(domain string, size int) string {
131+ if c.diskDir == "" || domain == "" {
132+ return ""
133+ }
134+ filename := domainurl.SanitizeDomainForPNGSized(domain, size)
135+ return filepath.Join(c.diskDir, filename)
136+}
137+
138+// HasPNGSizedOnDisk checks if a sized PNG favicon exists on disk for the given domain.
139+func (c *Cache) HasPNGSizedOnDisk(domain string, size int) bool {
140+ path := c.DiskPathPNGSized(domain, size)
141+ if path == "" {
142+ return false
143+ }
144+ _, err := os.Stat(path)
145+ return err == nil
146+}
147+
127 // WritePNG writes raw PNG data to disk for a domain.148 // WritePNG writes raw PNG data to disk for a domain.
128 // Used by UI layer to export WebKit textures for CLI tools.149 // Used by UI layer to export WebKit textures for CLI tools.
129 func (c *Cache) WritePNG(domain string, pngData []byte) {150 func (c *Cache) WritePNG(domain string, pngData []byte) {
A internal/infrastructure/favicon/resize.go +95 -0
@@ -0,0 +1,95 @@
1+// Package favicon provides favicon fetching and caching infrastructure.
2+package favicon
3+
4+import (
5+ "fmt"
6+ "image"
7+ "image/png"
8+ "os"
9+
10+ "golang.org/x/image/draw"
11+)
12+
13+const (
14+ // NormalizedIconSize is the standard size for dmenu/fuzzel icons.
15+ NormalizedIconSize = 32
16+)
17+
18+// ResizePNG loads a PNG from srcPath, resizes it to a square of the given size,
19+// and saves the result to dstPath. Uses golang.org/x/image/draw for high-quality
20+// resizing with CatmullRom interpolation.
21+func ResizePNG(srcPath, dstPath string, size int) error {
22+ // Open source file
23+ srcFile, err := os.Open(srcPath)
24+ if err != nil {
25+ return fmt.Errorf("open source: %w", err)
26+ }
27+ defer func() { _ = srcFile.Close() }()
28+
29+ // Decode PNG
30+ srcImg, err := png.Decode(srcFile)
31+ if err != nil {
32+ return fmt.Errorf("decode png: %w", err)
33+ }
34+
35+ // Calculate crop region to maintain aspect ratio (center crop to square)
36+ srcBounds := srcImg.Bounds()
37+ srcW := srcBounds.Dx()
38+ srcH := srcBounds.Dy()
39+
40+ var cropRect image.Rectangle
41+ if srcW > srcH {
42+ // Wider than tall - crop sides
43+ offset := (srcW - srcH) / 2
44+ cropRect = image.Rect(srcBounds.Min.X+offset, srcBounds.Min.Y, srcBounds.Min.X+offset+srcH, srcBounds.Max.Y)
45+ } else if srcH > srcW {
46+ // Taller than wide - crop top/bottom
47+ offset := (srcH - srcW) / 2
48+ cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+offset, srcBounds.Max.X, srcBounds.Min.Y+offset+srcW)
49+ } else {
50+ // Already square
51+ cropRect = srcBounds
52+ }
53+
54+ // Create cropped subimage
55+ croppedImg := cropImage(srcImg, cropRect)
56+
57+ // Create destination image
58+ dstImg := image.NewRGBA(image.Rect(0, 0, size, size))
59+
60+ // Scale using CatmullRom for high-quality interpolation
61+ draw.CatmullRom.Scale(dstImg, dstImg.Bounds(), croppedImg, croppedImg.Bounds(), draw.Over, nil)
62+
63+ // Create destination file
64+ dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, diskCacheFilePerm)
65+ if err != nil {
66+ return fmt.Errorf("create destination: %w", err)
67+ }
68+ defer func() { _ = dstFile.Close() }()
69+
70+ // Encode as PNG
71+ if err := png.Encode(dstFile, dstImg); err != nil {
72+ return fmt.Errorf("encode png: %w", err)
73+ }
74+
75+ return nil
76+}
77+
78+// cropImage returns a cropped portion of the source image.
79+func cropImage(src image.Image, rect image.Rectangle) image.Image {
80+ // If the source supports SubImage, use it for efficiency
81+ if subImager, ok := src.(interface {
82+ SubImage(r image.Rectangle) image.Image
83+ }); ok {
84+ return subImager.SubImage(rect)
85+ }
86+
87+ // Otherwise, copy pixels manually
88+ dst := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
89+ for y := 0; y < rect.Dy(); y++ {
90+ for x := 0; x < rect.Dx(); x++ {
91+ dst.Set(x, y, src.At(rect.Min.X+x, rect.Min.Y+y))
92+ }
93+ }
94+ return dst
95+}
@@ -0,0 +1,95 @@
1+// Package favicon provides favicon fetching and caching infrastructure.
2+package favicon
3+
4+import (
5+ "fmt"
6+ "image"
7+ "image/png"
8+ "os"
9+
10+ "golang.org/x/image/draw"
11+)
12+
13+const (
14+ // NormalizedIconSize is the standard size for dmenu/fuzzel icons.
15+ NormalizedIconSize = 32
16+)
17+
18+// ResizePNG loads a PNG from srcPath, resizes it to a square of the given size,
19+// and saves the result to dstPath. Uses golang.org/x/image/draw for high-quality
20+// resizing with CatmullRom interpolation.
21+func ResizePNG(srcPath, dstPath string, size int) error {
22+ // Open source file
23+ srcFile, err := os.Open(srcPath)
24+ if err != nil {
25+ return fmt.Errorf("open source: %w", err)
26+ }
27+ defer func() { _ = srcFile.Close() }()
28+
29+ // Decode PNG
30+ srcImg, err := png.Decode(srcFile)
31+ if err != nil {
32+ return fmt.Errorf("decode png: %w", err)
33+ }
34+
35+ // Calculate crop region to maintain aspect ratio (center crop to square)
36+ srcBounds := srcImg.Bounds()
37+ srcW := srcBounds.Dx()
38+ srcH := srcBounds.Dy()
39+
40+ var cropRect image.Rectangle
41+ if srcW > srcH {
42+ // Wider than tall - crop sides
43+ offset := (srcW - srcH) / 2
44+ cropRect = image.Rect(srcBounds.Min.X+offset, srcBounds.Min.Y, srcBounds.Min.X+offset+srcH, srcBounds.Max.Y)
45+ } else if srcH > srcW {
46+ // Taller than wide - crop top/bottom
47+ offset := (srcH - srcW) / 2
48+ cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+offset, srcBounds.Max.X, srcBounds.Min.Y+offset+srcW)
49+ } else {
50+ // Already square
51+ cropRect = srcBounds
52+ }
53+
54+ // Create cropped subimage
55+ croppedImg := cropImage(srcImg, cropRect)
56+
57+ // Create destination image
58+ dstImg := image.NewRGBA(image.Rect(0, 0, size, size))
59+
60+ // Scale using CatmullRom for high-quality interpolation
61+ draw.CatmullRom.Scale(dstImg, dstImg.Bounds(), croppedImg, croppedImg.Bounds(), draw.Over, nil)
62+
63+ // Create destination file
64+ dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, diskCacheFilePerm)
65+ if err != nil {
66+ return fmt.Errorf("create destination: %w", err)
67+ }
68+ defer func() { _ = dstFile.Close() }()
69+
70+ // Encode as PNG
71+ if err := png.Encode(dstFile, dstImg); err != nil {
72+ return fmt.Errorf("encode png: %w", err)
73+ }
74+
75+ return nil
76+}
77+
78+// cropImage returns a cropped portion of the source image.
79+func cropImage(src image.Image, rect image.Rectangle) image.Image {
80+ // If the source supports SubImage, use it for efficiency
81+ if subImager, ok := src.(interface {
82+ SubImage(r image.Rectangle) image.Image
83+ }); ok {
84+ return subImager.SubImage(rect)
85+ }
86+
87+ // Otherwise, copy pixels manually
88+ dst := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
89+ for y := 0; y < rect.Dy(); y++ {
90+ for x := 0; x < rect.Dx(); x++ {
91+ dst.Set(x, y, src.At(rect.Min.X+x, rect.Min.Y+y))
92+ }
93+ }
94+ return dst
95+}
M internal/infrastructure/favicon/service.go +40 -0
@@ -2,6 +2,7 @@ package favicon
22
33 import (
44 "context"
5+ "fmt"
56
67 "github.com/bnema/dumber/internal/logging"
78 )
@@ -109,3 +110,42 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
109110 s.cache.Set(domain, data)
110111 }
111112 }
113+
114+// DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
115+func (s *Service) DiskPathPNGSized(domain string, size int) string {
116+ return s.cache.DiskPathPNGSized(domain, size)
117+}
118+
119+// HasPNGSizedOnDisk checks if a sized PNG favicon exists on disk.
120+func (s *Service) HasPNGSizedOnDisk(domain string, size int) bool {
121+ return s.cache.HasPNGSizedOnDisk(domain, size)
122+}
123+
124+// EnsureSizedPNG creates a resized PNG from the original if it doesn't exist.
125+// This is used to generate normalized icons for dmenu/fuzzel.
126+func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) error {
127+ if domain == "" {
128+ return nil
129+ }
130+
131+ // Already exists?
132+ if s.HasPNGSizedOnDisk(domain, size) {
133+ return nil
134+ }
135+
136+ // Check source PNG exists
137+ srcPath := s.DiskPathPNG(domain)
138+ if srcPath == "" || !s.HasPNGOnDisk(domain) {
139+ return fmt.Errorf("source PNG not found for domain %s", domain)
140+ }
141+
142+ dstPath := s.DiskPathPNGSized(domain, size)
143+ if dstPath == "" {
144+ return fmt.Errorf("cannot determine destination path for domain %s", domain)
145+ }
146+
147+ log := logging.FromContext(ctx)
148+ log.Debug().Str("domain", domain).Int("size", size).Msg("creating sized favicon")
149+
150+ return ResizePNG(srcPath, dstPath, size)
151+}
@@ -2,6 +2,7 @@ package favicon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 6
6 "github.com/bnema/dumber/internal/logging"7 "github.com/bnema/dumber/internal/logging"
7 )8 )
@@ -109,3 +110,42 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
109 s.cache.Set(domain, data)110 s.cache.Set(domain, data)
110 }111 }
111 }112 }
113+
114+// DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
115+func (s *Service) DiskPathPNGSized(domain string, size int) string {
116+ return s.cache.DiskPathPNGSized(domain, size)
117+}
118+
119+// HasPNGSizedOnDisk checks if a sized PNG favicon exists on disk.
120+func (s *Service) HasPNGSizedOnDisk(domain string, size int) bool {
121+ return s.cache.HasPNGSizedOnDisk(domain, size)
122+}
123+
124+// EnsureSizedPNG creates a resized PNG from the original if it doesn't exist.
125+// This is used to generate normalized icons for dmenu/fuzzel.
126+func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) error {
127+ if domain == "" {
128+ return nil
129+ }
130+
131+ // Already exists?
132+ if s.HasPNGSizedOnDisk(domain, size) {
133+ return nil
134+ }
135+
136+ // Check source PNG exists
137+ srcPath := s.DiskPathPNG(domain)
138+ if srcPath == "" || !s.HasPNGOnDisk(domain) {
139+ return fmt.Errorf("source PNG not found for domain %s", domain)
140+ }
141+
142+ dstPath := s.DiskPathPNGSized(domain, size)
143+ if dstPath == "" {
144+ return fmt.Errorf("cannot determine destination path for domain %s", domain)
145+ }
146+
147+ log := logging.FromContext(ctx)
148+ log.Debug().Str("domain", domain).Int("size", size).Msg("creating sized favicon")
149+
150+ return ResizePNG(srcPath, dstPath, size)
151+}
M internal/ui/adapter/favicon.go +65 -2
@@ -63,6 +63,8 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
6363
6464 // Check texture cache first
6565 if texture := a.GetTexture(domain); texture != nil {
66+ // Ensure sized PNG exists for CLI tools (async, idempotent)
67+ go a.ensureSizedPNG(ctx, domain)
6668 callback(texture)
6769 return
6870 }
@@ -72,6 +74,8 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
7274 texture := bytesToTexture(data)
7375 if texture != nil {
7476 a.setTexture(domain, texture)
77+ // Ensure sized PNG exists for CLI tools (async, idempotent)
78+ go a.ensureSizedPNG(ctx, domain)
7579 callback(texture)
7680 return
7781 }
@@ -148,6 +152,8 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
148152 return
149153 }
150154
155+ log := logging.FromContext(ctx)
156+
151157 // Store in texture cache
152158 a.setTexture(domain, texture)
153159
@@ -155,7 +161,21 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
155161 // since GTK texture operations need to happen there
156162 pngPath := a.service.DiskPathPNG(domain)
157163 if pngPath != "" && !a.service.HasPNGOnDisk(domain) {
158- texture.SaveToPng(pngPath)
164+ if ok := texture.SaveToPng(pngPath); !ok {
165+ log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
166+ } else {
167+ log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
168+
169+ // Create normalized sized copy for dmenu/fuzzel (async)
170+ // The original PNG is now on disk, so this should succeed
171+ go func() {
172+ if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
173+ log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG after save")
174+ } else {
175+ log.Debug().Str("domain", domain).Msg("created sized PNG after save")
176+ }
177+ }()
178+ }
159179 }
160180
161181 // Ensure disk cache is populated (async, in background)
@@ -171,6 +191,7 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
171191
172192 // Also store under original URL domain if different
173193 if originURL != "" && originURL != currentURL {
194+ log := logging.FromContext(ctx)
174195 originDomain := domainurl.ExtractDomain(originURL)
175196 currentDomain := domainurl.ExtractDomain(currentURL)
176197 if originDomain != "" && originDomain != currentDomain {
@@ -179,7 +200,20 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
179200 // Export PNG for origin domain too
180201 pngPath := a.service.DiskPathPNG(originDomain)
181202 if pngPath != "" && !a.service.HasPNGOnDisk(originDomain) {
182- texture.SaveToPng(pngPath)
203+ if ok := texture.SaveToPng(pngPath); !ok {
204+ log.Warn().Str("domain", originDomain).Str("path", pngPath).Msg("failed to save origin favicon PNG")
205+ } else {
206+ log.Debug().Str("domain", originDomain).Str("path", pngPath).Msg("saved origin favicon PNG")
207+
208+ // Create normalized sized copy for dmenu/fuzzel (async)
209+ go func() {
210+ if err := a.service.EnsureSizedPNG(ctx, originDomain, favicon.NormalizedIconSize); err != nil {
211+ log.Warn().Err(err).Str("domain", originDomain).Msg("failed to create sized PNG for origin")
212+ } else {
213+ log.Debug().Str("domain", originDomain).Msg("created sized PNG for origin")
214+ }
215+ }()
216+ }
183217 }
184218
185219 go a.service.EnsureDiskCache(ctx, originDomain)
@@ -248,6 +282,35 @@ func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
248282 a.mu.Unlock()
249283 }
250284
285+// ensureSizedPNG ensures the sized PNG exists for CLI tools.
286+// This is idempotent - it only creates the file if it doesn't exist.
287+func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
288+ if domain == "" {
289+ return
290+ }
291+
292+ log := logging.FromContext(ctx)
293+
294+ hasPNG := a.service.HasPNGOnDisk(domain)
295+ hasSized := a.service.HasPNGSizedOnDisk(domain, favicon.NormalizedIconSize)
296+
297+ log.Debug().
298+ Str("domain", domain).
299+ Bool("has_png", hasPNG).
300+ Bool("has_sized", hasSized).
301+ Msg("checking sized PNG status")
302+
303+ // Only create if original PNG exists but sized version doesn't
304+ if hasPNG && !hasSized {
305+ log.Debug().Str("domain", domain).Msg("creating sized PNG")
306+ if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
307+ log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG")
308+ } else {
309+ log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
310+ }
311+ }
312+}
313+
251314 // bytesToTexture converts raw favicon bytes to a GDK texture.
252315 func bytesToTexture(data []byte) *gdk.Texture {
253316 if len(data) == 0 {
@@ -63,6 +63,8 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
63 63
64 // Check texture cache first64 // Check texture cache first
65 if texture := a.GetTexture(domain); texture != nil {65 if texture := a.GetTexture(domain); texture != nil {
66+ // Ensure sized PNG exists for CLI tools (async, idempotent)
67+ go a.ensureSizedPNG(ctx, domain)
66 callback(texture)68 callback(texture)
67 return69 return
68 }70 }
@@ -72,6 +74,8 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
72 texture := bytesToTexture(data)74 texture := bytesToTexture(data)
73 if texture != nil {75 if texture != nil {
74 a.setTexture(domain, texture)76 a.setTexture(domain, texture)
77+ // Ensure sized PNG exists for CLI tools (async, idempotent)
78+ go a.ensureSizedPNG(ctx, domain)
75 callback(texture)79 callback(texture)
76 return80 return
77 }81 }
@@ -148,6 +152,8 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
148 return152 return
149 }153 }
150 154
155+ log := logging.FromContext(ctx)
156+
151 // Store in texture cache157 // Store in texture cache
152 a.setTexture(domain, texture)158 a.setTexture(domain, texture)
153 159
@@ -155,7 +161,21 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
155 // since GTK texture operations need to happen there161 // since GTK texture operations need to happen there
156 pngPath := a.service.DiskPathPNG(domain)162 pngPath := a.service.DiskPathPNG(domain)
157 if pngPath != "" && !a.service.HasPNGOnDisk(domain) {163 if pngPath != "" && !a.service.HasPNGOnDisk(domain) {
158- texture.SaveToPng(pngPath)164+ if ok := texture.SaveToPng(pngPath); !ok {
165+ log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
166+ } else {
167+ log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
168+
169+ // Create normalized sized copy for dmenu/fuzzel (async)
170+ // The original PNG is now on disk, so this should succeed
171+ go func() {
172+ if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
173+ log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG after save")
174+ } else {
175+ log.Debug().Str("domain", domain).Msg("created sized PNG after save")
176+ }
177+ }()
178+ }
159 }179 }
160 180
161 // Ensure disk cache is populated (async, in background)181 // Ensure disk cache is populated (async, in background)
@@ -171,6 +191,7 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
171 191
172 // Also store under original URL domain if different192 // Also store under original URL domain if different
173 if originURL != "" && originURL != currentURL {193 if originURL != "" && originURL != currentURL {
194+ log := logging.FromContext(ctx)
174 originDomain := domainurl.ExtractDomain(originURL)195 originDomain := domainurl.ExtractDomain(originURL)
175 currentDomain := domainurl.ExtractDomain(currentURL)196 currentDomain := domainurl.ExtractDomain(currentURL)
176 if originDomain != "" && originDomain != currentDomain {197 if originDomain != "" && originDomain != currentDomain {
@@ -179,7 +200,20 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
179 // Export PNG for origin domain too200 // Export PNG for origin domain too
180 pngPath := a.service.DiskPathPNG(originDomain)201 pngPath := a.service.DiskPathPNG(originDomain)
181 if pngPath != "" && !a.service.HasPNGOnDisk(originDomain) {202 if pngPath != "" && !a.service.HasPNGOnDisk(originDomain) {
182- texture.SaveToPng(pngPath)203+ if ok := texture.SaveToPng(pngPath); !ok {
204+ log.Warn().Str("domain", originDomain).Str("path", pngPath).Msg("failed to save origin favicon PNG")
205+ } else {
206+ log.Debug().Str("domain", originDomain).Str("path", pngPath).Msg("saved origin favicon PNG")
207+
208+ // Create normalized sized copy for dmenu/fuzzel (async)
209+ go func() {
210+ if err := a.service.EnsureSizedPNG(ctx, originDomain, favicon.NormalizedIconSize); err != nil {
211+ log.Warn().Err(err).Str("domain", originDomain).Msg("failed to create sized PNG for origin")
212+ } else {
213+ log.Debug().Str("domain", originDomain).Msg("created sized PNG for origin")
214+ }
215+ }()
216+ }
183 }217 }
184 218
185 go a.service.EnsureDiskCache(ctx, originDomain)219 go a.service.EnsureDiskCache(ctx, originDomain)
@@ -248,6 +282,35 @@ func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
248 a.mu.Unlock()282 a.mu.Unlock()
249 }283 }
250 284
285+// ensureSizedPNG ensures the sized PNG exists for CLI tools.
286+// This is idempotent - it only creates the file if it doesn't exist.
287+func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
288+ if domain == "" {
289+ return
290+ }
291+
292+ log := logging.FromContext(ctx)
293+
294+ hasPNG := a.service.HasPNGOnDisk(domain)
295+ hasSized := a.service.HasPNGSizedOnDisk(domain, favicon.NormalizedIconSize)
296+
297+ log.Debug().
298+ Str("domain", domain).
299+ Bool("has_png", hasPNG).
300+ Bool("has_sized", hasSized).
301+ Msg("checking sized PNG status")
302+
303+ // Only create if original PNG exists but sized version doesn't
304+ if hasPNG && !hasSized {
305+ log.Debug().Str("domain", domain).Msg("creating sized PNG")
306+ if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
307+ log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG")
308+ } else {
309+ log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
310+ }
311+ }
312+}
313+
251 // bytesToTexture converts raw favicon bytes to a GDK texture.314 // bytesToTexture converts raw favicon bytes to a GDK texture.
252 func bytesToTexture(data []byte) *gdk.Texture {315 func bytesToTexture(data []byte) *gdk.Texture {
253 if len(data) == 0 {316 if len(data) == 0 {

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