summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKyle Javier [kj_sh604] <43.splash@gmail.com>2026-03-15 13:03:22 -0400
committerGitHub <noreply@github.com>2026-03-15 13:03:22 -0400
commit3bee6a29f75cc90a5bf2ad037c27c4ab03952c15 (patch)
tree375f37834c5d59ce58ca55e3ec0218ea119d5de4
parent9ceac108893fe2ce2b2abc441aaf9294d58ee4ed (diff)
parent5fee19838c90d3d75f6a8c7b0cbb4da8b6762ff5 (diff)
merge: pull request #1 from kj-sh604/feat/feature-complete-ish
feat: make it close to feature-complete to agave
-rw-r--r--README.md67
-rw-r--r--src/main.go1344
2 files changed, 1147 insertions, 264 deletions
diff --git a/README.md b/README.md
index 21e3779..5c33777 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
# kjagave
-a color picker inspired by agave, but only with the features I actually used
+a GTK3 color scheme generator inspired by classic Agave, rewritten in Go
<img width="554" height="467" alt="recent screenshot" src="https://github.com/user-attachments/assets/efbea3f0-9d1f-45b1-865b-bb82a3ddc443" /><br>
@@ -11,19 +11,48 @@ a color picker inspired by agave, but only with the features I actually used
## features
-- visual color picker with rgb/hsv sliders
-- **pick colors from anywhere on screen (only on x11 because I'm lazy)**
-- alpha channel support
-- save and manage favorite colors
-- copy hex color codes to clipboard
-- clean, minimal interface
+- agave-style scheme generator modes:
+ - triads
+ - complements
+ - split complements
+ - tetrads
+ - analogous
+ - monochromatic
+- compact top swatch cards with centered overlay metadata:
+ - hex
+ - rgb
+ - hsv
+- dynamic overlay text contrast (light/dark text based on background luminance)
+- toolbar actions: back, forward, random, lighten, darken, saturate, desaturate, paste
+- click a top swatch to promote it to the active base color
+- right-click on top swatches and palette swatches to copy:
+ - hex
+ - hsv
+ - rgb
+- 12 built-in palettes:
+ - Web-safe (legacy)
+ - Material Design
+ - Tailwind CSS
+ - Flat UI
+ - Pastel
+ - Nord
+ - Dracula
+ - Solarized
+ - Gruvbox
+ - One Dark
+ - Monokai
+ - KiJiSH Dark Pastel Terminal
+- favorites panel with add/remove/rename/clear
+- clipboard copy/paste support
+- screen picker support on X11 via `xcolor` or `grabc`
+- persisted state in `~/.config/kjagave.json` (last color, scheme, palette, favorites)
## requirements
- go 1.21 or higher
- gtk3 development libraries
-- `gotk3` go bindings
-- `xcolor`
+- `gotk3` Go bindings
+- optional for pick-from-screen: `xcolor` or `grabc`
## installation
@@ -34,15 +63,11 @@ cd archlinux
makepkg -si
```
-see `archlinux/README.md` for more details.
-
### manual build
```sh
cd src
-go mod download
-go mod download github.com/gotk3/gotk3
-go build -o kjagave main.go
+go build -o kjagave .
```
## running
@@ -53,12 +78,8 @@ go build -o kjagave main.go
## usage
-1. use the color picker button to open the full color selection dialog
-2. **click "Pick from Screen" to grab a color from anywhere on your screen** - your cursor will change to a crosshair, click on any pixel to select its color
-3. click "Copy to Clipboard" to copy the hex color code
-4. click "Save..." to save the current color to your favorites list
-5. expand "Saved Colors" to view and manage your saved colors
-6. select a saved color to load it in the picker
-7. click "Delete" to remove a selected saved color
-
-saved colors are stored in `~/.config/kjagave.json` as json.
+1. pick a base color with the color button, hex entry, palette swatches, or screen picker
+2. choose a scheme type
+3. click a preview swatch to make it the active base color
+4. right-click a swatch to copy hex/hsv/rgb
+5. use favorites controls (`+`, `-`, rename, clear) to manage saved colors \ No newline at end of file
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 (
"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,11 @@ import (
)
const (
- appTitle = "kjagave"
- appVersion = "20251221-0050"
+ appTitle = "kjagave"
+ appVersion = "20260315-0200"
+ maxHistoryLen = 250
+ cardImageW = 160
+ cardImageH = 132
)
type SavedColor struct {
@@ -24,325 +31,859 @@ 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
+ rgb string
+ hsv string
+}
+
type App struct {
- window *gtk.Window
+ window *gtk.Window
+ css *gtk.CssProvider
+
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
+
+ paletteGrid *gtk.Grid
+ paletteScroll *gtk.ScrolledWindow
+
+ favoritesStore *gtk.ListStore
+ favoritesView *gtk.TreeView
+ renameFavBtn *gtk.Button
+ 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 (legacy)",
+ "Material Design",
+ "Tailwind CSS",
+ "Flat UI",
+ "Pastel",
+ "Nord",
+ "Dracula",
+ "Solarized",
+ "Gruvbox",
+ "One Dark",
+ "Monokai",
+ "KiJiSH Dark Pastel Terminal",
}
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.populatePaletteGrid()
+ 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)
-
- // vertical box
- mainBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 10)
- mainBox.SetMarginTop(15)
- mainBox.SetMarginBottom(15)
- mainBox.SetMarginStart(15)
- mainBox.SetMarginEnd(15)
+ app.window.SetDefaultSize(640, 480)
+ app.window.Connect("destroy", func() {
+ app.saveConfig()
+ gtk.MainQuit()
+ })
- // color selection area
- colorBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 10)
- colorBox.SetHAlign(gtk.ALIGN_CENTER)
+ root, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
+ root.SetMarginTop(2)
+ root.SetMarginBottom(2)
+ root.SetMarginStart(2)
+ root.SetMarginEnd(2)
+
+ menuBar := app.buildMenuBar()
+ root.PackStart(menuBar, false, false, 0)
+ app.initCompactButtonCSS()
+
+ toolbar, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 3)
+ app.historyBackBtn, _ = gtk.ButtonNewWithLabel("Back")
+ app.setButtonIcon(app.historyBackBtn, "go-previous")
+ app.historyBackBtn.Connect("clicked", func() { app.navigateHistory(-1) })
+ toolbar.PackStart(app.historyBackBtn, false, false, 0)
+
+ app.historyFwdBtn, _ = gtk.ButtonNewWithLabel("Forward")
+ app.setButtonIcon(app.historyFwdBtn, "go-next")
+ app.historyFwdBtn.Connect("clicked", func() { app.navigateHistory(1) })
+ toolbar.PackStart(app.historyFwdBtn, false, false, 0)
+
+ randomBtn, _ := gtk.ButtonNewWithLabel("Random")
+ app.setButtonIcon(randomBtn, "view-refresh")
+ randomBtn.Connect("clicked", func() { app.randomizeColor() })
+ toolbar.PackStart(randomBtn, false, false, 0)
+
+ app.lightenBtn, _ = gtk.ButtonNewWithLabel("Lighten")
+ app.setButtonIcon(app.lightenBtn, "go-up")
+ app.lightenBtn.Connect("clicked", func() { app.adjustSV(0, 5) })
+ toolbar.PackStart(app.lightenBtn, false, false, 0)
+
+ app.darkenBtn, _ = gtk.ButtonNewWithLabel("Darken")
+ app.setButtonIcon(app.darkenBtn, "go-down")
+ app.darkenBtn.Connect("clicked", func() { app.adjustSV(0, -5) })
+ toolbar.PackStart(app.darkenBtn, false, false, 0)
+
+ app.saturateBtn, _ = gtk.ButtonNewWithLabel("Saturate")
+ app.setButtonIcon(app.saturateBtn, "list-add")
+ app.saturateBtn.Connect("clicked", func() { app.adjustSV(5, 0) })
+ toolbar.PackStart(app.saturateBtn, false, false, 0)
+
+ app.desaturateBtn, _ = gtk.ButtonNewWithLabel("Desaturate")
+ app.setButtonIcon(app.desaturateBtn, "list-remove")
+ app.desaturateBtn.Connect("clicked", func() { app.adjustSV(-5, 0) })
+ toolbar.PackStart(app.desaturateBtn, false, false, 0)
+
+ pasteBtn, _ := gtk.ButtonNewWithLabel("Paste")
+ app.setButtonIcon(pasteBtn, "edit-paste")
+ pasteBtn.Connect("clicked", func() { app.pasteColorFromClipboard() })
+ toolbar.PackStart(pasteBtn, false, false, 0)
- label, _ := gtk.LabelNew("Select Color:")
- colorBox.PackStart(label, false, false, 0)
+ aboutBtn, _ := gtk.ButtonNewWithLabel("About")
+ app.setButtonIcon(aboutBtn, "help-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, 2)
+ app.swatchCards = make([]SwatchCard, 0, 4)
+ for i := 0; i < 4; i++ {
+ card := app.newSwatchCard()
+ cardIdx := i
+ card.button.Connect("button-press-event", func(_ *gtk.Button, ev *gdk.Event) bool {
+ if ev == nil {
+ return false
+ }
+ evBtn := gdk.EventButtonNewFromEvent(ev)
+ if evBtn == nil || evBtn.Button() != 3 {
+ return false
+ }
+ app.showSwatchContextMenu(cardIdx, ev)
+ return true
+ })
+ 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)
- // initialize color
+ controlRow, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6)
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)
+
+ pickBtn, _ := gtk.ButtonNewWithLabel("Pick from Screen")
+ app.setButtonIcon(pickBtn, "color-select")
+ 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")
+ app.setButtonIcon(copyBtn, "edit-copy")
+ copyBtn.Connect("clicked", func() {
+ clipboard, _ := gtk.ClipboardGet(gdk.SELECTION_CLIPBOARD)
+ clipboard.SetText(app.currentHex)
+ })
+ controlRow.PackStart(copyBtn, false, false, 0)
+
+ root.PackStart(controlRow, false, false, 0)
+
+ lower, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 4)
+
+ paletteFrame, _ := gtk.FrameNew("Palette")
+ paletteBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
+ paletteBox.SetMarginTop(2)
+ paletteBox.SetMarginBottom(2)
+ paletteBox.SetMarginStart(2)
+ paletteBox.SetMarginEnd(2)
+
+ app.paletteScroll, _ = gtk.ScrolledWindowNew(nil, nil)
+ app.paletteScroll.SetPolicy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
+ app.paletteScroll.SetSizeRequest(430, 150)
+ app.paletteGrid, _ = gtk.GridNew()
+ app.paletteGrid.SetRowSpacing(0)
+ app.paletteGrid.SetColumnSpacing(0)
+ app.paletteScroll.Add(app.paletteGrid)
+ paletteBox.PackStart(app.paletteScroll, true, true, 0)
+
+ app.paletteCombo, _ = gtk.ComboBoxTextNew()
+ for _, name := range paletteNames {
+ app.paletteCombo.AppendText(name)
+ }
+ app.paletteCombo.SetActive(0)
+ app.paletteCombo.Connect("changed", func() {
+ app.populatePaletteGrid()
+ app.saveConfig()
})
+ paletteBox.PackStart(app.paletteCombo, false, false, 0)
+ paletteFrame.Add(paletteBox)
+ lower.PackStart(paletteFrame, true, true, 0)
+
+ favFrame, _ := gtk.FrameNew("Favorites")
+ favBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
+ favBox.SetMarginTop(2)
+ favBox.SetMarginBottom(2)
+ favBox.SetMarginStart(2)
+ favBox.SetMarginEnd(2)
+
+ favScroll, _ := gtk.ScrolledWindowNew(nil, nil)
+ favScroll.SetPolicy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
+ favScroll.SetSizeRequest(230, 180)
+
+ 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)
+ app.favoritesView.Connect("row-activated", func() {
+ app.renameSelectedFavorite()
+ })
+
+ favScroll.Add(app.favoritesView)
+ favBox.PackStart(favScroll, true, true, 0)
+
+ favBtns, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 4)
+ addFavBtn, _ := gtk.ButtonNewWithLabel("+")
+ app.setButtonIcon(addFavBtn, "list-add")
+ addFavBtn.Connect("clicked", func() { app.addCurrentToFavorites() })
+ favBtns.PackStart(addFavBtn, true, true, 0)
+
+ app.removeFavBtn, _ = gtk.ButtonNewWithLabel("-")
+ app.setButtonIcon(app.removeFavBtn, "list-remove")
+ app.removeFavBtn.Connect("clicked", func() { app.removeSelectedFavorite() })
+ favBtns.PackStart(app.removeFavBtn, true, true, 0)
+
+ app.renameFavBtn, _ = gtk.ButtonNewWithLabel("Rename")
+ app.setButtonIcon(app.renameFavBtn, "document-edit")
+ app.renameFavBtn.Connect("clicked", func() { app.renameSelectedFavorite() })
+ favBtns.PackStart(app.renameFavBtn, true, true, 0)
+
+ app.clearFavBtn, _ = gtk.ButtonNewWithLabel("Clear")
+ app.setButtonIcon(app.clearFavBtn, "edit-clear")
+ app.clearFavBtn.Connect("clicked", func() {
+ app.savedColors = nil
+ app.refreshFavoritesView()
+ app.saveConfig()
+ })
+ favBtns.PackStart(app.clearFavBtn, true, true, 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)
+ favBox.PackStart(favBtns, false, false, 0)
+ favFrame.Add(favBox)
+ lower.PackStart(favFrame, false, false, 0)
- aboutBtn, _ := gtk.ButtonNewWithLabel("About")
- aboutBtn.Connect("clicked", app.onAboutClicked)
- bottomBox.PackStart(aboutBtn, false, false, 0)
+ root.PackStart(lower, true, true, 0)
- mainBox.PackStart(bottomBox, false, false, 0)
+ status, _ := gtk.LabelNew("Choose a color and a scheme type")
+ status.SetHAlign(gtk.ALIGN_START)
+ root.PackStart(status, false, false, 0)
- app.window.Add(mainBox)
+ app.window.Add(root)
app.window.ShowAll()
}
-func (app *App) populateList() {
- app.listStore.Clear()
+func (app *App) buildMenuBar() *gtk.MenuBar {
+ menuBar, _ := gtk.MenuBarNew()
+
+ fileTop, _ := gtk.MenuItemNewWithLabel("File")
+ fileMenu, _ := gtk.MenuNew()
+ random, _ := gtk.MenuItemNewWithLabel("Random")
+ random.Connect("activate", func() { app.randomizeColor() })
+ fileMenu.Append(random)
+ quit, _ := gtk.MenuItemNewWithLabel("Quit")
+ quit.Connect("activate", func() {
+ app.saveConfig()
+ gtk.MainQuit()
+ })
+ fileMenu.Append(quit)
+ fileTop.SetSubmenu(fileMenu)
+ menuBar.Append(fileTop)
+
+ editTop, _ := gtk.MenuItemNewWithLabel("Edit")
+ editMenu, _ := gtk.MenuNew()
+ paste, _ := gtk.MenuItemNewWithLabel("Paste")
+ paste.Connect("activate", func() { app.pasteColorFromClipboard() })
+ editMenu.Append(paste)
+ editTop.SetSubmenu(editMenu)
+ menuBar.Append(editTop)
+
+ favTop, _ := gtk.MenuItemNewWithLabel("Favorites")
+ favMenu, _ := gtk.MenuNew()
+ add, _ := gtk.MenuItemNewWithLabel("Add Current")
+ add.Connect("activate", func() { app.addCurrentToFavorites() })
+ favMenu.Append(add)
+ rename, _ := gtk.MenuItemNewWithLabel("Rename Selected")
+ rename.Connect("activate", func() { app.renameSelectedFavorite() })
+ favMenu.Append(rename)
+ favTop.SetSubmenu(favMenu)
+ menuBar.Append(favTop)
+
+ helpTop, _ := gtk.MenuItemNewWithLabel("Help")
+ helpMenu, _ := gtk.MenuNew()
+ about, _ := gtk.MenuItemNewWithLabel("About")
+ about.Connect("activate", func() { app.onAboutClicked() })
+ helpMenu.Append(about)
+ helpTop.SetSubmenu(helpMenu)
+ menuBar.Append(helpTop)
+
+ return menuBar
+}
- 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})
+func (app *App) initCompactButtonCSS() {
+ app.css, _ = gtk.CssProviderNew()
+ 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; }"
+ _ = app.css.LoadFromData(css)
+ if screen, err := gdk.ScreenGetDefault(); err == nil && screen != nil {
+ gtk.AddProviderForScreen(screen, app.css, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
}
}
-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) setButtonIcon(btn *gtk.Button, iconName string) {
+ img, err := gtk.ImageNewFromIconName(iconName, gtk.ICON_SIZE_BUTTON)
+ if err != nil || img == nil {
+ return
+ }
+ if label, err := btn.GetLabel(); err == nil && strings.TrimSpace(label) != "" {
+ btn.SetTooltipText(label)
+ btn.SetLabel("")
}
+ btn.SetImage(img)
+ btn.SetAlwaysShowImage(true)
+}
- rgba := gdk.NewRGBA()
- rgba.Parse(hexColor)
+func (app *App) newSwatchCard() SwatchCard {
+ button, _ := gtk.ButtonNew()
+ button.SetSizeRequest(166, 138)
+
+ vbox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
+ vbox.SetMarginTop(1)
+ vbox.SetMarginBottom(1)
+ vbox.SetMarginStart(1)
+ vbox.SetMarginEnd(1)
+
+ overlay, _ := gtk.OverlayNew()
+ overlay.SetHExpand(true)
+ overlay.SetVExpand(true)
+
+ image, _ := gtk.ImageNew()
+ image.SetFromPixbuf(solidPixbuf("#000000", cardImageW, cardImageH))
+ overlay.Add(image)
+
+ label, _ := gtk.LabelNew("")
+ label.SetJustify(gtk.JUSTIFY_CENTER)
+ label.SetHAlign(gtk.ALIGN_CENTER)
+ label.SetVAlign(gtk.ALIGN_CENTER)
+ if ctx, err := label.GetStyleContext(); err == nil {
+ ctx.AddClass("swatch-overlay-label")
+ }
+ overlay.AddOverlay(label)
+ vbox.PackStart(overlay, true, true, 0)
+
+ button.Add(vbox)
+
+ return SwatchCard{button: button, image: image, label: label}
+}
- r := uint32(rgba.GetRed() * 255)
- g := uint32(rgba.GetGreen() * 255)
- b := uint32(rgba.GetBlue() * 255)
+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
- pixels := pixbuf.GetPixels()
- rowstride := pixbuf.GetRowstride()
- nChannels := pixbuf.GetNChannels()
+ if pushHistory {
+ app.pushHistory(hex)
+ }
- 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].hex = ""
+ app.swatchCards[i].rgb = ""
+ app.swatchCards[i].hsv = ""
+ app.swatchCards[i].button.Hide()
+ continue
}
+ app.swatchCards[i].button.Show()
+ app.swatchCards[i].button.SetSensitive(true)
+ 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))
+ textColor := "#F5F5F5"
+ if luminance(colors[i]) > 0.53 {
+ textColor = "#111111"
+ }
+ rgbText := fmt.Sprintf("rgb(%d, %d, %d)", r, g, b)
+ hsvText := fmt.Sprintf("hsv(%d, %d, %d)", int(h), int(s), int(v))
+ app.swatchCards[i].hex = hex
+ app.swatchCards[i].rgb = rgbText
+ app.swatchCards[i].hsv = hsvText
+ app.swatchCards[i].image.SetFromPixbuf(solidPixbuf(hex, cardImageW, cardImageH))
+ app.swatchCards[i].label.SetMarkup(fmt.Sprintf("<span foreground=\"%s\" size=\"9000\"><b>%s</b>\n%s\n%s</span>", textColor, hex, rgbText, hsvText))
}
+}
- return pixbuf
+func (app *App) copyTextToClipboard(text string) {
+ if strings.TrimSpace(text) == "" {
+ return
+ }
+ clipboard, _ := gtk.ClipboardGet(gdk.SELECTION_CLIPBOARD)
+ clipboard.SetText(text)
}
-func (app *App) onSelectionChanged(selection *gtk.TreeSelection) {
- model, iter, ok := selection.GetSelected()
- if !ok {
- app.deleteBtn.SetSensitive(false)
- app.selectedIter = nil
+func (app *App) showSwatchContextMenu(cardIdx int, ev *gdk.Event) {
+ if cardIdx < 0 || cardIdx >= len(app.swatchCards) {
+ return
+ }
+ card := app.swatchCards[cardIdx]
+ if card.hex == "" {
return
}
+ app.showColorContextMenu(card.hex, card.rgb, card.hsv, ev)
+}
- app.selectedIter = iter
- app.deleteBtn.SetSensitive(true)
+func (app *App) showColorContextMenu(hex, rgbText, hsvText string, ev *gdk.Event) {
+ if strings.TrimSpace(hex) == "" {
+ return
+ }
- value, _ := model.ToTreeModel().GetValue(iter, 1)
- hexColor, _ := value.GetString()
+ menu, _ := gtk.MenuNew()
+ copyHex, _ := gtk.MenuItemNewWithLabel("Copy HEX")
+ copyHex.Connect("activate", func() {
+ app.copyTextToClipboard(hex)
+ })
+ menu.Append(copyHex)
- rgba := gdk.NewRGBA()
- rgba.Parse(hexColor)
- app.colorButton.SetRGBA(rgba)
- app.currentColor = rgba
+ copyHSV, _ := gtk.MenuItemNewWithLabel("Copy HSV")
+ copyHSV.Connect("activate", func() {
+ app.copyTextToClipboard(hsvText)
+ })
+ menu.Append(copyHSV)
+
+ copyRGB, _ := gtk.MenuItemNewWithLabel("Copy RGB (RGV)")
+ copyRGB.Connect("activate", func() {
+ app.copyTextToClipboard(rgbText)
+ })
+ menu.Append(copyRGB)
+
+ menu.ShowAll()
+ menu.PopupAtPointer(ev)
}
-func (app *App) onSaveClicked() {
- dialog, _ := gtk.DialogNew()
- dialog.SetTitle("Save Color")
- dialog.SetTransientFor(app.window)
- dialog.SetModal(true)
- dialog.SetDefaultSize(300, -1)
+func colorStringsFromHex(hex string) (string, string) {
+ rgba := gdk.NewRGBA()
+ if !rgba.Parse(hex) {
+ return "", ""
+ }
+ r := int(math.Round(rgba.GetRed() * 255))
+ g := int(math.Round(rgba.GetGreen() * 255))
+ b := int(math.Round(rgba.GetBlue() * 255))
+ h, s, v := rgbToHSV(rgba)
+ return fmt.Sprintf("rgb(%d, %d, %d)", r, g, b), fmt.Sprintf("hsv(%d, %d, %d)", int(h), int(s), int(v))
+}
- box, _ := dialog.GetContentArea()
- box.SetSpacing(10)
- box.SetMarginTop(10)
- box.SetMarginBottom(10)
- box.SetMarginStart(10)
- box.SetMarginEnd(10)
+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)
+}
- // get current color
- hexColor := rgbaToHex(app.currentColor)
+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)
+}
- label, _ := gtk.LabelNew(fmt.Sprintf("Color: %s", hexColor))
- box.PackStart(label, false, false, 0)
+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)
+}
- entryLabel, _ := gtk.LabelNew("Color Name:")
- entryLabel.SetHAlign(gtk.ALIGN_START)
- box.PackStart(entryLabel, false, false, 0)
+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()
+ if rgba.Parse(text) {
+ app.setCurrentColor(rgba, true)
+ }
+}
- entry, _ := gtk.EntryNew()
- entry.SetText("Untitled")
- entry.SetActivatesDefault(true)
- box.PackStart(entry, false, false, 0)
+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
+}
- dialog.AddButton("Cancel", gtk.RESPONSE_CANCEL)
- okBtn, _ := dialog.AddButton("OK", gtk.RESPONSE_OK)
- okBtn.SetCanDefault(true)
- okBtn.GrabDefault()
+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)
+}
- dialog.ShowAll()
+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.renameFavBtn.SetSensitive(app.selectedIter != nil)
+ app.removeFavBtn.SetSensitive(app.selectedIter != nil)
+ app.clearFavBtn.SetSensitive(len(app.savedColors) > 0)
+}
- 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) activeSchemeName() string {
+ idx := app.schemeCombo.GetActive()
+ if idx < 0 || idx >= len(schemeNames) {
+ return schemeNames[0]
}
+ return schemeNames[idx]
+}
- dialog.Destroy()
+func (app *App) activePaletteName() string {
+ idx := app.paletteCombo.GetActive()
+ if idx < 0 || idx >= len(paletteNames) {
+ return paletteNames[0]
+ }
+ return paletteNames[idx]
}
-func (app *App) onDeleteClicked() {
- if app.selectedIter == nil {
+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()
+}
- model := app.listStore.ToTreeModel()
- value, _ := model.GetValue(app.selectedIter, 1)
- hexColor, _ := value.GetString()
+func (app *App) populatePaletteGrid() {
+ children := app.paletteGrid.GetChildren()
+ children.Foreach(func(item interface{}) {
+ if widget, ok := item.(*gtk.Widget); ok {
+ app.paletteGrid.Remove(widget)
+ }
+ })
+
+ colors := paletteByName(app.activePaletteName())
+ cols := 24
+ for i, hex := range colors {
+ btn, _ := gtk.ButtonNew()
+ btn.SetRelief(gtk.RELIEF_NONE)
+ btn.SetCanFocus(false)
+ btn.SetSizeRequest(16, 11)
+ if ctx, err := btn.GetStyleContext(); err == nil {
+ ctx.AddClass("palette-swatch")
+ }
+ img, _ := gtk.ImageNewFromPixbuf(solidPixbuf(hex, 16, 11))
+ btn.Add(img)
+ h := hex
+ btn.Connect("button-press-event", func(_ *gtk.Button, ev *gdk.Event) bool {
+ if ev == nil {
+ return false
+ }
+ evBtn := gdk.EventButtonNewFromEvent(ev)
+ if evBtn == nil || evBtn.Button() != 3 {
+ return false
+ }
+ rgbText, hsvText := colorStringsFromHex(h)
+ app.showColorContextMenu(h, rgbText, hsvText, ev)
+ return true
+ })
+ btn.Connect("clicked", func() {
+ rgba := gdk.NewRGBA()
+ if rgba.Parse(h) {
+ app.setCurrentColor(rgba, true)
+ }
+ })
+ app.paletteGrid.Attach(btn, i%cols, i/cols, 1, 1)
+ }
+ app.paletteGrid.ShowAll()
+}
- // remove from saved colors
- for i, color := range app.savedColors {
- if color.Hex == hexColor {
+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) removeSelectedFavorite() {
+ if app.selectedIter == nil {
+ return
+ }
+ 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) renameSelectedFavorite() {
+ if app.selectedIter == nil {
+ return
+ }
+ vHex, _ := app.favoritesStore.GetValue(app.selectedIter, 1)
+ hex, _ := vHex.GetString()
+ vName, _ := app.favoritesStore.GetValue(app.selectedIter, 2)
+ currentName, _ := vName.GetString()
- 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, _ := gtk.DialogNew()
+ dialog.SetTitle("Rename Color")
+ dialog.SetTransientFor(app.window)
+ dialog.SetModal(true)
+ box, _ := dialog.GetContentArea()
+ box.SetMarginTop(10)
+ box.SetMarginBottom(10)
+ box.SetMarginStart(10)
+ box.SetMarginEnd(10)
+ box.SetSpacing(6)
+ label, _ := gtk.LabelNew("Enter a new name:")
+ label.SetHAlign(gtk.ALIGN_START)
+ box.PackStart(label, false, false, 0)
+ entry, _ := gtk.EntryNew()
+ entry.SetText(currentName)
+ entry.SetActivatesDefault(true)
+ box.PackStart(entry, false, false, 0)
+ dialog.AddButton("Cancel", gtk.RESPONSE_CANCEL)
+ okBtn, _ := dialog.AddButton("OK", gtk.RESPONSE_OK)
+ okBtn.SetCanDefault(true)
+ okBtn.GrabDefault()
+ dialog.ShowAll()
+ if dialog.Run() == gtk.RESPONSE_OK {
+ newName, _ := entry.GetText()
+ newName = strings.TrimSpace(newName)
+ if newName != "" {
+ for i := range app.savedColors {
+ if strings.EqualFold(app.savedColors[i].Hex, hex) {
+ app.savedColors[i].Name = newName
+ break
+ }
+ }
+ app.refreshFavoritesView()
+ app.saveConfig()
+ }
+ }
dialog.Destroy()
}
+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()
+}
+
func (app *App) onAboutClicked() {
dialog, _ := gtk.AboutDialogNew()
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 +891,384 @@ 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 "Web-safe (legacy)":
+ 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
+ case "Material Design":
+ return []string{
+ "#F44336", "#E91E63", "#9C27B0", "#673AB7", "#3F51B5", "#2196F3",
+ "#03A9F4", "#00BCD4", "#009688", "#4CAF50", "#8BC34A", "#CDDC39",
+ "#FFEB3B", "#FFC107", "#FF9800", "#FF5722", "#795548", "#9E9E9E",
+ "#607D8B", "#000000", "#FFFFFF", "#EF5350", "#EC407A", "#AB47BC",
+ "#7E57C2", "#5C6BC0", "#42A5F5", "#29B6F6", "#26C6DA", "#26A69A",
+ "#66BB6A", "#9CCC65", "#D4E157", "#FFEE58", "#FFCA28", "#FFA726",
+ "#FF7043", "#8D6E63", "#BDBDBD", "#78909C", "#212121", "#FAFAFA",
+ "#C62828", "#AD1457", "#6A1B9A", "#4527A0", "#283593", "#1565C0",
+ }
+ case "Tailwind CSS":
+ return []string{
+ "#EF4444", "#F97316", "#F59E0B", "#EAB308", "#84CC16", "#22C55E",
+ "#10B981", "#14B8A6", "#06B6D4", "#0EA5E9", "#3B82F6", "#6366F1",
+ "#8B5CF6", "#A855F7", "#D946EF", "#EC4899", "#F43F5E", "#64748B",
+ "#DC2626", "#EA580C", "#D97706", "#CA8A04", "#65A30D", "#16A34A",
+ "#059669", "#0D9488", "#0891B2", "#0284C7", "#2563EB", "#4F46E5",
+ "#7C3AED", "#9333EA", "#C026D3", "#DB2777", "#E11D48", "#475569",
+ "#991B1B", "#9A3412", "#92400E", "#854D0E", "#3F6212", "#14532D",
+ "#064E3B", "#134E4A", "#164E63", "#075985", "#1E3A8A", "#312E81",
+ }
+ case "Flat UI":
+ return []string{
+ "#1ABC9C", "#16A085", "#2ECC71", "#27AE60", "#3498DB", "#2980B9",
+ "#9B59B6", "#8E44AD", "#34495E", "#2C3E50", "#F1C40F", "#F39C12",
+ "#E67E22", "#D35400", "#E74C3C", "#C0392B", "#ECF0F1", "#BDC3C7",
+ "#95A5A6", "#7F8C8D", "#52B3D9", "#E8F8F5", "#D5F4E6", "#D6EAF8",
+ "#E8DAEF", "#FADBD8", "#F9E79F", "#FAD7A0", "#F5B7B1", "#D7DBDD",
+ }
+ case "Pastel":
+ return []string{
+ "#FFB3BA", "#FFDFBA", "#FFFFBA", "#BAFFC9", "#BAE1FF", "#E0BBE4",
+ "#FFDFD3", "#FEC8D8", "#D5AAFF", "#B4F8C8", "#A0E7E5", "#FFAEBC",
+ "#FBE7C6", "#B4F8C8", "#A0C4FF", "#BDB2FF", "#FFC6FF", "#FDFFB6",
+ "#CAFFBF", "#9BF6FF", "#A0C4FF", "#BDB2FF", "#FFC6FF", "#FFFFFC",
+ "#FFD6A5", "#FDFFB6", "#CAFFBF", "#A8E6CF", "#FFD3B6", "#FFAAA5",
+ }
+ case "Nord":
+ return []string{
+ "#2E3440", "#3B4252", "#434C5E", "#4C566A", "#D8DEE9", "#E5E9F0",
+ "#ECEFF4", "#8FBCBB", "#88C0D0", "#81A1C1", "#5E81AC", "#BF616A",
+ "#D08770", "#EBCB8B", "#A3BE8C", "#B48EAD", "#4C566A", "#434C5E",
+ "#3B4252", "#2E3440", "#ECEFF4", "#E5E9F0", "#D8DEE9", "#88C0D0",
+ }
+ case "Dracula":
+ return []string{
+ "#282A36", "#44475A", "#F8F8F2", "#6272A4", "#8BE9FD", "#50FA7B",
+ "#FFB86C", "#FF79C6", "#BD93F9", "#FF5555", "#F1FA8C", "#21222C",
+ "#191A21", "#6272A4", "#B45BCF", "#4D4F68", "#626483", "#62D6E8",
+ "#EA51B2", "#EBFF87", "#00F769", "#B45BCF", "#7081D0", "#A1EFE4",
+ }
+ case "Solarized":
+ return []string{
+ "#002B36", "#073642", "#586E75", "#657B83", "#839496", "#93A1A1",
+ "#EEE8D5", "#FDF6E3", "#B58900", "#CB4B16", "#DC322F", "#D33682",
+ "#6C71C4", "#268BD2", "#2AA198", "#859900", "#002B36", "#073642",
+ "#586E75", "#657B83", "#839496", "#93A1A1", "#EEE8D5", "#FDF6E3",
+ }
+ case "Gruvbox":
+ return []string{
+ "#282828", "#CC241D", "#98971A", "#D79921", "#458588", "#B16286",
+ "#689D6A", "#A89984", "#928374", "#FB4934", "#B8BB26", "#FABD2F",
+ "#83A598", "#D3869B", "#8EC07C", "#EBDBB2", "#FBF1C7", "#3C3836",
+ "#504945", "#665C54", "#7C6F64", "#D65D0E", "#FE8019", "#BDAE93",
+ }
+ case "One Dark":
+ return []string{
+ "#282C34", "#ABB2BF", "#E06C75", "#D19A66", "#E5C07B", "#98C379",
+ "#56B6C2", "#61AFEF", "#C678DD", "#BE5046", "#3B4048", "#4B5263",
+ "#545862", "#565C64", "#5C6370", "#636D83", "#828997", "#2C323C",
+ "#353B45", "#3E4451", "#4F5666", "#5F697A", "#6B7587", "#979EAB",
+ }
+ case "Monokai":
+ return []string{
+ "#272822", "#F8F8F2", "#F92672", "#E6DB74", "#A6E22E", "#66D9EF",
+ "#AE81FF", "#FD971F", "#75715E", "#49483E", "#3E3D32", "#F8F8F0",
+ "#F5F4F1", "#A59F85", "#FD5FF0", "#F4BF75", "#FFF59D", "#CFCFC2",
+ "#A1EFE4", "#FFE792", "#CC6633", "#778899", "#9D550F", "#E69F66",
+ }
+ case "KiJiSH Dark Pastel Terminal":
+ return []string{
+ "#2C2C2C", "#DCDCDC", "#3F3F3F", "#D67979", "#60B48A", "#DFAF8F",
+ "#9AB8D7", "#DC8CC3", "#8CD0D3", "#DCDCDC", "#709080", "#DCA3A3",
+ "#72D5A3", "#F0DFAF", "#94BFF3", "#EC93D3", "#93E0E3", "#FFFFFF",
+ }
+ 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 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
+}