summaryrefslogtreecommitdiff
path: root/src/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'src/main.go')
-rw-r--r--src/main.go1344
1 files changed, 1103 insertions, 241 deletions
diff --git a/src/main.go b/src/main.go
index bab289c..df5ecef 100644
--- a/src/main.go
+++ b/src/main.go
@@ -4,10 +4,14 @@ import (
4 "encoding/json" 4 "encoding/json"
5 "fmt" 5 "fmt"
6 "log" 6 "log"
7 "math"
8 "math/rand"
7 "os" 9 "os"
8 "os/exec" 10 "os/exec"
9 "path/filepath" 11 "path/filepath"
12 "sort"
10 "strings" 13 "strings"
14 "time"
11 15
12 "github.com/gotk3/gotk3/gdk" 16 "github.com/gotk3/gotk3/gdk"
13 "github.com/gotk3/gotk3/glib" 17 "github.com/gotk3/gotk3/glib"
@@ -15,8 +19,11 @@ import (
15) 19)
16 20
17const ( 21const (
18 appTitle = "kjagave" 22 appTitle = "kjagave"
19 appVersion = "20251221-0050" 23 appVersion = "20260315-0200"
24 maxHistoryLen = 250
25 cardImageW = 160
26 cardImageH = 132
20) 27)
21 28
22type SavedColor struct { 29type SavedColor struct {
@@ -24,325 +31,859 @@ type SavedColor struct {
24 Name string `json:"name"` 31 Name string `json:"name"`
25} 32}
26 33
34type AppConfig struct {
35 Favorites []SavedColor `json:"favorites"`
36 LastColor string `json:"lastColor"`
37 LastScheme string `json:"lastScheme"`
38 Palette string `json:"palette"`
39}
40
41type SwatchCard struct {
42 button *gtk.Button
43 image *gtk.Image
44 label *gtk.Label
45 hex string
46 rgb string
47 hsv string
48}
49
27type App struct { 50type App struct {
28 window *gtk.Window 51 window *gtk.Window
52 css *gtk.CssProvider
53
29 colorButton *gtk.ColorButton 54 colorButton *gtk.ColorButton
55 hexEntry *gtk.Entry
56 schemeCombo *gtk.ComboBoxText
57 paletteCombo *gtk.ComboBoxText
58
59 historyBackBtn *gtk.Button
60 historyFwdBtn *gtk.Button
61 lightenBtn *gtk.Button
62 darkenBtn *gtk.Button
63 saturateBtn *gtk.Button
64 desaturateBtn *gtk.Button
65
66 swatchCards []SwatchCard
67
68 paletteGrid *gtk.Grid
69 paletteScroll *gtk.ScrolledWindow
70
71 favoritesStore *gtk.ListStore
72 favoritesView *gtk.TreeView
73 renameFavBtn *gtk.Button
74 removeFavBtn *gtk.Button
75 clearFavBtn *gtk.Button
76 selectedIter *gtk.TreeIter
77
30 currentColor *gdk.RGBA 78 currentColor *gdk.RGBA
31 listStore *gtk.ListStore 79 currentHex string
32 treeView *gtk.TreeView 80
33 deleteBtn *gtk.Button 81 savedColors []SavedColor
34 savedColors []SavedColor 82 history []string
35 configFile string 83 historyPos int
36 selectedIter *gtk.TreeIter 84
85 configFile string
86 config AppConfig
87
88 suppressColorSet bool
89 rng *rand.Rand
90}
91
92var schemeNames = []string{
93 "Triads",
94 "Complements",
95 "Split Complements",
96 "Tetrads",
97 "Analogous",
98 "Monochromatic",
99}
100
101var paletteNames = []string{
102 "Web-safe (legacy)",
103 "Material Design",
104 "Tailwind CSS",
105 "Flat UI",
106 "Pastel",
107 "Nord",
108 "Dracula",
109 "Solarized",
110 "Gruvbox",
111 "One Dark",
112 "Monokai",
113 "KiJiSH Dark Pastel Terminal",
37} 114}
38 115
39func main() { 116func main() {
40 gtk.Init(nil) 117 gtk.Init(nil)
41 118
42 configDir := filepath.Join(os.Getenv("HOME"), ".config") 119 configDir := filepath.Join(os.Getenv("HOME"), ".config")
43 os.MkdirAll(configDir, 0755) 120 _ = os.MkdirAll(configDir, 0755)
44 121
45 app := &App{ 122 app := &App{
46 configFile: filepath.Join(configDir, "kjagave.json"), 123 configFile: filepath.Join(configDir, "kjagave.json"),
124 rng: rand.New(rand.NewSource(time.Now().UnixNano())),
125 historyPos: -1,
47 } 126 }
48 127
49 app.loadColors() 128 app.loadConfig()
50 app.createUI() 129 app.createUI()
51 app.populateList() 130 app.refreshFavoritesView()
131 app.restoreStartupState()
132 app.populatePaletteGrid()
133 app.updateSchemePreview()
134 app.updateActionStates()
52 135
53 gtk.Main() 136 gtk.Main()
54} 137}
55 138
56func (app *App) createUI() { 139func (app *App) createUI() {
57 var err error 140 var err error
58
59 // main window
60 app.window, err = gtk.WindowNew(gtk.WINDOW_TOPLEVEL) 141 app.window, err = gtk.WindowNew(gtk.WINDOW_TOPLEVEL)
61 if err != nil { 142 if err != nil {
62 log.Fatal("Unable to create window:", err) 143 log.Fatal("Unable to create window:", err)
63 } 144 }
64 app.window.SetTitle(appTitle) 145 app.window.SetTitle(appTitle)
65 app.window.SetDefaultSize(550, 450) 146 app.window.SetDefaultSize(640, 480)
66 app.window.SetResizable(false) 147 app.window.Connect("destroy", func() {
67 app.window.Connect("destroy", gtk.MainQuit) 148 app.saveConfig()
68 149 gtk.MainQuit()
69 // vertical box 150 })
70 mainBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 10)
71 mainBox.SetMarginTop(15)
72 mainBox.SetMarginBottom(15)
73 mainBox.SetMarginStart(15)
74 mainBox.SetMarginEnd(15)
75 151
76 // color selection area 152 root, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
77 colorBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 10) 153 root.SetMarginTop(2)
78 colorBox.SetHAlign(gtk.ALIGN_CENTER) 154 root.SetMarginBottom(2)
155 root.SetMarginStart(2)
156 root.SetMarginEnd(2)
157
158 menuBar := app.buildMenuBar()
159 root.PackStart(menuBar, false, false, 0)
160 app.initCompactButtonCSS()
161
162 toolbar, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 3)
163 app.historyBackBtn, _ = gtk.ButtonNewWithLabel("Back")
164 app.setButtonIcon(app.historyBackBtn, "go-previous")
165 app.historyBackBtn.Connect("clicked", func() { app.navigateHistory(-1) })
166 toolbar.PackStart(app.historyBackBtn, false, false, 0)
167
168 app.historyFwdBtn, _ = gtk.ButtonNewWithLabel("Forward")
169 app.setButtonIcon(app.historyFwdBtn, "go-next")
170 app.historyFwdBtn.Connect("clicked", func() { app.navigateHistory(1) })
171 toolbar.PackStart(app.historyFwdBtn, false, false, 0)
172
173 randomBtn, _ := gtk.ButtonNewWithLabel("Random")
174 app.setButtonIcon(randomBtn, "view-refresh")
175 randomBtn.Connect("clicked", func() { app.randomizeColor() })
176 toolbar.PackStart(randomBtn, false, false, 0)
177
178 app.lightenBtn, _ = gtk.ButtonNewWithLabel("Lighten")
179 app.setButtonIcon(app.lightenBtn, "go-up")
180 app.lightenBtn.Connect("clicked", func() { app.adjustSV(0, 5) })
181 toolbar.PackStart(app.lightenBtn, false, false, 0)
182
183 app.darkenBtn, _ = gtk.ButtonNewWithLabel("Darken")
184 app.setButtonIcon(app.darkenBtn, "go-down")
185 app.darkenBtn.Connect("clicked", func() { app.adjustSV(0, -5) })
186 toolbar.PackStart(app.darkenBtn, false, false, 0)
187
188 app.saturateBtn, _ = gtk.ButtonNewWithLabel("Saturate")
189 app.setButtonIcon(app.saturateBtn, "list-add")
190 app.saturateBtn.Connect("clicked", func() { app.adjustSV(5, 0) })
191 toolbar.PackStart(app.saturateBtn, false, false, 0)
192
193 app.desaturateBtn, _ = gtk.ButtonNewWithLabel("Desaturate")
194 app.setButtonIcon(app.desaturateBtn, "list-remove")
195 app.desaturateBtn.Connect("clicked", func() { app.adjustSV(-5, 0) })
196 toolbar.PackStart(app.desaturateBtn, false, false, 0)
197
198 pasteBtn, _ := gtk.ButtonNewWithLabel("Paste")
199 app.setButtonIcon(pasteBtn, "edit-paste")
200 pasteBtn.Connect("clicked", func() { app.pasteColorFromClipboard() })
201 toolbar.PackStart(pasteBtn, false, false, 0)
79 202
80 label, _ := gtk.LabelNew("Select Color:") 203 aboutBtn, _ := gtk.ButtonNewWithLabel("About")
81 colorBox.PackStart(label, false, false, 0) 204 app.setButtonIcon(aboutBtn, "help-about")
205 aboutBtn.Connect("clicked", func() { app.onAboutClicked() })
206 toolbar.PackStart(aboutBtn, false, false, 0)
207
208 root.PackStart(toolbar, false, false, 0)
209
210 schemeRow, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 2)
211 app.swatchCards = make([]SwatchCard, 0, 4)
212 for i := 0; i < 4; i++ {
213 card := app.newSwatchCard()
214 cardIdx := i
215 card.button.Connect("button-press-event", func(_ *gtk.Button, ev *gdk.Event) bool {
216 if ev == nil {
217 return false
218 }
219 evBtn := gdk.EventButtonNewFromEvent(ev)
220 if evBtn == nil || evBtn.Button() != 3 {
221 return false
222 }
223 app.showSwatchContextMenu(cardIdx, ev)
224 return true
225 })
226 card.button.Connect("clicked", func() {
227 hex := app.swatchCards[cardIdx].hex
228 if hex == "" {
229 return
230 }
231 rgba := gdk.NewRGBA()
232 if !rgba.Parse(hex) {
233 return
234 }
235 app.setCurrentColor(rgba, true)
236 })
237 app.swatchCards = append(app.swatchCards, card)
238 schemeRow.PackStart(card.button, true, true, 0)
239 }
240 root.PackStart(schemeRow, false, false, 0)
82 241
83 // initialize color 242 controlRow, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6)
84 app.currentColor = gdk.NewRGBA() 243 app.currentColor = gdk.NewRGBA()
85 app.currentColor.Parse("#69BAA7") 244 app.currentColor.Parse("#0066FF")
86 245
87 app.colorButton, err = gtk.ColorButtonNewWithRGBA(app.currentColor) 246 app.colorButton, err = gtk.ColorButtonNewWithRGBA(app.currentColor)
88 if err != nil { 247 if err != nil {
89 log.Fatal("Unable to create color button:", err) 248 log.Fatal("Unable to create color button:", err)
90 } 249 }
91 app.colorButton.SetUseAlpha(true) 250 app.colorButton.SetUseAlpha(false)
92 app.colorButton.SetTitle("Choose a Color") 251 app.colorButton.SetTitle("Pick a Color")
93 app.colorButton.Connect("color-set", func() { 252 app.colorButton.Connect("color-set", func() {
94 app.currentColor = app.colorButton.GetRGBA() 253 if app.suppressColorSet {
254 return
255 }
256 app.setCurrentColor(app.colorButton.GetRGBA(), true)
95 }) 257 })
258 controlRow.PackStart(app.colorButton, false, false, 0)
96 259
97 colorBox.PackStart(app.colorButton, false, false, 0) 260 app.schemeCombo, _ = gtk.ComboBoxTextNew()
98 261 for _, schemeName := range schemeNames {
99 hexEntry, _ := gtk.EntryNew() 262 app.schemeCombo.AppendText(schemeName)
100 hexEntry.SetEditable(false) 263 }
101 hexEntry.SetWidthChars(10) 264 app.schemeCombo.SetActive(0)
102 hexEntry.SetText(rgbaToHex(app.currentColor)) 265 app.schemeCombo.Connect("changed", func() {
103 colorBox.PackStart(hexEntry, false, false, 0) 266 app.updateSchemePreview()
104 267 app.saveConfig()
105 // color picker button 268 })
106 pickerBtn, _ := gtk.ButtonNewWithLabel("Pick from Screen") 269 controlRow.PackStart(app.schemeCombo, false, false, 0)
107 pickerBtn.Connect("clicked", func() { 270
108 if color, err := app.pickColorFromScreen(); err == nil { 271 app.hexEntry, _ = gtk.EntryNew()
109 app.colorButton.SetRGBA(color) 272 app.hexEntry.SetWidthChars(11)
110 app.currentColor = color 273 app.hexEntry.Connect("activate", func() { app.applyHexEntry() })
111 hexEntry.SetText(rgbaToHex(color)) 274 controlRow.PackStart(app.hexEntry, false, false, 0)
275
276 pickBtn, _ := gtk.ButtonNewWithLabel("Pick from Screen")
277 app.setButtonIcon(pickBtn, "color-select")
278 pickBtn.Connect("clicked", func() {
279 clr, err := app.pickColorFromScreen()
280 if err == nil {
281 app.setCurrentColor(clr, true)
112 } 282 }
113 }) 283 })
114 colorBox.PackStart(pickerBtn, false, false, 0) 284 controlRow.PackStart(pickBtn, false, false, 0)
115 285
116 // bump hex entry when color changes 286 copyBtn, _ := gtk.ButtonNewWithLabel("Copy")
117 app.colorButton.Connect("color-set", func() { 287 app.setButtonIcon(copyBtn, "edit-copy")
118 app.currentColor = app.colorButton.GetRGBA() 288 copyBtn.Connect("clicked", func() {
119 hexEntry.SetText(rgbaToHex(app.currentColor)) 289 clipboard, _ := gtk.ClipboardGet(gdk.SELECTION_CLIPBOARD)
290 clipboard.SetText(app.currentHex)
291 })
292 controlRow.PackStart(copyBtn, false, false, 0)
293
294 root.PackStart(controlRow, false, false, 0)
295
296 lower, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 4)
297
298 paletteFrame, _ := gtk.FrameNew("Palette")
299 paletteBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
300 paletteBox.SetMarginTop(2)
301 paletteBox.SetMarginBottom(2)
302 paletteBox.SetMarginStart(2)
303 paletteBox.SetMarginEnd(2)
304
305 app.paletteScroll, _ = gtk.ScrolledWindowNew(nil, nil)
306 app.paletteScroll.SetPolicy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
307 app.paletteScroll.SetSizeRequest(430, 150)
308 app.paletteGrid, _ = gtk.GridNew()
309 app.paletteGrid.SetRowSpacing(0)
310 app.paletteGrid.SetColumnSpacing(0)
311 app.paletteScroll.Add(app.paletteGrid)
312 paletteBox.PackStart(app.paletteScroll, true, true, 0)
313
314 app.paletteCombo, _ = gtk.ComboBoxTextNew()
315 for _, name := range paletteNames {
316 app.paletteCombo.AppendText(name)
317 }
318 app.paletteCombo.SetActive(0)
319 app.paletteCombo.Connect("changed", func() {
320 app.populatePaletteGrid()
321 app.saveConfig()
120 }) 322 })
323 paletteBox.PackStart(app.paletteCombo, false, false, 0)
324 paletteFrame.Add(paletteBox)
325 lower.PackStart(paletteFrame, true, true, 0)
326
327 favFrame, _ := gtk.FrameNew("Favorites")
328 favBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
329 favBox.SetMarginTop(2)
330 favBox.SetMarginBottom(2)
331 favBox.SetMarginStart(2)
332 favBox.SetMarginEnd(2)
333
334 favScroll, _ := gtk.ScrolledWindowNew(nil, nil)
335 favScroll.SetPolicy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
336 favScroll.SetSizeRequest(230, 180)
337
338 app.favoritesStore, _ = gtk.ListStoreNew(gdk.PixbufGetType(), glib.TYPE_STRING, glib.TYPE_STRING)
339 app.favoritesView, _ = gtk.TreeViewNew()
340 app.favoritesView.SetModel(app.favoritesStore)
341 app.favoritesView.SetHeadersVisible(false)
342
343 favCol, _ := gtk.TreeViewColumnNew()
344 favPix, _ := gtk.CellRendererPixbufNew()
345 favCol.PackStart(favPix, false)
346 favCol.AddAttribute(favPix, "pixbuf", 0)
347 favHexText, _ := gtk.CellRendererTextNew()
348 favCol.PackStart(favHexText, true)
349 favCol.AddAttribute(favHexText, "text", 1)
350 app.favoritesView.AppendColumn(favCol)
351
352 favNameText, _ := gtk.CellRendererTextNew()
353 favNameCol, _ := gtk.TreeViewColumnNewWithAttribute("", favNameText, "text", 2)
354 app.favoritesView.AppendColumn(favNameCol)
355
356 favSel, _ := app.favoritesView.GetSelection()
357 favSel.SetMode(gtk.SELECTION_SINGLE)
358 favSel.Connect("changed", app.onFavoriteSelectionChanged)
359 app.favoritesView.Connect("row-activated", func() {
360 app.renameSelectedFavorite()
361 })
362
363 favScroll.Add(app.favoritesView)
364 favBox.PackStart(favScroll, true, true, 0)
365
366 favBtns, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 4)
367 addFavBtn, _ := gtk.ButtonNewWithLabel("+")
368 app.setButtonIcon(addFavBtn, "list-add")
369 addFavBtn.Connect("clicked", func() { app.addCurrentToFavorites() })
370 favBtns.PackStart(addFavBtn, true, true, 0)
371
372 app.removeFavBtn, _ = gtk.ButtonNewWithLabel("-")
373 app.setButtonIcon(app.removeFavBtn, "list-remove")
374 app.removeFavBtn.Connect("clicked", func() { app.removeSelectedFavorite() })
375 favBtns.PackStart(app.removeFavBtn, true, true, 0)
376
377 app.renameFavBtn, _ = gtk.ButtonNewWithLabel("Rename")
378 app.setButtonIcon(app.renameFavBtn, "document-edit")
379 app.renameFavBtn.Connect("clicked", func() { app.renameSelectedFavorite() })
380 favBtns.PackStart(app.renameFavBtn, true, true, 0)
381
382 app.clearFavBtn, _ = gtk.ButtonNewWithLabel("Clear")
383 app.setButtonIcon(app.clearFavBtn, "edit-clear")
384 app.clearFavBtn.Connect("clicked", func() {
385 app.savedColors = nil
386 app.refreshFavoritesView()
387 app.saveConfig()
388 })
389 favBtns.PackStart(app.clearFavBtn, true, true, 0)
121 390
122 mainBox.PackStart(colorBox, false, false, 0) 391 favBox.PackStart(favBtns, false, false, 0)
123 392 favFrame.Add(favBox)
124 expander, _ := gtk.ExpanderNew("Saved Colors") 393 lower.PackStart(favFrame, false, false, 0)
125 expander.SetExpanded(true)
126 expanderBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 5)
127 expanderBox.SetMarginTop(5)
128 expanderBox.SetMarginBottom(5)
129 btnBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
130 btnBox.SetHAlign(gtk.ALIGN_END)
131 app.deleteBtn, _ = gtk.ButtonNewWithLabel("Delete")
132 app.deleteBtn.SetSensitive(false)
133 app.deleteBtn.Connect("clicked", app.onDeleteClicked)
134 btnBox.PackStart(app.deleteBtn, false, false, 0)
135 saveBtn, _ := gtk.ButtonNewWithLabel("Save...")
136 saveBtn.Connect("clicked", app.onSaveClicked)
137 btnBox.PackStart(saveBtn, false, false, 0)
138 expanderBox.PackStart(btnBox, false, false, 0)
139
140 // treeview
141 scrolled, _ := gtk.ScrolledWindowNew(nil, nil)
142 scrolled.SetPolicy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
143 scrolled.SetSizeRequest(-1, 180)
144
145 app.listStore, _ = gtk.ListStoreNew(gdk.PixbufGetType(), glib.TYPE_STRING, glib.TYPE_STRING)
146 app.treeView, _ = gtk.TreeViewNew()
147 app.treeView.SetModel(app.listStore)
148 app.treeView.SetHeadersVisible(true)
149
150 // color column with swatch
151 colorCol, _ := gtk.TreeViewColumnNew()
152 colorCol.SetTitle("Color")
153 colorCol.SetSortColumnID(1)
154
155 pixbufRenderer, _ := gtk.CellRendererPixbufNew()
156 colorCol.PackStart(pixbufRenderer, false)
157 colorCol.AddAttribute(pixbufRenderer, "pixbuf", 0)
158
159 textRenderer, _ := gtk.CellRendererTextNew()
160 colorCol.PackStart(textRenderer, true)
161 colorCol.AddAttribute(textRenderer, "text", 1)
162
163 app.treeView.AppendColumn(colorCol)
164
165 // name column
166 nameRenderer, _ := gtk.CellRendererTextNew()
167 nameCol, _ := gtk.TreeViewColumnNewWithAttribute("Name", nameRenderer, "text", 2)
168 nameCol.SetSortColumnID(2)
169 app.treeView.AppendColumn(nameCol)
170
171 selection, _ := app.treeView.GetSelection()
172 selection.SetMode(gtk.SELECTION_SINGLE)
173 selection.Connect("changed", app.onSelectionChanged)
174
175 scrolled.Add(app.treeView)
176 expanderBox.PackStart(scrolled, true, true, 0)
177 expander.Add(expanderBox)
178 mainBox.PackStart(expander, true, true, 0)
179
180 // bottom button box
181 bottomBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
182 bottomBox.SetHAlign(gtk.ALIGN_END)
183
184 copyBtn, _ := gtk.ButtonNewWithLabel("Copy to Clipboard")
185 copyBtn.Connect("clicked", app.onCopyClicked)
186 bottomBox.PackStart(copyBtn, false, false, 0)
187 394
188 aboutBtn, _ := gtk.ButtonNewWithLabel("About") 395 root.PackStart(lower, true, true, 0)
189 aboutBtn.Connect("clicked", app.onAboutClicked)
190 bottomBox.PackStart(aboutBtn, false, false, 0)
191 396
192 mainBox.PackStart(bottomBox, false, false, 0) 397 status, _ := gtk.LabelNew("Choose a color and a scheme type")
398 status.SetHAlign(gtk.ALIGN_START)
399 root.PackStart(status, false, false, 0)
193 400
194 app.window.Add(mainBox) 401 app.window.Add(root)
195 app.window.ShowAll() 402 app.window.ShowAll()
196} 403}
197 404
198func (app *App) populateList() { 405func (app *App) buildMenuBar() *gtk.MenuBar {
199 app.listStore.Clear() 406 menuBar, _ := gtk.MenuBarNew()
407
408 fileTop, _ := gtk.MenuItemNewWithLabel("File")
409 fileMenu, _ := gtk.MenuNew()
410 random, _ := gtk.MenuItemNewWithLabel("Random")
411 random.Connect("activate", func() { app.randomizeColor() })
412 fileMenu.Append(random)
413 quit, _ := gtk.MenuItemNewWithLabel("Quit")
414 quit.Connect("activate", func() {
415 app.saveConfig()
416 gtk.MainQuit()
417 })
418 fileMenu.Append(quit)
419 fileTop.SetSubmenu(fileMenu)
420 menuBar.Append(fileTop)
421
422 editTop, _ := gtk.MenuItemNewWithLabel("Edit")
423 editMenu, _ := gtk.MenuNew()
424 paste, _ := gtk.MenuItemNewWithLabel("Paste")
425 paste.Connect("activate", func() { app.pasteColorFromClipboard() })
426 editMenu.Append(paste)
427 editTop.SetSubmenu(editMenu)
428 menuBar.Append(editTop)
429
430 favTop, _ := gtk.MenuItemNewWithLabel("Favorites")
431 favMenu, _ := gtk.MenuNew()
432 add, _ := gtk.MenuItemNewWithLabel("Add Current")
433 add.Connect("activate", func() { app.addCurrentToFavorites() })
434 favMenu.Append(add)
435 rename, _ := gtk.MenuItemNewWithLabel("Rename Selected")
436 rename.Connect("activate", func() { app.renameSelectedFavorite() })
437 favMenu.Append(rename)
438 favTop.SetSubmenu(favMenu)
439 menuBar.Append(favTop)
440
441 helpTop, _ := gtk.MenuItemNewWithLabel("Help")
442 helpMenu, _ := gtk.MenuNew()
443 about, _ := gtk.MenuItemNewWithLabel("About")
444 about.Connect("activate", func() { app.onAboutClicked() })
445 helpMenu.Append(about)
446 helpTop.SetSubmenu(helpMenu)
447 menuBar.Append(helpTop)
448
449 return menuBar
450}
200 451
201 for _, color := range app.savedColors { 452func (app *App) initCompactButtonCSS() {
202 pixbuf := app.createColorSwatch(color.Hex) 453 app.css, _ = gtk.CssProviderNew()
203 iter := app.listStore.Append() 454 css := "button { padding: 1px 4px; min-height: 0; min-width: 0; } .palette-swatch { padding: 0; border-width: 0; border-radius: 0; } .swatch-overlay-label { text-shadow: none; }"
204 app.listStore.Set(iter, []int{0, 1, 2}, []interface{}{pixbuf, color.Hex, color.Name}) 455 _ = app.css.LoadFromData(css)
456 if screen, err := gdk.ScreenGetDefault(); err == nil && screen != nil {
457 gtk.AddProviderForScreen(screen, app.css, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
205 } 458 }
206} 459}
207 460
208func (app *App) createColorSwatch(hexColor string) *gdk.Pixbuf { 461func (app *App) setButtonIcon(btn *gtk.Button, iconName string) {
209 pixbuf, err := gdk.PixbufNew(gdk.COLORSPACE_RGB, false, 8, 16, 14) 462 img, err := gtk.ImageNewFromIconName(iconName, gtk.ICON_SIZE_BUTTON)
210 if err != nil { 463 if err != nil || img == nil {
211 return nil 464 return
465 }
466 if label, err := btn.GetLabel(); err == nil && strings.TrimSpace(label) != "" {
467 btn.SetTooltipText(label)
468 btn.SetLabel("")
212 } 469 }
470 btn.SetImage(img)
471 btn.SetAlwaysShowImage(true)
472}
213 473
214 rgba := gdk.NewRGBA() 474func (app *App) newSwatchCard() SwatchCard {
215 rgba.Parse(hexColor) 475 button, _ := gtk.ButtonNew()
476 button.SetSizeRequest(166, 138)
477
478 vbox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
479 vbox.SetMarginTop(1)
480 vbox.SetMarginBottom(1)
481 vbox.SetMarginStart(1)
482 vbox.SetMarginEnd(1)
483
484 overlay, _ := gtk.OverlayNew()
485 overlay.SetHExpand(true)
486 overlay.SetVExpand(true)
487
488 image, _ := gtk.ImageNew()
489 image.SetFromPixbuf(solidPixbuf("#000000", cardImageW, cardImageH))
490 overlay.Add(image)
491
492 label, _ := gtk.LabelNew("")
493 label.SetJustify(gtk.JUSTIFY_CENTER)
494 label.SetHAlign(gtk.ALIGN_CENTER)
495 label.SetVAlign(gtk.ALIGN_CENTER)
496 if ctx, err := label.GetStyleContext(); err == nil {
497 ctx.AddClass("swatch-overlay-label")
498 }
499 overlay.AddOverlay(label)
500 vbox.PackStart(overlay, true, true, 0)
501
502 button.Add(vbox)
503
504 return SwatchCard{button: button, image: image, label: label}
505}
216 506
217 r := uint32(rgba.GetRed() * 255) 507func (app *App) setCurrentColor(rgba *gdk.RGBA, pushHistory bool) {
218 g := uint32(rgba.GetGreen() * 255) 508 hex := rgbaToHex(rgba)
219 b := uint32(rgba.GetBlue() * 255) 509 app.currentColor = rgba
510 app.currentHex = hex
511 app.hexEntry.SetText(hex)
512
513 app.suppressColorSet = true
514 app.colorButton.SetRGBA(rgba)
515 app.suppressColorSet = false
220 516
221 pixels := pixbuf.GetPixels() 517 if pushHistory {
222 rowstride := pixbuf.GetRowstride() 518 app.pushHistory(hex)
223 nChannels := pixbuf.GetNChannels() 519 }
224 520
225 for y := 0; y < 14; y++ { 521 app.config.LastColor = hex
226 for x := 0; x < 16; x++ { 522 app.config.LastScheme = app.activeSchemeName()
227 offset := y*rowstride + x*nChannels 523 app.config.Palette = app.activePaletteName()
228 pixels[offset] = byte(r) 524 app.updateSchemePreview()
229 pixels[offset+1] = byte(g) 525 app.updateActionStates()
230 pixels[offset+2] = byte(b) 526 app.saveConfig()
527}
528
529func (app *App) updateSchemePreview() {
530 colors := generateScheme(app.currentColor, app.activeSchemeName())
531 for i := 0; i < len(app.swatchCards); i++ {
532 if i >= len(colors) {
533 app.swatchCards[i].hex = ""
534 app.swatchCards[i].rgb = ""
535 app.swatchCards[i].hsv = ""
536 app.swatchCards[i].button.Hide()
537 continue
231 } 538 }
539 app.swatchCards[i].button.Show()
540 app.swatchCards[i].button.SetSensitive(true)
541 hex := rgbaToHex(colors[i])
542 h, s, v := rgbToHSV(colors[i])
543 r := int(math.Round(colors[i].GetRed() * 255))
544 g := int(math.Round(colors[i].GetGreen() * 255))
545 b := int(math.Round(colors[i].GetBlue() * 255))
546 textColor := "#F5F5F5"
547 if luminance(colors[i]) > 0.53 {
548 textColor = "#111111"
549 }
550 rgbText := fmt.Sprintf("rgb(%d, %d, %d)", r, g, b)
551 hsvText := fmt.Sprintf("hsv(%d, %d, %d)", int(h), int(s), int(v))
552 app.swatchCards[i].hex = hex
553 app.swatchCards[i].rgb = rgbText
554 app.swatchCards[i].hsv = hsvText
555 app.swatchCards[i].image.SetFromPixbuf(solidPixbuf(hex, cardImageW, cardImageH))
556 app.swatchCards[i].label.SetMarkup(fmt.Sprintf("<span foreground=\"%s\" size=\"9000\"><b>%s</b>\n%s\n%s</span>", textColor, hex, rgbText, hsvText))
232 } 557 }
558}
233 559
234 return pixbuf 560func (app *App) copyTextToClipboard(text string) {
561 if strings.TrimSpace(text) == "" {
562 return
563 }
564 clipboard, _ := gtk.ClipboardGet(gdk.SELECTION_CLIPBOARD)
565 clipboard.SetText(text)
235} 566}
236 567
237func (app *App) onSelectionChanged(selection *gtk.TreeSelection) { 568func (app *App) showSwatchContextMenu(cardIdx int, ev *gdk.Event) {
238 model, iter, ok := selection.GetSelected() 569 if cardIdx < 0 || cardIdx >= len(app.swatchCards) {
239 if !ok { 570 return
240 app.deleteBtn.SetSensitive(false) 571 }
241 app.selectedIter = nil 572 card := app.swatchCards[cardIdx]
573 if card.hex == "" {
242 return 574 return
243 } 575 }
576 app.showColorContextMenu(card.hex, card.rgb, card.hsv, ev)
577}
244 578
245 app.selectedIter = iter 579func (app *App) showColorContextMenu(hex, rgbText, hsvText string, ev *gdk.Event) {
246 app.deleteBtn.SetSensitive(true) 580 if strings.TrimSpace(hex) == "" {
581 return
582 }
247 583
248 value, _ := model.ToTreeModel().GetValue(iter, 1) 584 menu, _ := gtk.MenuNew()
249 hexColor, _ := value.GetString() 585 copyHex, _ := gtk.MenuItemNewWithLabel("Copy HEX")
586 copyHex.Connect("activate", func() {
587 app.copyTextToClipboard(hex)
588 })
589 menu.Append(copyHex)
250 590
251 rgba := gdk.NewRGBA() 591 copyHSV, _ := gtk.MenuItemNewWithLabel("Copy HSV")
252 rgba.Parse(hexColor) 592 copyHSV.Connect("activate", func() {
253 app.colorButton.SetRGBA(rgba) 593 app.copyTextToClipboard(hsvText)
254 app.currentColor = rgba 594 })
595 menu.Append(copyHSV)
596
597 copyRGB, _ := gtk.MenuItemNewWithLabel("Copy RGB (RGV)")
598 copyRGB.Connect("activate", func() {
599 app.copyTextToClipboard(rgbText)
600 })
601 menu.Append(copyRGB)
602
603 menu.ShowAll()
604 menu.PopupAtPointer(ev)
255} 605}
256 606
257func (app *App) onSaveClicked() { 607func colorStringsFromHex(hex string) (string, string) {
258 dialog, _ := gtk.DialogNew() 608 rgba := gdk.NewRGBA()
259 dialog.SetTitle("Save Color") 609 if !rgba.Parse(hex) {
260 dialog.SetTransientFor(app.window) 610 return "", ""
261 dialog.SetModal(true) 611 }
262 dialog.SetDefaultSize(300, -1) 612 r := int(math.Round(rgba.GetRed() * 255))
613 g := int(math.Round(rgba.GetGreen() * 255))
614 b := int(math.Round(rgba.GetBlue() * 255))
615 h, s, v := rgbToHSV(rgba)
616 return fmt.Sprintf("rgb(%d, %d, %d)", r, g, b), fmt.Sprintf("hsv(%d, %d, %d)", int(h), int(s), int(v))
617}
263 618
264 box, _ := dialog.GetContentArea() 619func (app *App) applyHexEntry() {
265 box.SetSpacing(10) 620 text, _ := app.hexEntry.GetText()
266 box.SetMarginTop(10) 621 text = strings.TrimSpace(text)
267 box.SetMarginBottom(10) 622 if text == "" {
268 box.SetMarginStart(10) 623 return
269 box.SetMarginEnd(10) 624 }
625 if !strings.HasPrefix(text, "#") {
626 text = "#" + text
627 }
628 rgba := gdk.NewRGBA()
629 if !rgba.Parse(text) {
630 app.hexEntry.SetText(app.currentHex)
631 return
632 }
633 app.setCurrentColor(rgba, true)
634}
270 635
271 // get current color 636func (app *App) randomizeColor() {
272 hexColor := rgbaToHex(app.currentColor) 637 r := app.rng.Intn(256)
638 g := app.rng.Intn(256)
639 b := app.rng.Intn(256)
640 rgba := gdk.NewRGBA()
641 rgba.SetRed(float64(r) / 255.0)
642 rgba.SetGreen(float64(g) / 255.0)
643 rgba.SetBlue(float64(b) / 255.0)
644 rgba.SetAlpha(1)
645 app.setCurrentColor(rgba, true)
646}
273 647
274 label, _ := gtk.LabelNew(fmt.Sprintf("Color: %s", hexColor)) 648func (app *App) adjustSV(ds, dv float64) {
275 box.PackStart(label, false, false, 0) 649 h, s, v := rgbToHSV(app.currentColor)
650 s = clamp(s+ds, 0, 100)
651 v = clamp(v+dv, 0, 100)
652 app.setCurrentColor(hsvToRGBA(h, s, v), true)
653}
276 654
277 entryLabel, _ := gtk.LabelNew("Color Name:") 655func (app *App) pasteColorFromClipboard() {
278 entryLabel.SetHAlign(gtk.ALIGN_START) 656 clipboard, _ := gtk.ClipboardGet(gdk.SELECTION_CLIPBOARD)
279 box.PackStart(entryLabel, false, false, 0) 657 text, err := clipboard.WaitForText()
658 if err != nil {
659 return
660 }
661 text = strings.TrimSpace(text)
662 if text == "" {
663 return
664 }
665 if !strings.HasPrefix(text, "#") {
666 text = "#" + text
667 }
668 rgba := gdk.NewRGBA()
669 if rgba.Parse(text) {
670 app.setCurrentColor(rgba, true)
671 }
672}
280 673
281 entry, _ := gtk.EntryNew() 674func (app *App) pushHistory(hex string) {
282 entry.SetText("Untitled") 675 if app.historyPos >= 0 && app.historyPos < len(app.history) && app.history[app.historyPos] == hex {
283 entry.SetActivatesDefault(true) 676 return
284 box.PackStart(entry, false, false, 0) 677 }
678 if app.historyPos+1 < len(app.history) {
679 app.history = app.history[:app.historyPos+1]
680 }
681 app.history = append(app.history, hex)
682 if len(app.history) > maxHistoryLen {
683 over := len(app.history) - maxHistoryLen
684 app.history = app.history[over:]
685 }
686 app.historyPos = len(app.history) - 1
687}
285 688
286 dialog.AddButton("Cancel", gtk.RESPONSE_CANCEL) 689func (app *App) navigateHistory(step int) {
287 okBtn, _ := dialog.AddButton("OK", gtk.RESPONSE_OK) 690 if len(app.history) == 0 {
288 okBtn.SetCanDefault(true) 691 return
289 okBtn.GrabDefault() 692 }
693 next := app.historyPos + step
694 if next < 0 || next >= len(app.history) {
695 return
696 }
697 rgba := gdk.NewRGBA()
698 if !rgba.Parse(app.history[next]) {
699 return
700 }
701 app.historyPos = next
702 app.setCurrentColor(rgba, false)
703}
290 704
291 dialog.ShowAll() 705func (app *App) updateActionStates() {
706 _, s, v := rgbToHSV(app.currentColor)
707 app.historyBackBtn.SetSensitive(app.historyPos > 0)
708 app.historyFwdBtn.SetSensitive(app.historyPos >= 0 && app.historyPos < len(app.history)-1)
709 app.lightenBtn.SetSensitive(v < 100)
710 app.darkenBtn.SetSensitive(v > 5)
711 app.saturateBtn.SetSensitive(s < 100)
712 app.desaturateBtn.SetSensitive(s > 5)
713 app.renameFavBtn.SetSensitive(app.selectedIter != nil)
714 app.removeFavBtn.SetSensitive(app.selectedIter != nil)
715 app.clearFavBtn.SetSensitive(len(app.savedColors) > 0)
716}
292 717
293 response := dialog.Run() 718func (app *App) activeSchemeName() string {
294 if response == gtk.RESPONSE_OK { 719 idx := app.schemeCombo.GetActive()
295 text, _ := entry.GetText() 720 if idx < 0 || idx >= len(schemeNames) {
296 app.savedColors = append([]SavedColor{{Hex: hexColor, Name: text}}, app.savedColors...) 721 return schemeNames[0]
297 app.saveColors()
298 app.populateList()
299 } 722 }
723 return schemeNames[idx]
724}
300 725
301 dialog.Destroy() 726func (app *App) activePaletteName() string {
727 idx := app.paletteCombo.GetActive()
728 if idx < 0 || idx >= len(paletteNames) {
729 return paletteNames[0]
730 }
731 return paletteNames[idx]
302} 732}
303 733
304func (app *App) onDeleteClicked() { 734func (app *App) onFavoriteSelectionChanged(selection *gtk.TreeSelection) {
305 if app.selectedIter == nil { 735 model, iter, ok := selection.GetSelected()
736 if !ok {
737 app.selectedIter = nil
738 app.updateActionStates()
306 return 739 return
307 } 740 }
741 app.selectedIter = iter
742 value, _ := model.ToTreeModel().GetValue(iter, 1)
743 hex, _ := value.GetString()
744 rgba := gdk.NewRGBA()
745 if rgba.Parse(hex) {
746 app.setCurrentColor(rgba, true)
747 }
748 app.updateActionStates()
749}
308 750
309 model := app.listStore.ToTreeModel() 751func (app *App) populatePaletteGrid() {
310 value, _ := model.GetValue(app.selectedIter, 1) 752 children := app.paletteGrid.GetChildren()
311 hexColor, _ := value.GetString() 753 children.Foreach(func(item interface{}) {
754 if widget, ok := item.(*gtk.Widget); ok {
755 app.paletteGrid.Remove(widget)
756 }
757 })
758
759 colors := paletteByName(app.activePaletteName())
760 cols := 24
761 for i, hex := range colors {
762 btn, _ := gtk.ButtonNew()
763 btn.SetRelief(gtk.RELIEF_NONE)
764 btn.SetCanFocus(false)
765 btn.SetSizeRequest(16, 11)
766 if ctx, err := btn.GetStyleContext(); err == nil {
767 ctx.AddClass("palette-swatch")
768 }
769 img, _ := gtk.ImageNewFromPixbuf(solidPixbuf(hex, 16, 11))
770 btn.Add(img)
771 h := hex
772 btn.Connect("button-press-event", func(_ *gtk.Button, ev *gdk.Event) bool {
773 if ev == nil {
774 return false
775 }
776 evBtn := gdk.EventButtonNewFromEvent(ev)
777 if evBtn == nil || evBtn.Button() != 3 {
778 return false
779 }
780 rgbText, hsvText := colorStringsFromHex(h)
781 app.showColorContextMenu(h, rgbText, hsvText, ev)
782 return true
783 })
784 btn.Connect("clicked", func() {
785 rgba := gdk.NewRGBA()
786 if rgba.Parse(h) {
787 app.setCurrentColor(rgba, true)
788 }
789 })
790 app.paletteGrid.Attach(btn, i%cols, i/cols, 1, 1)
791 }
792 app.paletteGrid.ShowAll()
793}
312 794
313 // remove from saved colors 795func (app *App) addCurrentToFavorites() {
314 for i, color := range app.savedColors { 796 for _, item := range app.savedColors {
315 if color.Hex == hexColor { 797 if strings.EqualFold(item.Hex, app.currentHex) {
798 return
799 }
800 }
801 app.savedColors = append([]SavedColor{{Hex: app.currentHex, Name: app.currentHex}}, app.savedColors...)
802 app.refreshFavoritesView()
803 app.saveConfig()
804}
805
806func (app *App) removeSelectedFavorite() {
807 if app.selectedIter == nil {
808 return
809 }
810 value, _ := app.favoritesStore.GetValue(app.selectedIter, 1)
811 hex, _ := value.GetString()
812 for i, c := range app.savedColors {
813 if strings.EqualFold(c.Hex, hex) {
316 app.savedColors = append(app.savedColors[:i], app.savedColors[i+1:]...) 814 app.savedColors = append(app.savedColors[:i], app.savedColors[i+1:]...)
317 break 815 break
318 } 816 }
319 } 817 }
320
321 app.saveColors()
322 app.populateList()
323 app.deleteBtn.SetSensitive(false)
324 app.selectedIter = nil 818 app.selectedIter = nil
819 app.refreshFavoritesView()
820 app.saveConfig()
325} 821}
326 822
327func (app *App) onCopyClicked() { 823func (app *App) renameSelectedFavorite() {
328 hexColor := rgbaToHex(app.currentColor) 824 if app.selectedIter == nil {
329 825 return
330 clipboard, _ := gtk.ClipboardGet(gdk.SELECTION_CLIPBOARD) 826 }
331 clipboard.SetText(hexColor) 827 vHex, _ := app.favoritesStore.GetValue(app.selectedIter, 1)
828 hex, _ := vHex.GetString()
829 vName, _ := app.favoritesStore.GetValue(app.selectedIter, 2)
830 currentName, _ := vName.GetString()
332 831
333 dialog := gtk.MessageDialogNew(app.window, gtk.DIALOG_MODAL, gtk.MESSAGE_INFO, 832 dialog, _ := gtk.DialogNew()
334 gtk.BUTTONS_OK, fmt.Sprintf("Color %s copied to clipboard!", hexColor)) 833 dialog.SetTitle("Rename Color")
335 dialog.Run() 834 dialog.SetTransientFor(app.window)
835 dialog.SetModal(true)
836 box, _ := dialog.GetContentArea()
837 box.SetMarginTop(10)
838 box.SetMarginBottom(10)
839 box.SetMarginStart(10)
840 box.SetMarginEnd(10)
841 box.SetSpacing(6)
842 label, _ := gtk.LabelNew("Enter a new name:")
843 label.SetHAlign(gtk.ALIGN_START)
844 box.PackStart(label, false, false, 0)
845 entry, _ := gtk.EntryNew()
846 entry.SetText(currentName)
847 entry.SetActivatesDefault(true)
848 box.PackStart(entry, false, false, 0)
849 dialog.AddButton("Cancel", gtk.RESPONSE_CANCEL)
850 okBtn, _ := dialog.AddButton("OK", gtk.RESPONSE_OK)
851 okBtn.SetCanDefault(true)
852 okBtn.GrabDefault()
853 dialog.ShowAll()
854 if dialog.Run() == gtk.RESPONSE_OK {
855 newName, _ := entry.GetText()
856 newName = strings.TrimSpace(newName)
857 if newName != "" {
858 for i := range app.savedColors {
859 if strings.EqualFold(app.savedColors[i].Hex, hex) {
860 app.savedColors[i].Name = newName
861 break
862 }
863 }
864 app.refreshFavoritesView()
865 app.saveConfig()
866 }
867 }
336 dialog.Destroy() 868 dialog.Destroy()
337} 869}
338 870
871func (app *App) refreshFavoritesView() {
872 app.favoritesStore.Clear()
873 for _, color := range app.savedColors {
874 iter := app.favoritesStore.Append()
875 app.favoritesStore.Set(iter, []int{0, 1, 2}, []interface{}{solidPixbuf(color.Hex, 16, 14), strings.ToUpper(color.Hex), color.Name})
876 }
877 app.updateActionStates()
878}
879
339func (app *App) onAboutClicked() { 880func (app *App) onAboutClicked() {
340 dialog, _ := gtk.AboutDialogNew() 881 dialog, _ := gtk.AboutDialogNew()
341 dialog.SetTransientFor(app.window) 882 dialog.SetTransientFor(app.window)
342 dialog.SetProgramName(appTitle) 883 dialog.SetProgramName(appTitle)
343 dialog.SetVersion(appVersion) 884 dialog.SetVersion(appVersion)
344 dialog.SetComments("a color picker inspired by agave, but only with the features kj_sh604 actually used") 885 dialog.SetComments("Agave-inspired GTK color scheme tool")
345 dialog.SetAuthors([]string{"kj_sh604"}) 886 dialog.SetAuthors([]string{"kj_sh604", "Agave inspiration: Jonathon Jongsma"})
346 dialog.SetLicense("BSD Zero Clause License (0-clause BSD)") 887 dialog.SetLicense("BSD Zero Clause License (0-clause BSD)")
347 dialog.SetLogoIconName("applications-graphics") 888 dialog.SetLogoIconName("applications-graphics")
348 dialog.Run() 889 dialog.Run()
@@ -350,63 +891,384 @@ func (app *App) onAboutClicked() {
350} 891}
351 892
352func (app *App) pickColorFromScreen() (*gdk.RGBA, error) { 893func (app *App) pickColorFromScreen() (*gdk.RGBA, error) {
353 // use xcolor for x11 color picking
354 cmd := exec.Command("xcolor", "--format", "hex") 894 cmd := exec.Command("xcolor", "--format", "hex")
355 output, err := cmd.Output() 895 output, err := cmd.Output()
356 if err != nil { 896 if err != nil {
357 // fallback to grabc
358 cmd = exec.Command("grabc") 897 cmd = exec.Command("grabc")
359 output, err = cmd.Output() 898 output, err = cmd.Output()
360 if err != nil { 899 if err != nil {
361 dialog := gtk.MessageDialogNew(app.window, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, 900 dialog := gtk.MessageDialogNew(app.window, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR,
362 gtk.BUTTONS_OK, "Color picker not found. Please install 'xcolor'") 901 gtk.BUTTONS_OK, "Color picker not found. Please install xcolor or grabc")
363 dialog.Run() 902 dialog.Run()
364 dialog.Destroy() 903 dialog.Destroy()
365 return nil, err 904 return nil, err
366 } 905 }
367 } 906 }
368 907
369 hexColor := strings.TrimSpace(string(output)) 908 hex := strings.TrimSpace(string(output))
370 if !strings.HasPrefix(hexColor, "#") { 909 if !strings.HasPrefix(hex, "#") {
371 hexColor = "#" + hexColor 910 hex = "#" + hex
372 } 911 }
373
374 rgba := gdk.NewRGBA() 912 rgba := gdk.NewRGBA()
375 if !rgba.Parse(hexColor) { 913 if !rgba.Parse(hex) {
376 return nil, fmt.Errorf("invalid color format: %s", hexColor) 914 return nil, fmt.Errorf("invalid color format: %s", hex)
377 } 915 }
378
379 return rgba, nil 916 return rgba, nil
380} 917}
381 918
382func (app *App) loadColors() { 919func (app *App) loadConfig() {
383 data, err := os.ReadFile(app.configFile) 920 data, err := os.ReadFile(app.configFile)
384 if err != nil { 921 if err != nil {
385 app.savedColors = []SavedColor{} 922 app.savedColors = []SavedColor{}
923 app.config = AppConfig{Favorites: []SavedColor{}, LastColor: "#0066FF", LastScheme: "Triads", Palette: "Web-safe colors"}
386 return 924 return
387 } 925 }
388 926
389 if err := json.Unmarshal(data, &app.savedColors); err != nil { 927 var cfg AppConfig
390 log.Printf("Error loading colors: %v", err) 928 if err := json.Unmarshal(data, &cfg); err == nil && (cfg.LastColor != "" || len(cfg.Favorites) > 0) {
391 app.savedColors = []SavedColor{} 929 app.config = cfg
930 app.savedColors = append([]SavedColor(nil), cfg.Favorites...)
931 if app.savedColors == nil {
932 app.savedColors = []SavedColor{}
933 }
934 if app.config.LastColor == "" {
935 app.config.LastColor = "#0066FF"
936 }
937 if app.config.LastScheme == "" {
938 app.config.LastScheme = "Triads"
939 }
940 if app.config.Palette == "" {
941 app.config.Palette = "Web-safe colors"
942 }
943 return
944 }
945
946 var legacy []SavedColor
947 if err := json.Unmarshal(data, &legacy); err == nil {
948 app.savedColors = legacy
949 app.config = AppConfig{Favorites: legacy, LastColor: "#0066FF", LastScheme: "Triads", Palette: "Web-safe colors"}
950 return
392 } 951 }
952
953 app.savedColors = []SavedColor{}
954 app.config = AppConfig{Favorites: []SavedColor{}, LastColor: "#0066FF", LastScheme: "Triads", Palette: "Web-safe colors"}
393} 955}
394 956
395func (app *App) saveColors() { 957func (app *App) saveConfig() {
396 data, err := json.MarshalIndent(app.savedColors, "", " ") 958 app.config.Favorites = app.savedColors
959 if app.currentHex != "" {
960 app.config.LastColor = app.currentHex
961 }
962 if app.schemeCombo != nil {
963 app.config.LastScheme = app.activeSchemeName()
964 }
965 if app.paletteCombo != nil {
966 app.config.Palette = app.activePaletteName()
967 }
968
969 data, err := json.MarshalIndent(app.config, "", " ")
397 if err != nil { 970 if err != nil {
398 log.Printf("Error marshaling colors: %v", err) 971 log.Printf("Error marshaling config: %v", err)
399 return 972 return
400 } 973 }
401
402 if err := os.WriteFile(app.configFile, data, 0644); err != nil { 974 if err := os.WriteFile(app.configFile, data, 0644); err != nil {
403 log.Printf("Error saving colors: %v", err) 975 log.Printf("Error saving config: %v", err)
976 }
977}
978
979func (app *App) restoreStartupState() {
980 rgba := gdk.NewRGBA()
981 if app.config.LastColor != "" && rgba.Parse(app.config.LastColor) {
982 app.currentColor = rgba
983 }
984
985 for i, name := range schemeNames {
986 if name == app.config.LastScheme {
987 app.schemeCombo.SetActive(i)
988 break
989 }
990 }
991 for i, name := range paletteNames {
992 if name == app.config.Palette {
993 app.paletteCombo.SetActive(i)
994 break
995 }
996 }
997
998 app.setCurrentColor(app.currentColor, true)
999}
1000
1001func generateScheme(base *gdk.RGBA, schemeName string) []*gdk.RGBA {
1002 h, s, v := rgbToHSV(base)
1003 mk := func(hue float64) *gdk.RGBA {
1004 return hsvToRGBA(wrapHue(hue), s, v)
1005 }
1006
1007 switch schemeName {
1008 case "Complements":
1009 return []*gdk.RGBA{hsvToRGBA(h, s, v), mk(h + 180)}
1010 case "Split Complements":
1011 offset := 360.0 / 15.0
1012 return []*gdk.RGBA{hsvToRGBA(h, s, v), mk(h + 180 - offset), mk(h + 180 + offset)}
1013 case "Tetrads":
1014 offset := 90.0
1015 return []*gdk.RGBA{hsvToRGBA(h, s, v), mk(h + offset), mk(h + 180), mk(h + 180 + offset)}
1016 case "Analogous":
1017 offset := 360.0 / 12.0
1018 return []*gdk.RGBA{mk(h - offset), hsvToRGBA(h, s, v), mk(h + offset)}
1019 case "Monochromatic":
1020 c0 := hsvToRGBA(h, s, v)
1021 c1 := hsvToRGBA(h, s, v)
1022 c2 := hsvToRGBA(h, s, v)
1023 if s < 10 {
1024 c1 = hsvToRGBA(h, math.Mod(s+33, 100), v)
1025 c2 = hsvToRGBA(h, math.Mod(s+66, 100), v)
1026 } else {
1027 c1 = hsvToRGBA(h, s, math.Mod(v+33, 100))
1028 c2 = hsvToRGBA(h, s, math.Mod(v+66, 100))
1029 }
1030 out := []*gdk.RGBA{c0, c1, c2}
1031 sort.Slice(out, func(i, j int) bool { return luminance(out[i]) < luminance(out[j]) })
1032 return out
1033 case "Triads":
1034 fallthrough
1035 default:
1036 offset := 120.0
1037 return []*gdk.RGBA{hsvToRGBA(h, s, v), mk(h + offset), mk(h - offset)}
1038 }
1039}
1040
1041func paletteByName(name string) []string {
1042 switch name {
1043 case "Web-safe (legacy)":
1044 vals := []int{0x00, 0x33, 0x66, 0x99, 0xCC, 0xFF}
1045 colors := make([]string, 0, 216)
1046 for _, r := range vals {
1047 for _, g := range vals {
1048 for _, b := range vals {
1049 colors = append(colors, fmt.Sprintf("#%02X%02X%02X", r, g, b))
1050 }
1051 }
1052 }
1053 return colors
1054 case "Material Design":
1055 return []string{
1056 "#F44336", "#E91E63", "#9C27B0", "#673AB7", "#3F51B5", "#2196F3",
1057 "#03A9F4", "#00BCD4", "#009688", "#4CAF50", "#8BC34A", "#CDDC39",
1058 "#FFEB3B", "#FFC107", "#FF9800", "#FF5722", "#795548", "#9E9E9E",
1059 "#607D8B", "#000000", "#FFFFFF", "#EF5350", "#EC407A", "#AB47BC",
1060 "#7E57C2", "#5C6BC0", "#42A5F5", "#29B6F6", "#26C6DA", "#26A69A",
1061 "#66BB6A", "#9CCC65", "#D4E157", "#FFEE58", "#FFCA28", "#FFA726",
1062 "#FF7043", "#8D6E63", "#BDBDBD", "#78909C", "#212121", "#FAFAFA",
1063 "#C62828", "#AD1457", "#6A1B9A", "#4527A0", "#283593", "#1565C0",
1064 }
1065 case "Tailwind CSS":
1066 return []string{
1067 "#EF4444", "#F97316", "#F59E0B", "#EAB308", "#84CC16", "#22C55E",
1068 "#10B981", "#14B8A6", "#06B6D4", "#0EA5E9", "#3B82F6", "#6366F1",
1069 "#8B5CF6", "#A855F7", "#D946EF", "#EC4899", "#F43F5E", "#64748B",
1070 "#DC2626", "#EA580C", "#D97706", "#CA8A04", "#65A30D", "#16A34A",
1071 "#059669", "#0D9488", "#0891B2", "#0284C7", "#2563EB", "#4F46E5",
1072 "#7C3AED", "#9333EA", "#C026D3", "#DB2777", "#E11D48", "#475569",
1073 "#991B1B", "#9A3412", "#92400E", "#854D0E", "#3F6212", "#14532D",
1074 "#064E3B", "#134E4A", "#164E63", "#075985", "#1E3A8A", "#312E81",
1075 }
1076 case "Flat UI":
1077 return []string{
1078 "#1ABC9C", "#16A085", "#2ECC71", "#27AE60", "#3498DB", "#2980B9",
1079 "#9B59B6", "#8E44AD", "#34495E", "#2C3E50", "#F1C40F", "#F39C12",
1080 "#E67E22", "#D35400", "#E74C3C", "#C0392B", "#ECF0F1", "#BDC3C7",
1081 "#95A5A6", "#7F8C8D", "#52B3D9", "#E8F8F5", "#D5F4E6", "#D6EAF8",
1082 "#E8DAEF", "#FADBD8", "#F9E79F", "#FAD7A0", "#F5B7B1", "#D7DBDD",
1083 }
1084 case "Pastel":
1085 return []string{
1086 "#FFB3BA", "#FFDFBA", "#FFFFBA", "#BAFFC9", "#BAE1FF", "#E0BBE4",
1087 "#FFDFD3", "#FEC8D8", "#D5AAFF", "#B4F8C8", "#A0E7E5", "#FFAEBC",
1088 "#FBE7C6", "#B4F8C8", "#A0C4FF", "#BDB2FF", "#FFC6FF", "#FDFFB6",
1089 "#CAFFBF", "#9BF6FF", "#A0C4FF", "#BDB2FF", "#FFC6FF", "#FFFFFC",
1090 "#FFD6A5", "#FDFFB6", "#CAFFBF", "#A8E6CF", "#FFD3B6", "#FFAAA5",
1091 }
1092 case "Nord":
1093 return []string{
1094 "#2E3440", "#3B4252", "#434C5E", "#4C566A", "#D8DEE9", "#E5E9F0",
1095 "#ECEFF4", "#8FBCBB", "#88C0D0", "#81A1C1", "#5E81AC", "#BF616A",
1096 "#D08770", "#EBCB8B", "#A3BE8C", "#B48EAD", "#4C566A", "#434C5E",
1097 "#3B4252", "#2E3440", "#ECEFF4", "#E5E9F0", "#D8DEE9", "#88C0D0",
1098 }
1099 case "Dracula":
1100 return []string{
1101 "#282A36", "#44475A", "#F8F8F2", "#6272A4", "#8BE9FD", "#50FA7B",
1102 "#FFB86C", "#FF79C6", "#BD93F9", "#FF5555", "#F1FA8C", "#21222C",
1103 "#191A21", "#6272A4", "#B45BCF", "#4D4F68", "#626483", "#62D6E8",
1104 "#EA51B2", "#EBFF87", "#00F769", "#B45BCF", "#7081D0", "#A1EFE4",
1105 }
1106 case "Solarized":
1107 return []string{
1108 "#002B36", "#073642", "#586E75", "#657B83", "#839496", "#93A1A1",
1109 "#EEE8D5", "#FDF6E3", "#B58900", "#CB4B16", "#DC322F", "#D33682",
1110 "#6C71C4", "#268BD2", "#2AA198", "#859900", "#002B36", "#073642",
1111 "#586E75", "#657B83", "#839496", "#93A1A1", "#EEE8D5", "#FDF6E3",
1112 }
1113 case "Gruvbox":
1114 return []string{
1115 "#282828", "#CC241D", "#98971A", "#D79921", "#458588", "#B16286",
1116 "#689D6A", "#A89984", "#928374", "#FB4934", "#B8BB26", "#FABD2F",
1117 "#83A598", "#D3869B", "#8EC07C", "#EBDBB2", "#FBF1C7", "#3C3836",
1118 "#504945", "#665C54", "#7C6F64", "#D65D0E", "#FE8019", "#BDAE93",
1119 }
1120 case "One Dark":
1121 return []string{
1122 "#282C34", "#ABB2BF", "#E06C75", "#D19A66", "#E5C07B", "#98C379",
1123 "#56B6C2", "#61AFEF", "#C678DD", "#BE5046", "#3B4048", "#4B5263",
1124 "#545862", "#565C64", "#5C6370", "#636D83", "#828997", "#2C323C",
1125 "#353B45", "#3E4451", "#4F5666", "#5F697A", "#6B7587", "#979EAB",
1126 }
1127 case "Monokai":
1128 return []string{
1129 "#272822", "#F8F8F2", "#F92672", "#E6DB74", "#A6E22E", "#66D9EF",
1130 "#AE81FF", "#FD971F", "#75715E", "#49483E", "#3E3D32", "#F8F8F0",
1131 "#F5F4F1", "#A59F85", "#FD5FF0", "#F4BF75", "#FFF59D", "#CFCFC2",
1132 "#A1EFE4", "#FFE792", "#CC6633", "#778899", "#9D550F", "#E69F66",
1133 }
1134 case "KiJiSH Dark Pastel Terminal":
1135 return []string{
1136 "#2C2C2C", "#DCDCDC", "#3F3F3F", "#D67979", "#60B48A", "#DFAF8F",
1137 "#9AB8D7", "#DC8CC3", "#8CD0D3", "#DCDCDC", "#709080", "#DCA3A3",
1138 "#72D5A3", "#F0DFAF", "#94BFF3", "#EC93D3", "#93E0E3", "#FFFFFF",
1139 }
1140 default:
1141 vals := []int{0x00, 0x33, 0x66, 0x99, 0xCC, 0xFF}
1142 colors := make([]string, 0, 216)
1143 for _, r := range vals {
1144 for _, g := range vals {
1145 for _, b := range vals {
1146 colors = append(colors, fmt.Sprintf("#%02X%02X%02X", r, g, b))
1147 }
1148 }
1149 }
1150 return colors
404 } 1151 }
405} 1152}
406 1153
1154func solidPixbuf(hex string, width, height int) *gdk.Pixbuf {
1155 pb, err := gdk.PixbufNew(gdk.COLORSPACE_RGB, false, 8, width, height)
1156 if err != nil {
1157 return nil
1158 }
1159 rgba := gdk.NewRGBA()
1160 rgba.Parse(hex)
1161 r := byte(rgba.GetRed() * 255)
1162 g := byte(rgba.GetGreen() * 255)
1163 b := byte(rgba.GetBlue() * 255)
1164
1165 pixels := pb.GetPixels()
1166 rowstride := pb.GetRowstride()
1167 channels := pb.GetNChannels()
1168 for y := 0; y < height; y++ {
1169 for x := 0; x < width; x++ {
1170 off := y*rowstride + x*channels
1171 pixels[off] = r
1172 pixels[off+1] = g
1173 pixels[off+2] = b
1174 }
1175 }
1176 return pb
1177}
1178
1179func rgbToHSV(rgba *gdk.RGBA) (float64, float64, float64) {
1180 r := rgba.GetRed()
1181 g := rgba.GetGreen()
1182 b := rgba.GetBlue()
1183
1184 mx := math.Max(r, math.Max(g, b))
1185 mn := math.Min(r, math.Min(g, b))
1186 delta := mx - mn
1187
1188 h := 0.0
1189 if delta > 0 {
1190 switch mx {
1191 case r:
1192 h = 60 * math.Mod((g-b)/delta, 6)
1193 case g:
1194 h = 60 * ((b-r)/delta + 2)
1195 case b:
1196 h = 60 * ((r-g)/delta + 4)
1197 }
1198 }
1199 if h < 0 {
1200 h += 360
1201 }
1202
1203 s := 0.0
1204 if mx > 0 {
1205 s = (delta / mx) * 100
1206 }
1207 v := mx * 100
1208 return wrapHue(h), clamp(s, 0, 100), clamp(v, 0, 100)
1209}
1210
1211func hsvToRGBA(h, s, v float64) *gdk.RGBA {
1212 h = wrapHue(h)
1213 s = clamp(s, 0, 100) / 100
1214 v = clamp(v, 0, 100) / 100
1215
1216 c := v * s
1217 x := c * (1 - math.Abs(math.Mod(h/60, 2)-1))
1218 m := v - c
1219
1220 var r1, g1, b1 float64
1221 switch {
1222 case h < 60:
1223 r1, g1, b1 = c, x, 0
1224 case h < 120:
1225 r1, g1, b1 = x, c, 0
1226 case h < 180:
1227 r1, g1, b1 = 0, c, x
1228 case h < 240:
1229 r1, g1, b1 = 0, x, c
1230 case h < 300:
1231 r1, g1, b1 = x, 0, c
1232 default:
1233 r1, g1, b1 = c, 0, x
1234 }
1235
1236 rgba := gdk.NewRGBA()
1237 rgba.SetRed(r1 + m)
1238 rgba.SetGreen(g1 + m)
1239 rgba.SetBlue(b1 + m)
1240 rgba.SetAlpha(1)
1241 return rgba
1242}
1243
407func rgbaToHex(rgba *gdk.RGBA) string { 1244func rgbaToHex(rgba *gdk.RGBA) string {
408 r := uint8(rgba.GetRed() * 255) 1245 r := int(math.Round(rgba.GetRed() * 255))
409 g := uint8(rgba.GetGreen() * 255) 1246 g := int(math.Round(rgba.GetGreen() * 255))
410 b := uint8(rgba.GetBlue() * 255) 1247 b := int(math.Round(rgba.GetBlue() * 255))
411 return fmt.Sprintf("#%02X%02X%02X", r, g, b) 1248 return fmt.Sprintf("#%02X%02X%02X", r, g, b)
412} 1249}
1250
1251func wrapHue(h float64) float64 {
1252 v := math.Mod(h, 360)
1253 if v < 0 {
1254 v += 360
1255 }
1256 return v
1257}
1258
1259func clamp(v, lo, hi float64) float64 {
1260 if v < lo {
1261 return lo
1262 }
1263 if v > hi {
1264 return hi
1265 }
1266 return v
1267}
1268
1269func luminance(rgba *gdk.RGBA) float64 {
1270 r := rgba.GetRed()
1271 g := rgba.GetGreen()
1272 b := rgba.GetBlue()
1273 return 0.2126*r + 0.7152*g + 0.0722*b
1274}