1package cmd
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "os"
8 "os/exec"
9 "strings"
10
11 tea "github.com/charmbracelet/bubbletea"
12 "github.com/spf13/cobra"
13
14 "github.com/bnema/dumber/assets"
15 "github.com/bnema/dumber/internal/cli/model"
16 domainurl "github.com/bnema/dumber/internal/domain/url"
17 "github.com/bnema/dumber/internal/infrastructure/config"
18 "github.com/bnema/dumber/internal/infrastructure/favicon"
19 "github.com/bnema/dumber/internal/logging"
20)
21
22var (
23 dmenuInteractive bool
24 dmenuSelect bool
25 dmenuMax int
26)
27
28var dmenuCmd = &cobra.Command{
29 Use: "dmenu",
30 Short: "Launcher integration for rofi/fuzzel",
31 Long: `Output history for use with rofi, fuzzel, or other launchers.
32
33Default mode outputs entries for piping:
34 dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
35
36Interactive mode provides a built-in TUI fuzzy finder:
37 dumber dmenu --interactive`,
38 RunE: runDmenu,
39}
40
41func init() {
42 rootCmd.AddCommand(dmenuCmd)
43
44 dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")
45 dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")
46 dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)")
47}
48
49func runDmenu(_ *cobra.Command, _ []string) error {
50 app := GetApp()
51 if app == nil {
52 return fmt.Errorf("app not initialized")
53 }
54
55 // Selection mode: read URL from stdin and open it
56 if dmenuSelect {
57 return runDmenuSelect()
58 }
59
60 // Interactive TUI mode
61 if dmenuInteractive {
62 return runDmenuInteractive()
63 }
64
65 // Pipe mode: output entries for rofi/fuzzel
66 return runDmenuPipe()
67}
68
69// getDmenuConfig returns the dmenu configuration, with CLI flag overrides.
70func getDmenuConfig() config.DmenuConfig {
71 app := GetApp()
72 cfg := app.Config.Dmenu
73
74 // Override max if explicitly set via CLI flag
75 if dmenuMax > 0 {
76 cfg.MaxHistoryItems = dmenuMax
77 }
78
79 return cfg
80}
81
82// getFaviconPath returns the path to a cached PNG favicon for the given URL.
83// Returns empty string if the PNG favicon doesn't exist in cache.
84// Prefers sized (32x32) PNG for consistent display in fuzzel/rofi.
85// Will create the sized PNG on-demand if only the original exists.
86func getFaviconPath(rawURL string) string {
87 app := GetApp()
88 if app == nil || app.FaviconService == nil {
89 return ""
90 }
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
99 domain := domainurl.ExtractDomain(rawURL)
100 if domain == "" {
101 return ""
102 }
103
104 ctx := app.Ctx()
105 log := logging.FromContext(ctx)
106
107 // Check if sized PNG already exists
108 if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) {
109 return app.FaviconService.DiskPathPNGSized(domain, normalizedSize)
110 }
111
112 // If original PNG exists but sized doesn't, create it on-demand
113 if app.FaviconService.HasPNGOnDisk(domain) {
114 log.Debug().Str("domain", domain).Msg("creating sized PNG on-demand from dmenu")
115 if err := app.FaviconService.EnsureSizedPNG(ctx, domain, normalizedSize); err != nil {
116 log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG")
117 return ""
118 }
119 log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
120 return app.FaviconService.DiskPathPNGSized(domain, normalizedSize)
121 }
122
123 // No PNG at all for this domain
124 log.Debug().Str("domain", domain).Msg("no favicon PNG found for domain")
125 return ""
126}
127
128// runDmenuPipe outputs history entries for launcher consumption.
129func runDmenuPipe() error {
130 app := GetApp()
131 ctx := context.Background()
132 cfg := getDmenuConfig()
133
134 entries, err := app.SearchHistoryUC.GetRecent(ctx, cfg.MaxHistoryItems, 0)
135 if err != nil {
136 return fmt.Errorf("get history: %w", err)
137 }
138
139 // Output in rofi/fuzzel compatible format
140 // Display URL (stripped of scheme) so selection directly returns usable URL
141 for _, entry := range entries {
142 // Strip scheme from URL for cleaner display
143 display := entry.URL
144 display = strings.TrimPrefix(display, "https://")
145 display = strings.TrimPrefix(display, "http://")
146
147 // Truncate long URLs
148 const maxDisplayLen = 100
149 if len(display) > maxDisplayLen {
150 display = display[:maxDisplayLen-3] + "..."
151 }
152
153 // Check if we have a favicon
154 faviconPath := getFaviconPath(entry.URL)
155
156 // Format: "URL\0icon\x1f/path\n" (simple, selection returns URL directly)
157 // Note: Icon size is controlled by rofi theme (element-icon { size: X; })
158 if faviconPath != "" {
159 fmt.Printf("%s\x00icon\x1f%s\n", display, faviconPath)
160 } else {
161 fmt.Println(display)
162 }
163 }
164
165 return nil
166}
167
168// runDmenuSelect processes a selection from stdin.
169func runDmenuSelect() error {
170 reader := bufio.NewReader(os.Stdin)
171 line, err := reader.ReadString('\n')
172 if err != nil {
173 return fmt.Errorf("read selection: %w", err)
174 }
175
176 line = strings.TrimSpace(line)
177 if line == "" {
178 return nil // No selection
179 }
180
181 url := parseSelection(line)
182 if url != "" {
183 return openInBrowser(url)
184 }
185
186 return nil
187}
188
189// parseSelection extracts the URL from a dmenu selection.
190// Selection is the URL (stripped of scheme) as displayed by runDmenuPipe,
191// or a bang shortcut like "!g query" for search.
192func parseSelection(selection string) string {
193 selection = strings.TrimSpace(selection)
194
195 // Strip metadata if present (format: "url\0icon\x1f...")
196 if nullIndex := strings.Index(selection, "\x00"); nullIndex > 0 {
197 selection = selection[:nullIndex]
198 }
199
200 selection = strings.TrimSpace(selection)
201 if selection == "" {
202 return ""
203 }
204
205 // Use BuildSearchURL to handle bang shortcuts, URLs, and search queries
206 app := GetApp()
207 if app != nil {
208 return domainurl.BuildSearchURL(selection, app.Config.ShortcutURLs(), app.Config.DefaultSearchEngine)
209 }
210
211 // Fallback: add https:// scheme if no app context
212 if !strings.Contains(selection, "://") {
213 selection = "https://" + selection
214 }
215
216 return selection
217}
218
219// runDmenuInteractive runs the interactive TUI dmenu.
220func runDmenuInteractive() error {
221 app := GetApp()
222
223 m := model.NewDmenuModel(app.Theme, app.SearchHistoryUC)
224
225 p := tea.NewProgram(m, tea.WithAltScreen())
226 finalModel, err := p.Run()
227 if err != nil {
228 return err
229 }
230
231 // If user selected an entry, open it
232 dm, ok := finalModel.(model.DmenuModel)
233 if ok && dm.SelectedURL() != "" {
234 return openInBrowser(dm.SelectedURL())
235 }
236
237 return nil
238}
239
240// openInBrowser opens a URL in dumber browser.
241func openInBrowser(url string) error {
242 // Use the current executable to open in dumber
243 executable, err := os.Executable()
244 if err != nil {
245 // Fallback to searching PATH
246 executable = "dumber"
247 }
248 cmd := newCommand(executable, "browse", url)
249 return cmd.Start()
250}
251
252// newCommand creates a command (abstracted for testing).
253var newCommand = func(name string, args ...string) interface{ Start() error } {
254 return &commandWrapper{name: name, args: args}
255}
256
257type commandWrapper struct {
258 name string
259 args []string
260}
261
262func (c *commandWrapper) Start() error {
263 cmd := exec.Command(c.name, c.args...)
264 return cmd.Start()
265}