From ef712cc8c545044b469fe843c5a3fc190fab4e42 Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Sun, 15 Mar 2026 11:40:51 -0400 Subject: refactor: boilerplate re-write --- src/main.go | 1047 +++++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 800 insertions(+), 247 deletions(-) (limited to 'src') diff --git a/src/main.go b/src/main.go index bab289c..910aef8 100644 --- a/src/main.go +++ b/src/main.go @@ -4,10 +4,14 @@ import ( "encoding/json" "fmt" "log" + "math" + "math/rand" "os" "os/exec" "path/filepath" + "sort" "strings" + "time" "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/glib" @@ -15,8 +19,9 @@ import ( ) const ( - appTitle = "kjagave" - appVersion = "20251221-0050" + appTitle = "kjagave" + appVersion = "20260315-0200" + maxHistoryLen = 250 ) type SavedColor struct { @@ -24,316 +29,613 @@ type SavedColor struct { Name string `json:"name"` } +type AppConfig struct { + Favorites []SavedColor `json:"favorites"` + LastColor string `json:"lastColor"` + LastScheme string `json:"lastScheme"` + Palette string `json:"palette"` +} + +type SwatchCard struct { + button *gtk.Button + image *gtk.Image + label *gtk.Label + hex string +} + type App struct { - window *gtk.Window + window *gtk.Window + colorButton *gtk.ColorButton + hexEntry *gtk.Entry + schemeCombo *gtk.ComboBoxText + paletteCombo *gtk.ComboBoxText + + historyBackBtn *gtk.Button + historyFwdBtn *gtk.Button + lightenBtn *gtk.Button + darkenBtn *gtk.Button + saturateBtn *gtk.Button + desaturateBtn *gtk.Button + + swatchCards []SwatchCard + + paletteStore *gtk.ListStore + paletteView *gtk.TreeView + + favoritesStore *gtk.ListStore + favoritesView *gtk.TreeView + removeFavBtn *gtk.Button + clearFavBtn *gtk.Button + selectedIter *gtk.TreeIter + currentColor *gdk.RGBA - listStore *gtk.ListStore - treeView *gtk.TreeView - deleteBtn *gtk.Button - savedColors []SavedColor - configFile string - selectedIter *gtk.TreeIter + currentHex string + + savedColors []SavedColor + history []string + historyPos int + + configFile string + config AppConfig + + suppressColorSet bool + rng *rand.Rand +} + +var schemeNames = []string{ + "Triads", + "Complements", + "Split Complements", + "Tetrads", + "Analogous", + "Monochromatic", +} + +var paletteNames = []string{ + "Web-safe colors", + "Tango", + "Visibone Core", } func main() { gtk.Init(nil) configDir := filepath.Join(os.Getenv("HOME"), ".config") - os.MkdirAll(configDir, 0755) + _ = os.MkdirAll(configDir, 0755) app := &App{ configFile: filepath.Join(configDir, "kjagave.json"), + rng: rand.New(rand.NewSource(time.Now().UnixNano())), + historyPos: -1, } - app.loadColors() + app.loadConfig() app.createUI() - app.populateList() + app.refreshFavoritesView() + app.restoreStartupState() + app.populatePaletteList() + app.updateSchemePreview() + app.updateActionStates() gtk.Main() } func (app *App) createUI() { var err error - - // main window app.window, err = gtk.WindowNew(gtk.WINDOW_TOPLEVEL) if err != nil { log.Fatal("Unable to create window:", err) } app.window.SetTitle(appTitle) - app.window.SetDefaultSize(550, 450) - app.window.SetResizable(false) - app.window.Connect("destroy", gtk.MainQuit) + app.window.SetDefaultSize(980, 680) + app.window.Connect("destroy", func() { + app.saveConfig() + gtk.MainQuit() + }) + + root, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 8) + root.SetMarginTop(8) + root.SetMarginBottom(8) + root.SetMarginStart(8) + root.SetMarginEnd(8) + + toolbar, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 4) + app.historyBackBtn, _ = gtk.ButtonNewWithLabel("Back") + app.historyBackBtn.Connect("clicked", func() { app.navigateHistory(-1) }) + toolbar.PackStart(app.historyBackBtn, false, false, 0) - // vertical box - mainBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 10) - mainBox.SetMarginTop(15) - mainBox.SetMarginBottom(15) - mainBox.SetMarginStart(15) - mainBox.SetMarginEnd(15) + app.historyFwdBtn, _ = gtk.ButtonNewWithLabel("Forward") + app.historyFwdBtn.Connect("clicked", func() { app.navigateHistory(1) }) + toolbar.PackStart(app.historyFwdBtn, false, false, 0) - // color selection area - colorBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 10) - colorBox.SetHAlign(gtk.ALIGN_CENTER) + randomBtn, _ := gtk.ButtonNewWithLabel("Random") + randomBtn.Connect("clicked", func() { app.randomizeColor() }) + toolbar.PackStart(randomBtn, false, false, 0) - label, _ := gtk.LabelNew("Select Color:") - colorBox.PackStart(label, false, false, 0) + app.lightenBtn, _ = gtk.ButtonNewWithLabel("Lighten") + app.lightenBtn.Connect("clicked", func() { app.adjustSV(0, 5) }) + toolbar.PackStart(app.lightenBtn, false, false, 0) - // initialize color + app.darkenBtn, _ = gtk.ButtonNewWithLabel("Darken") + app.darkenBtn.Connect("clicked", func() { app.adjustSV(0, -5) }) + toolbar.PackStart(app.darkenBtn, false, false, 0) + + app.saturateBtn, _ = gtk.ButtonNewWithLabel("Saturate") + app.saturateBtn.Connect("clicked", func() { app.adjustSV(5, 0) }) + toolbar.PackStart(app.saturateBtn, false, false, 0) + + app.desaturateBtn, _ = gtk.ButtonNewWithLabel("Desaturate") + app.desaturateBtn.Connect("clicked", func() { app.adjustSV(-5, 0) }) + toolbar.PackStart(app.desaturateBtn, false, false, 0) + + pasteBtn, _ := gtk.ButtonNewWithLabel("Paste") + pasteBtn.Connect("clicked", func() { app.pasteColorFromClipboard() }) + toolbar.PackStart(pasteBtn, false, false, 0) + + aboutBtn, _ := gtk.ButtonNewWithLabel("About") + aboutBtn.Connect("clicked", func() { app.onAboutClicked() }) + toolbar.PackStart(aboutBtn, false, false, 0) + + root.PackStart(toolbar, false, false, 0) + + schemeRow, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 8) + app.swatchCards = make([]SwatchCard, 0, 4) + for i := 0; i < 4; i++ { + card := app.newSwatchCard() + cardIdx := i + card.button.Connect("clicked", func() { + hex := app.swatchCards[cardIdx].hex + if hex == "" { + return + } + rgba := gdk.NewRGBA() + if !rgba.Parse(hex) { + return + } + app.setCurrentColor(rgba, true) + }) + app.swatchCards = append(app.swatchCards, card) + schemeRow.PackStart(card.button, true, true, 0) + } + root.PackStart(schemeRow, false, false, 0) + + controlRow, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 8) app.currentColor = gdk.NewRGBA() - app.currentColor.Parse("#69BAA7") + app.currentColor.Parse("#0066FF") app.colorButton, err = gtk.ColorButtonNewWithRGBA(app.currentColor) if err != nil { log.Fatal("Unable to create color button:", err) } - app.colorButton.SetUseAlpha(true) - app.colorButton.SetTitle("Choose a Color") + app.colorButton.SetUseAlpha(false) + app.colorButton.SetTitle("Pick a Color") app.colorButton.Connect("color-set", func() { - app.currentColor = app.colorButton.GetRGBA() + if app.suppressColorSet { + return + } + app.setCurrentColor(app.colorButton.GetRGBA(), true) }) + controlRow.PackStart(app.colorButton, false, false, 0) - colorBox.PackStart(app.colorButton, false, false, 0) - - hexEntry, _ := gtk.EntryNew() - hexEntry.SetEditable(false) - hexEntry.SetWidthChars(10) - hexEntry.SetText(rgbaToHex(app.currentColor)) - colorBox.PackStart(hexEntry, false, false, 0) - - // color picker button - pickerBtn, _ := gtk.ButtonNewWithLabel("Pick from Screen") - pickerBtn.Connect("clicked", func() { - if color, err := app.pickColorFromScreen(); err == nil { - app.colorButton.SetRGBA(color) - app.currentColor = color - hexEntry.SetText(rgbaToHex(color)) + app.schemeCombo, _ = gtk.ComboBoxTextNew() + for _, schemeName := range schemeNames { + app.schemeCombo.AppendText(schemeName) + } + app.schemeCombo.SetActive(0) + app.schemeCombo.Connect("changed", func() { + app.updateSchemePreview() + app.saveConfig() + }) + controlRow.PackStart(app.schemeCombo, false, false, 0) + + app.hexEntry, _ = gtk.EntryNew() + app.hexEntry.SetWidthChars(11) + app.hexEntry.Connect("activate", func() { app.applyHexEntry() }) + controlRow.PackStart(app.hexEntry, false, false, 0) + + useHexBtn, _ := gtk.ButtonNewWithLabel("Use") + useHexBtn.Connect("clicked", func() { app.applyHexEntry() }) + controlRow.PackStart(useHexBtn, false, false, 0) + + pickBtn, _ := gtk.ButtonNewWithLabel("Pick from Screen") + pickBtn.Connect("clicked", func() { + clr, err := app.pickColorFromScreen() + if err == nil { + app.setCurrentColor(clr, true) } }) - colorBox.PackStart(pickerBtn, false, false, 0) + controlRow.PackStart(pickBtn, false, false, 0) - // bump hex entry when color changes - app.colorButton.Connect("color-set", func() { - app.currentColor = app.colorButton.GetRGBA() - hexEntry.SetText(rgbaToHex(app.currentColor)) + copyBtn, _ := gtk.ButtonNewWithLabel("Copy") + copyBtn.Connect("clicked", func() { + clipboard, _ := gtk.ClipboardGet(gdk.SELECTION_CLIPBOARD) + clipboard.SetText(app.currentHex) }) + controlRow.PackStart(copyBtn, false, false, 0) - mainBox.PackStart(colorBox, false, false, 0) - - expander, _ := gtk.ExpanderNew("Saved Colors") - expander.SetExpanded(true) - expanderBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 5) - expanderBox.SetMarginTop(5) - expanderBox.SetMarginBottom(5) - btnBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5) - btnBox.SetHAlign(gtk.ALIGN_END) - app.deleteBtn, _ = gtk.ButtonNewWithLabel("Delete") - app.deleteBtn.SetSensitive(false) - app.deleteBtn.Connect("clicked", app.onDeleteClicked) - btnBox.PackStart(app.deleteBtn, false, false, 0) - saveBtn, _ := gtk.ButtonNewWithLabel("Save...") - saveBtn.Connect("clicked", app.onSaveClicked) - btnBox.PackStart(saveBtn, false, false, 0) - expanderBox.PackStart(btnBox, false, false, 0) - - // treeview - scrolled, _ := gtk.ScrolledWindowNew(nil, nil) - scrolled.SetPolicy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) - scrolled.SetSizeRequest(-1, 180) - - app.listStore, _ = gtk.ListStoreNew(gdk.PixbufGetType(), glib.TYPE_STRING, glib.TYPE_STRING) - app.treeView, _ = gtk.TreeViewNew() - app.treeView.SetModel(app.listStore) - app.treeView.SetHeadersVisible(true) - - // color column with swatch - colorCol, _ := gtk.TreeViewColumnNew() - colorCol.SetTitle("Color") - colorCol.SetSortColumnID(1) - - pixbufRenderer, _ := gtk.CellRendererPixbufNew() - colorCol.PackStart(pixbufRenderer, false) - colorCol.AddAttribute(pixbufRenderer, "pixbuf", 0) - - textRenderer, _ := gtk.CellRendererTextNew() - colorCol.PackStart(textRenderer, true) - colorCol.AddAttribute(textRenderer, "text", 1) - - app.treeView.AppendColumn(colorCol) - - // name column - nameRenderer, _ := gtk.CellRendererTextNew() - nameCol, _ := gtk.TreeViewColumnNewWithAttribute("Name", nameRenderer, "text", 2) - nameCol.SetSortColumnID(2) - app.treeView.AppendColumn(nameCol) - - selection, _ := app.treeView.GetSelection() - selection.SetMode(gtk.SELECTION_SINGLE) - selection.Connect("changed", app.onSelectionChanged) - - scrolled.Add(app.treeView) - expanderBox.PackStart(scrolled, true, true, 0) - expander.Add(expanderBox) - mainBox.PackStart(expander, true, true, 0) - - // bottom button box - bottomBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5) - bottomBox.SetHAlign(gtk.ALIGN_END) - - copyBtn, _ := gtk.ButtonNewWithLabel("Copy to Clipboard") - copyBtn.Connect("clicked", app.onCopyClicked) - bottomBox.PackStart(copyBtn, false, false, 0) + root.PackStart(controlRow, false, false, 0) - aboutBtn, _ := gtk.ButtonNewWithLabel("About") - aboutBtn.Connect("clicked", app.onAboutClicked) - bottomBox.PackStart(aboutBtn, false, false, 0) + lower, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 8) + + paletteFrame, _ := gtk.FrameNew("Palette") + paletteBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6) + paletteBox.SetMarginTop(8) + paletteBox.SetMarginBottom(8) + paletteBox.SetMarginStart(8) + paletteBox.SetMarginEnd(8) + + app.paletteCombo, _ = gtk.ComboBoxTextNew() + for _, name := range paletteNames { + app.paletteCombo.AppendText(name) + } + app.paletteCombo.SetActive(0) + app.paletteCombo.Connect("changed", func() { + app.populatePaletteList() + app.saveConfig() + }) + paletteBox.PackStart(app.paletteCombo, false, false, 0) + + paletteScroll, _ := gtk.ScrolledWindowNew(nil, nil) + paletteScroll.SetPolicy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) + paletteScroll.SetSizeRequest(430, 260) + + app.paletteStore, _ = gtk.ListStoreNew(gdk.PixbufGetType(), glib.TYPE_STRING) + app.paletteView, _ = gtk.TreeViewNew() + app.paletteView.SetModel(app.paletteStore) + app.paletteView.SetHeadersVisible(false) + paletteCol, _ := gtk.TreeViewColumnNew() + palettePix, _ := gtk.CellRendererPixbufNew() + paletteCol.PackStart(palettePix, false) + paletteCol.AddAttribute(palettePix, "pixbuf", 0) + paletteText, _ := gtk.CellRendererTextNew() + paletteCol.PackStart(paletteText, true) + paletteCol.AddAttribute(paletteText, "text", 1) + app.paletteView.AppendColumn(paletteCol) + palSel, _ := app.paletteView.GetSelection() + palSel.SetMode(gtk.SELECTION_SINGLE) + palSel.Connect("changed", app.onPaletteSelectionChanged) + paletteScroll.Add(app.paletteView) + paletteBox.PackStart(paletteScroll, true, true, 0) + paletteFrame.Add(paletteBox) + lower.PackStart(paletteFrame, true, true, 0) + + favFrame, _ := gtk.FrameNew("Favorites") + favBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 6) + favBox.SetMarginTop(8) + favBox.SetMarginBottom(8) + favBox.SetMarginStart(8) + favBox.SetMarginEnd(8) + + favScroll, _ := gtk.ScrolledWindowNew(nil, nil) + favScroll.SetPolicy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) + favScroll.SetSizeRequest(260, 260) + + app.favoritesStore, _ = gtk.ListStoreNew(gdk.PixbufGetType(), glib.TYPE_STRING, glib.TYPE_STRING) + app.favoritesView, _ = gtk.TreeViewNew() + app.favoritesView.SetModel(app.favoritesStore) + app.favoritesView.SetHeadersVisible(false) + + favCol, _ := gtk.TreeViewColumnNew() + favPix, _ := gtk.CellRendererPixbufNew() + favCol.PackStart(favPix, false) + favCol.AddAttribute(favPix, "pixbuf", 0) + favHexText, _ := gtk.CellRendererTextNew() + favCol.PackStart(favHexText, true) + favCol.AddAttribute(favHexText, "text", 1) + app.favoritesView.AppendColumn(favCol) + + favNameText, _ := gtk.CellRendererTextNew() + favNameCol, _ := gtk.TreeViewColumnNewWithAttribute("", favNameText, "text", 2) + app.favoritesView.AppendColumn(favNameCol) + + favSel, _ := app.favoritesView.GetSelection() + favSel.SetMode(gtk.SELECTION_SINGLE) + favSel.Connect("changed", app.onFavoriteSelectionChanged) + + favScroll.Add(app.favoritesView) + favBox.PackStart(favScroll, true, true, 0) + + favBtns, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 4) + addFavBtn, _ := gtk.ButtonNewWithLabel("+") + addFavBtn.Connect("clicked", func() { app.addCurrentToFavorites() }) + favBtns.PackStart(addFavBtn, true, true, 0) + + app.removeFavBtn, _ = gtk.ButtonNewWithLabel("-") + app.removeFavBtn.Connect("clicked", func() { app.removeSelectedFavorite() }) + favBtns.PackStart(app.removeFavBtn, true, true, 0) + + app.clearFavBtn, _ = gtk.ButtonNewWithLabel("Clear") + app.clearFavBtn.Connect("clicked", func() { + app.savedColors = nil + app.refreshFavoritesView() + app.saveConfig() + }) + favBtns.PackStart(app.clearFavBtn, true, true, 0) + + exportBtn, _ := gtk.ButtonNewWithLabel("Export GPL") + exportBtn.Connect("clicked", func() { app.exportFavoritesGPLToDefaultPath() }) + favBtns.PackStart(exportBtn, true, true, 0) + + favBox.PackStart(favBtns, false, false, 0) + favFrame.Add(favBox) + lower.PackStart(favFrame, false, false, 0) - mainBox.PackStart(bottomBox, false, false, 0) + root.PackStart(lower, true, true, 0) - app.window.Add(mainBox) + status, _ := gtk.LabelNew("Choose a color and a scheme type") + status.SetHAlign(gtk.ALIGN_START) + root.PackStart(status, false, false, 0) + + app.window.Add(root) app.window.ShowAll() } -func (app *App) populateList() { - app.listStore.Clear() +func (app *App) newSwatchCard() SwatchCard { + button, _ := gtk.ButtonNew() + button.SetSizeRequest(220, 240) - for _, color := range app.savedColors { - pixbuf := app.createColorSwatch(color.Hex) - iter := app.listStore.Append() - app.listStore.Set(iter, []int{0, 1, 2}, []interface{}{pixbuf, color.Hex, color.Name}) - } + vbox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 4) + vbox.SetMarginTop(8) + vbox.SetMarginBottom(8) + vbox.SetMarginStart(8) + vbox.SetMarginEnd(8) + + image, _ := gtk.ImageNew() + image.SetFromPixbuf(solidPixbuf("#000000", 220, 150)) + vbox.PackStart(image, false, false, 0) + + label, _ := gtk.LabelNew("") + label.SetJustify(gtk.JUSTIFY_CENTER) + label.SetHAlign(gtk.ALIGN_CENTER) + vbox.PackStart(label, false, false, 0) + + button.Add(vbox) + + return SwatchCard{button: button, image: image, label: label} } -func (app *App) createColorSwatch(hexColor string) *gdk.Pixbuf { - pixbuf, err := gdk.PixbufNew(gdk.COLORSPACE_RGB, false, 8, 16, 14) - if err != nil { - return nil +func (app *App) setCurrentColor(rgba *gdk.RGBA, pushHistory bool) { + hex := rgbaToHex(rgba) + app.currentColor = rgba + app.currentHex = hex + app.hexEntry.SetText(hex) + + app.suppressColorSet = true + app.colorButton.SetRGBA(rgba) + app.suppressColorSet = false + + if pushHistory { + app.pushHistory(hex) } - rgba := gdk.NewRGBA() - rgba.Parse(hexColor) - - r := uint32(rgba.GetRed() * 255) - g := uint32(rgba.GetGreen() * 255) - b := uint32(rgba.GetBlue() * 255) - - pixels := pixbuf.GetPixels() - rowstride := pixbuf.GetRowstride() - nChannels := pixbuf.GetNChannels() - - for y := 0; y < 14; y++ { - for x := 0; x < 16; x++ { - offset := y*rowstride + x*nChannels - pixels[offset] = byte(r) - pixels[offset+1] = byte(g) - pixels[offset+2] = byte(b) + app.config.LastColor = hex + app.config.LastScheme = app.activeSchemeName() + app.config.Palette = app.activePaletteName() + app.updateSchemePreview() + app.updateActionStates() + app.saveConfig() +} + +func (app *App) updateSchemePreview() { + colors := generateScheme(app.currentColor, app.activeSchemeName()) + for i := 0; i < len(app.swatchCards); i++ { + if i >= len(colors) { + app.swatchCards[i].button.Hide() + continue } + app.swatchCards[i].button.Show() + hex := rgbaToHex(colors[i]) + h, s, v := rgbToHSV(colors[i]) + r := int(math.Round(colors[i].GetRed() * 255)) + g := int(math.Round(colors[i].GetGreen() * 255)) + b := int(math.Round(colors[i].GetBlue() * 255)) + app.swatchCards[i].hex = hex + app.swatchCards[i].image.SetFromPixbuf(solidPixbuf(hex, 220, 150)) + app.swatchCards[i].label.SetText(fmt.Sprintf("%s\nrgb(%d, %d, %d)\nhsv(%d, %d, %d)", hex, r, g, b, int(h), int(s), int(v))) } - - return pixbuf } -func (app *App) onSelectionChanged(selection *gtk.TreeSelection) { - model, iter, ok := selection.GetSelected() - if !ok { - app.deleteBtn.SetSensitive(false) - app.selectedIter = nil +func (app *App) applyHexEntry() { + text, _ := app.hexEntry.GetText() + text = strings.TrimSpace(text) + if text == "" { return } + if !strings.HasPrefix(text, "#") { + text = "#" + text + } + rgba := gdk.NewRGBA() + if !rgba.Parse(text) { + app.hexEntry.SetText(app.currentHex) + return + } + app.setCurrentColor(rgba, true) +} - app.selectedIter = iter - app.deleteBtn.SetSensitive(true) +func (app *App) randomizeColor() { + r := app.rng.Intn(256) + g := app.rng.Intn(256) + b := app.rng.Intn(256) + rgba := gdk.NewRGBA() + rgba.SetRed(float64(r) / 255.0) + rgba.SetGreen(float64(g) / 255.0) + rgba.SetBlue(float64(b) / 255.0) + rgba.SetAlpha(1) + app.setCurrentColor(rgba, true) +} - value, _ := model.ToTreeModel().GetValue(iter, 1) - hexColor, _ := value.GetString() +func (app *App) adjustSV(ds, dv float64) { + h, s, v := rgbToHSV(app.currentColor) + s = clamp(s+ds, 0, 100) + v = clamp(v+dv, 0, 100) + app.setCurrentColor(hsvToRGBA(h, s, v), true) +} +func (app *App) pasteColorFromClipboard() { + clipboard, _ := gtk.ClipboardGet(gdk.SELECTION_CLIPBOARD) + text, err := clipboard.WaitForText() + if err != nil { + return + } + text = strings.TrimSpace(text) + if text == "" { + return + } + if !strings.HasPrefix(text, "#") { + text = "#" + text + } rgba := gdk.NewRGBA() - rgba.Parse(hexColor) - app.colorButton.SetRGBA(rgba) - app.currentColor = rgba + if rgba.Parse(text) { + app.setCurrentColor(rgba, true) + } } -func (app *App) onSaveClicked() { - dialog, _ := gtk.DialogNew() - dialog.SetTitle("Save Color") - dialog.SetTransientFor(app.window) - dialog.SetModal(true) - dialog.SetDefaultSize(300, -1) - - box, _ := dialog.GetContentArea() - box.SetSpacing(10) - box.SetMarginTop(10) - box.SetMarginBottom(10) - box.SetMarginStart(10) - box.SetMarginEnd(10) +func (app *App) pushHistory(hex string) { + if app.historyPos >= 0 && app.historyPos < len(app.history) && app.history[app.historyPos] == hex { + return + } + if app.historyPos+1 < len(app.history) { + app.history = app.history[:app.historyPos+1] + } + app.history = append(app.history, hex) + if len(app.history) > maxHistoryLen { + over := len(app.history) - maxHistoryLen + app.history = app.history[over:] + } + app.historyPos = len(app.history) - 1 +} - // get current color - hexColor := rgbaToHex(app.currentColor) +func (app *App) navigateHistory(step int) { + if len(app.history) == 0 { + return + } + next := app.historyPos + step + if next < 0 || next >= len(app.history) { + return + } + rgba := gdk.NewRGBA() + if !rgba.Parse(app.history[next]) { + return + } + app.historyPos = next + app.setCurrentColor(rgba, false) +} - label, _ := gtk.LabelNew(fmt.Sprintf("Color: %s", hexColor)) - box.PackStart(label, false, false, 0) +func (app *App) updateActionStates() { + _, s, v := rgbToHSV(app.currentColor) + app.historyBackBtn.SetSensitive(app.historyPos > 0) + app.historyFwdBtn.SetSensitive(app.historyPos >= 0 && app.historyPos < len(app.history)-1) + app.lightenBtn.SetSensitive(v < 100) + app.darkenBtn.SetSensitive(v > 5) + app.saturateBtn.SetSensitive(s < 100) + app.desaturateBtn.SetSensitive(s > 5) + app.removeFavBtn.SetSensitive(app.selectedIter != nil) + app.clearFavBtn.SetSensitive(len(app.savedColors) > 0) +} - entryLabel, _ := gtk.LabelNew("Color Name:") - entryLabel.SetHAlign(gtk.ALIGN_START) - box.PackStart(entryLabel, false, false, 0) +func (app *App) activeSchemeName() string { + idx := app.schemeCombo.GetActive() + if idx < 0 || idx >= len(schemeNames) { + return schemeNames[0] + } + return schemeNames[idx] +} - entry, _ := gtk.EntryNew() - entry.SetText("Untitled") - entry.SetActivatesDefault(true) - box.PackStart(entry, false, false, 0) +func (app *App) activePaletteName() string { + idx := app.paletteCombo.GetActive() + if idx < 0 || idx >= len(paletteNames) { + return paletteNames[0] + } + return paletteNames[idx] +} - dialog.AddButton("Cancel", gtk.RESPONSE_CANCEL) - okBtn, _ := dialog.AddButton("OK", gtk.RESPONSE_OK) - okBtn.SetCanDefault(true) - okBtn.GrabDefault() +func (app *App) onPaletteSelectionChanged(selection *gtk.TreeSelection) { + model, iter, ok := selection.GetSelected() + if !ok { + return + } + value, _ := model.ToTreeModel().GetValue(iter, 1) + hex, _ := value.GetString() + rgba := gdk.NewRGBA() + if rgba.Parse(hex) { + app.setCurrentColor(rgba, true) + } +} - dialog.ShowAll() +func (app *App) onFavoriteSelectionChanged(selection *gtk.TreeSelection) { + model, iter, ok := selection.GetSelected() + if !ok { + app.selectedIter = nil + app.updateActionStates() + return + } + app.selectedIter = iter + value, _ := model.ToTreeModel().GetValue(iter, 1) + hex, _ := value.GetString() + rgba := gdk.NewRGBA() + if rgba.Parse(hex) { + app.setCurrentColor(rgba, true) + } + app.updateActionStates() +} - response := dialog.Run() - if response == gtk.RESPONSE_OK { - text, _ := entry.GetText() - app.savedColors = append([]SavedColor{{Hex: hexColor, Name: text}}, app.savedColors...) - app.saveColors() - app.populateList() +func (app *App) populatePaletteList() { + app.paletteStore.Clear() + for _, hex := range paletteByName(app.activePaletteName()) { + iter := app.paletteStore.Append() + app.paletteStore.Set(iter, []int{0, 1}, []interface{}{solidPixbuf(hex, 36, 14), hex}) } +} - dialog.Destroy() +func (app *App) addCurrentToFavorites() { + for _, item := range app.savedColors { + if strings.EqualFold(item.Hex, app.currentHex) { + return + } + } + app.savedColors = append([]SavedColor{{Hex: app.currentHex, Name: app.currentHex}}, app.savedColors...) + app.refreshFavoritesView() + app.saveConfig() } -func (app *App) onDeleteClicked() { +func (app *App) removeSelectedFavorite() { if app.selectedIter == nil { return } - - model := app.listStore.ToTreeModel() - value, _ := model.GetValue(app.selectedIter, 1) - hexColor, _ := value.GetString() - - // remove from saved colors - for i, color := range app.savedColors { - if color.Hex == hexColor { + value, _ := app.favoritesStore.GetValue(app.selectedIter, 1) + hex, _ := value.GetString() + for i, c := range app.savedColors { + if strings.EqualFold(c.Hex, hex) { app.savedColors = append(app.savedColors[:i], app.savedColors[i+1:]...) break } } - - app.saveColors() - app.populateList() - app.deleteBtn.SetSensitive(false) app.selectedIter = nil + app.refreshFavoritesView() + app.saveConfig() } -func (app *App) onCopyClicked() { - hexColor := rgbaToHex(app.currentColor) - - clipboard, _ := gtk.ClipboardGet(gdk.SELECTION_CLIPBOARD) - clipboard.SetText(hexColor) +func (app *App) refreshFavoritesView() { + app.favoritesStore.Clear() + for _, color := range app.savedColors { + iter := app.favoritesStore.Append() + app.favoritesStore.Set(iter, []int{0, 1, 2}, []interface{}{solidPixbuf(color.Hex, 16, 14), strings.ToUpper(color.Hex), color.Name}) + } + app.updateActionStates() +} - dialog := gtk.MessageDialogNew(app.window, gtk.DIALOG_MODAL, gtk.MESSAGE_INFO, - gtk.BUTTONS_OK, fmt.Sprintf("Color %s copied to clipboard!", hexColor)) - dialog.Run() - dialog.Destroy() +func (app *App) exportFavoritesGPLToDefaultPath() { + if len(app.savedColors) == 0 { + return + } + outPath := filepath.Join(filepath.Dir(app.configFile), "kjagave-favorites.gpl") + lines := []string{"GIMP Palette", "Name: kjagave Favorites", "#"} + for _, item := range app.savedColors { + r, g, b := hexToRGB(item.Hex) + lines = append(lines, fmt.Sprintf("%3d %3d %3d\t%s", r, g, b, item.Name)) + } + _ = os.WriteFile(outPath, []byte(strings.Join(lines, "\n")+"\n"), 0644) } func (app *App) onAboutClicked() { @@ -341,8 +643,8 @@ func (app *App) onAboutClicked() { dialog.SetTransientFor(app.window) dialog.SetProgramName(appTitle) dialog.SetVersion(appVersion) - dialog.SetComments("a color picker inspired by agave, but only with the features kj_sh604 actually used") - dialog.SetAuthors([]string{"kj_sh604"}) + dialog.SetComments("Agave-inspired GTK color scheme tool") + dialog.SetAuthors([]string{"kj_sh604", "Agave inspiration: Jonathon Jongsma"}) dialog.SetLicense("BSD Zero Clause License (0-clause BSD)") dialog.SetLogoIconName("applications-graphics") dialog.Run() @@ -350,63 +652,314 @@ func (app *App) onAboutClicked() { } func (app *App) pickColorFromScreen() (*gdk.RGBA, error) { - // use xcolor for x11 color picking cmd := exec.Command("xcolor", "--format", "hex") output, err := cmd.Output() if err != nil { - // fallback to grabc cmd = exec.Command("grabc") output, err = cmd.Output() if err != nil { dialog := gtk.MessageDialogNew(app.window, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, - gtk.BUTTONS_OK, "Color picker not found. Please install 'xcolor'") + gtk.BUTTONS_OK, "Color picker not found. Please install xcolor or grabc") dialog.Run() dialog.Destroy() return nil, err } } - hexColor := strings.TrimSpace(string(output)) - if !strings.HasPrefix(hexColor, "#") { - hexColor = "#" + hexColor + hex := strings.TrimSpace(string(output)) + if !strings.HasPrefix(hex, "#") { + hex = "#" + hex } - rgba := gdk.NewRGBA() - if !rgba.Parse(hexColor) { - return nil, fmt.Errorf("invalid color format: %s", hexColor) + if !rgba.Parse(hex) { + return nil, fmt.Errorf("invalid color format: %s", hex) } - return rgba, nil } -func (app *App) loadColors() { +func (app *App) loadConfig() { data, err := os.ReadFile(app.configFile) if err != nil { app.savedColors = []SavedColor{} + app.config = AppConfig{Favorites: []SavedColor{}, LastColor: "#0066FF", LastScheme: "Triads", Palette: "Web-safe colors"} return } - if err := json.Unmarshal(data, &app.savedColors); err != nil { - log.Printf("Error loading colors: %v", err) - app.savedColors = []SavedColor{} + var cfg AppConfig + if err := json.Unmarshal(data, &cfg); err == nil && (cfg.LastColor != "" || len(cfg.Favorites) > 0) { + app.config = cfg + app.savedColors = append([]SavedColor(nil), cfg.Favorites...) + if app.savedColors == nil { + app.savedColors = []SavedColor{} + } + if app.config.LastColor == "" { + app.config.LastColor = "#0066FF" + } + if app.config.LastScheme == "" { + app.config.LastScheme = "Triads" + } + if app.config.Palette == "" { + app.config.Palette = "Web-safe colors" + } + return + } + + var legacy []SavedColor + if err := json.Unmarshal(data, &legacy); err == nil { + app.savedColors = legacy + app.config = AppConfig{Favorites: legacy, LastColor: "#0066FF", LastScheme: "Triads", Palette: "Web-safe colors"} + return } + + app.savedColors = []SavedColor{} + app.config = AppConfig{Favorites: []SavedColor{}, LastColor: "#0066FF", LastScheme: "Triads", Palette: "Web-safe colors"} } -func (app *App) saveColors() { - data, err := json.MarshalIndent(app.savedColors, "", " ") +func (app *App) saveConfig() { + app.config.Favorites = app.savedColors + if app.currentHex != "" { + app.config.LastColor = app.currentHex + } + if app.schemeCombo != nil { + app.config.LastScheme = app.activeSchemeName() + } + if app.paletteCombo != nil { + app.config.Palette = app.activePaletteName() + } + + data, err := json.MarshalIndent(app.config, "", " ") if err != nil { - log.Printf("Error marshaling colors: %v", err) + log.Printf("Error marshaling config: %v", err) return } - if err := os.WriteFile(app.configFile, data, 0644); err != nil { - log.Printf("Error saving colors: %v", err) + log.Printf("Error saving config: %v", err) } } +func (app *App) restoreStartupState() { + rgba := gdk.NewRGBA() + if app.config.LastColor != "" && rgba.Parse(app.config.LastColor) { + app.currentColor = rgba + } + + for i, name := range schemeNames { + if name == app.config.LastScheme { + app.schemeCombo.SetActive(i) + break + } + } + for i, name := range paletteNames { + if name == app.config.Palette { + app.paletteCombo.SetActive(i) + break + } + } + + app.setCurrentColor(app.currentColor, true) +} + +func generateScheme(base *gdk.RGBA, schemeName string) []*gdk.RGBA { + h, s, v := rgbToHSV(base) + mk := func(hue float64) *gdk.RGBA { + return hsvToRGBA(wrapHue(hue), s, v) + } + + switch schemeName { + case "Complements": + return []*gdk.RGBA{hsvToRGBA(h, s, v), mk(h + 180)} + case "Split Complements": + offset := 360.0 / 15.0 + return []*gdk.RGBA{hsvToRGBA(h, s, v), mk(h + 180 - offset), mk(h + 180 + offset)} + case "Tetrads": + offset := 90.0 + return []*gdk.RGBA{hsvToRGBA(h, s, v), mk(h + offset), mk(h + 180), mk(h + 180 + offset)} + case "Analogous": + offset := 360.0 / 12.0 + return []*gdk.RGBA{mk(h - offset), hsvToRGBA(h, s, v), mk(h + offset)} + case "Monochromatic": + c0 := hsvToRGBA(h, s, v) + c1 := hsvToRGBA(h, s, v) + c2 := hsvToRGBA(h, s, v) + if s < 10 { + c1 = hsvToRGBA(h, math.Mod(s+33, 100), v) + c2 = hsvToRGBA(h, math.Mod(s+66, 100), v) + } else { + c1 = hsvToRGBA(h, s, math.Mod(v+33, 100)) + c2 = hsvToRGBA(h, s, math.Mod(v+66, 100)) + } + out := []*gdk.RGBA{c0, c1, c2} + sort.Slice(out, func(i, j int) bool { return luminance(out[i]) < luminance(out[j]) }) + return out + case "Triads": + fallthrough + default: + offset := 120.0 + return []*gdk.RGBA{hsvToRGBA(h, s, v), mk(h + offset), mk(h - offset)} + } +} + +func paletteByName(name string) []string { + switch name { + case "Tango": + return []string{ + "#2E3436", "#555753", "#888A85", "#BABDB6", "#D3D7CF", "#EEEEEC", + "#FCE94F", "#EDD400", "#C4A000", "#8AE234", "#73D216", "#4E9A06", + "#729FCF", "#3465A4", "#204A87", "#AD7FA8", "#75507B", "#5C3566", + "#EF2929", "#CC0000", "#A40000", "#FCAF3E", "#F57900", "#CE5C00", + } + case "Visibone Core": + return []string{ + "#000000", "#333333", "#666666", "#999999", "#CCCCCC", "#FFFFFF", + "#FF0000", "#FF9900", "#FFFF00", "#00FF00", "#00FFFF", "#0000FF", + "#9900FF", "#FF00FF", "#FF0066", "#663300", "#CC6633", "#99CC33", + "#6699CC", "#CC33CC", "#CC9999", "#33CCCC", "#336699", "#9966CC", + } + default: + vals := []int{0x00, 0x33, 0x66, 0x99, 0xCC, 0xFF} + colors := make([]string, 0, 216) + for _, r := range vals { + for _, g := range vals { + for _, b := range vals { + colors = append(colors, fmt.Sprintf("#%02X%02X%02X", r, g, b)) + } + } + } + return colors + } +} + +func solidPixbuf(hex string, width, height int) *gdk.Pixbuf { + pb, err := gdk.PixbufNew(gdk.COLORSPACE_RGB, false, 8, width, height) + if err != nil { + return nil + } + rgba := gdk.NewRGBA() + rgba.Parse(hex) + r := byte(rgba.GetRed() * 255) + g := byte(rgba.GetGreen() * 255) + b := byte(rgba.GetBlue() * 255) + + pixels := pb.GetPixels() + rowstride := pb.GetRowstride() + channels := pb.GetNChannels() + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + off := y*rowstride + x*channels + pixels[off] = r + pixels[off+1] = g + pixels[off+2] = b + } + } + return pb +} + +func rgbToHSV(rgba *gdk.RGBA) (float64, float64, float64) { + r := rgba.GetRed() + g := rgba.GetGreen() + b := rgba.GetBlue() + + mx := math.Max(r, math.Max(g, b)) + mn := math.Min(r, math.Min(g, b)) + delta := mx - mn + + h := 0.0 + if delta > 0 { + switch mx { + case r: + h = 60 * math.Mod((g-b)/delta, 6) + case g: + h = 60 * ((b-r)/delta + 2) + case b: + h = 60 * ((r-g)/delta + 4) + } + } + if h < 0 { + h += 360 + } + + s := 0.0 + if mx > 0 { + s = (delta / mx) * 100 + } + v := mx * 100 + return wrapHue(h), clamp(s, 0, 100), clamp(v, 0, 100) +} + +func hsvToRGBA(h, s, v float64) *gdk.RGBA { + h = wrapHue(h) + s = clamp(s, 0, 100) / 100 + v = clamp(v, 0, 100) / 100 + + c := v * s + x := c * (1 - math.Abs(math.Mod(h/60, 2)-1)) + m := v - c + + var r1, g1, b1 float64 + switch { + case h < 60: + r1, g1, b1 = c, x, 0 + case h < 120: + r1, g1, b1 = x, c, 0 + case h < 180: + r1, g1, b1 = 0, c, x + case h < 240: + r1, g1, b1 = 0, x, c + case h < 300: + r1, g1, b1 = x, 0, c + default: + r1, g1, b1 = c, 0, x + } + + rgba := gdk.NewRGBA() + rgba.SetRed(r1 + m) + rgba.SetGreen(g1 + m) + rgba.SetBlue(b1 + m) + rgba.SetAlpha(1) + return rgba +} + func rgbaToHex(rgba *gdk.RGBA) string { - r := uint8(rgba.GetRed() * 255) - g := uint8(rgba.GetGreen() * 255) - b := uint8(rgba.GetBlue() * 255) + r := int(math.Round(rgba.GetRed() * 255)) + g := int(math.Round(rgba.GetGreen() * 255)) + b := int(math.Round(rgba.GetBlue() * 255)) return fmt.Sprintf("#%02X%02X%02X", r, g, b) } + +func hexToRGB(hex string) (int, int, int) { + text := strings.TrimPrefix(strings.TrimSpace(hex), "#") + if len(text) != 6 { + return 0, 0, 0 + } + var r, g, b int + _, err := fmt.Sscanf(text, "%02x%02x%02x", &r, &g, &b) + if err != nil { + return 0, 0, 0 + } + return r, g, b +} + +func wrapHue(h float64) float64 { + v := math.Mod(h, 360) + if v < 0 { + v += 360 + } + return v +} + +func clamp(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func luminance(rgba *gdk.RGBA) float64 { + r := rgba.GetRed() + g := rgba.GetGreen() + b := rgba.GetBlue() + return 0.2126*r + 0.7152*g + 0.0722*b +} -- cgit v1.2.3