1package cli
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "os"
8 "sort"
9 "strings"
10
11 "github.com/spf13/cobra"
12)
13
14// NewDmenuCmd creates the dmenu command for launcher integration
15func NewDmenuCmd() *cobra.Command {
16 cmd := &cobra.Command{
17 Use: "dmenu",
18 Short: "Run in dmenu mode for launcher integration",
19 Long: `Run in dmenu mode to integrate with rofi, dmenu, or other launchers.
20This mode reads from stdin and outputs selectable options to stdout.
21
22Usage with rofi:
23 dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
24
25Usage with dmenu:
26 dumber dmenu | dmenu -p "Browse: " | dumber dmenu --select`,
27 RunE: func(cmd *cobra.Command, _ []string) error {
28 selectFlag := cmd.Flag("select").Changed
29
30 cli, err := NewCLI()
31 if err != nil {
32 return fmt.Errorf("failed to initialize CLI: %w", err)
33 }
34 defer func() {
35 if closeErr := cli.Close(); closeErr != nil {
36 fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
37 }
38 }()
39
40 if selectFlag {
41 return handleSelection(cli)
42 }
43 return generateOptions(cli)
44 },
45 }
46
47 cmd.Flags().Bool("select", false, "Process selection from launcher (reads from stdin)")
48 return cmd
49}
50
51// generateOptions outputs all available options for the launcher
52func generateOptions(cli *CLI) error {
53 ctx := context.Background()
54
55 const maxHistoryEntries = 50
56 // Get history entries
57 history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
58 if err != nil {
59 return fmt.Errorf("failed to get history: %w", err)
60 }
61
62 // Get shortcuts
63 shortcuts, err := cli.Queries.GetShortcuts(ctx)
64 if err != nil {
65 return fmt.Errorf("failed to get shortcuts: %w", err)
66 }
67
68 // Generate options
69 options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
70
71 // Add direct URL input option
72 options = append(options, DmenuOption{
73 Display: "π Enter URL or search query...",
74 Value: "",
75 Type: "input",
76 Description: "Type any URL or search term",
77 })
78
79 // Add shortcuts with examples
80 for _, shortcut := range shortcuts {
81 desc := shortcut.Description.String
82 if !shortcut.Description.Valid {
83 desc = "Custom shortcut"
84 }
85
86 options = append(options, DmenuOption{
87 Display: fmt.Sprintf("π %s: (%s)", shortcut.Shortcut, desc),
88 Value: shortcut.Shortcut + ":",
89 Type: "shortcut",
90 Description: desc,
91 })
92 }
93
94 // Add history entries
95 for _, entry := range history {
96 title := entry.Url
97 if entry.Title.Valid && entry.Title.String != "" {
98 title = entry.Title.String
99 }
100
101 const maxDisplayLength = 80
102 // Truncate long titles/URLs for display
103 display := truncateString(title, maxDisplayLength)
104
105 options = append(options, DmenuOption{
106 Display: fmt.Sprintf("π %s", display),
107 Value: entry.Url,
108 Type: "history",
109 Description: entry.Url,
110 })
111 }
112
113 // Sort options: input first, then shortcuts, then history by visit count/recency
114 sort.Slice(options, func(i, j int) bool {
115 if options[i].Type != options[j].Type {
116 // Order: input, shortcut, history
117 typeOrder := map[string]int{"input": 0, "shortcut": 1, "history": 2} //nolint:mnd
118 return typeOrder[options[i].Type] < typeOrder[options[j].Type]
119 }
120
121 if options[i].Type == "history" && options[j].Type == "history" {
122 // History already sorted by recency from database
123 return i < j
124 }
125
126 return options[i].Display < options[j].Display
127 })
128
129 // Output options to stdout
130 for _, option := range options {
131 fmt.Println(option.Display)
132 }
133
134 return nil
135}
136
137// handleSelection processes the user's selection from the launcher
138func handleSelection(cli *CLI) error {
139 scanner := bufio.NewScanner(os.Stdin)
140 if !scanner.Scan() {
141 return fmt.Errorf("no input received")
142 }
143
144 selection := strings.TrimSpace(scanner.Text())
145 if selection == "" {
146 return fmt.Errorf("empty selection")
147 }
148
149 // Parse the selection to get the actual input
150 input := parseSelection(selection)
151
152 // If it's just the input prompt, read another line for the actual input
153 if input == "" || input == "Enter URL or search query..." {
154 fmt.Print("Enter URL or search query: ")
155 if !scanner.Scan() {
156 return fmt.Errorf("no input received")
157 }
158 input = strings.TrimSpace(scanner.Text())
159 }
160
161 if input == "" {
162 return fmt.Errorf("empty input")
163 }
164
165 // Browse the selected/entered URL
166 return browse(cli, input)
167}
168
169// DmenuOption represents a selectable option in dmenu mode
170type DmenuOption struct {
171 Display string
172 Value string
173 Type string // "input", "shortcut", "history"
174 Description string
175}
176
177// parseSelection extracts the actual URL/query from a dmenu selection
178func parseSelection(selection string) string {
179 // Remove emoji prefixes and clean up the selection
180 selection = strings.TrimSpace(selection)
181
182 // Handle different option types
183 if strings.HasPrefix(selection, "π ") {
184 // Input option selected
185 return ""
186 }
187
188 if strings.HasPrefix(selection, "π ") {
189 // Shortcut selected - extract the shortcut prefix
190 rest := strings.TrimPrefix(selection, "π ")
191 if idx := strings.Index(rest, ":"); idx > 0 {
192 return rest[:idx+1] // Return "g:" format
193 }
194 }
195
196 if strings.HasPrefix(selection, "π ") {
197 // History entry selected - this is the display title, need to extract URL
198 // For now, we'll assume the display is the URL or close enough
199 return strings.TrimPrefix(selection, "π ")
200 }
201
202 // Fallback: return the selection as-is
203 return selection
204}
205
206// truncateString truncates a string to the specified length with ellipsis
207func truncateString(s string, maxLen int) string {
208 if len(s) <= maxLen {
209 return s
210 }
211
212 const minEllipsisLength = 3
213 if maxLen <= minEllipsisLength {
214 return s[:maxLen]
215 }
216
217 return s[:maxLen-minEllipsisLength] + "..."
218}