diff options
| -rw-r--r-- | src/.gitignore | 2 | ||||
| -rw-r--r-- | src/go.mod | 5 | ||||
| -rw-r--r-- | src/main.go | 411 |
3 files changed, 418 insertions, 0 deletions
diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..973bad1 --- /dev/null +++ b/src/.gitignore | |||
| @@ -0,0 +1,2 @@ | |||
| 1 | kjagave | ||
| 2 | go.sum \ No newline at end of file | ||
diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..08ebd30 --- /dev/null +++ b/src/go.mod | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | module kjagave | ||
| 2 | |||
| 3 | go 1.21 | ||
| 4 | |||
| 5 | require github.com/gotk3/gotk3 v0.6.3 | ||
diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..d734583 --- /dev/null +++ b/src/main.go | |||
| @@ -0,0 +1,411 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "log" | ||
| 7 | "os" | ||
| 8 | "os/exec" | ||
| 9 | "path/filepath" | ||
| 10 | "strings" | ||
| 11 | |||
| 12 | "github.com/gotk3/gotk3/gdk" | ||
| 13 | "github.com/gotk3/gotk3/glib" | ||
| 14 | "github.com/gotk3/gotk3/gtk" | ||
| 15 | ) | ||
| 16 | |||
| 17 | const ( | ||
| 18 | appTitle = "kjagave" | ||
| 19 | appVersion = "1.0" | ||
| 20 | ) | ||
| 21 | |||
| 22 | type SavedColor struct { | ||
| 23 | Hex string `json:"hex"` | ||
| 24 | Name string `json:"name"` | ||
| 25 | } | ||
| 26 | |||
| 27 | type App struct { | ||
| 28 | window *gtk.Window | ||
| 29 | colorButton *gtk.ColorButton | ||
| 30 | currentColor *gdk.RGBA | ||
| 31 | listStore *gtk.ListStore | ||
| 32 | treeView *gtk.TreeView | ||
| 33 | deleteBtn *gtk.Button | ||
| 34 | savedColors []SavedColor | ||
| 35 | configFile string | ||
| 36 | selectedIter *gtk.TreeIter | ||
| 37 | } | ||
| 38 | |||
| 39 | func main() { | ||
| 40 | gtk.Init(nil) | ||
| 41 | |||
| 42 | configDir := filepath.Join(os.Getenv("HOME"), ".config") | ||
| 43 | os.MkdirAll(configDir, 0755) | ||
| 44 | |||
| 45 | app := &App{ | ||
| 46 | configFile: filepath.Join(configDir, "kjagave.json"), | ||
| 47 | } | ||
| 48 | |||
| 49 | app.loadColors() | ||
| 50 | app.createUI() | ||
| 51 | app.populateList() | ||
| 52 | |||
| 53 | gtk.Main() | ||
| 54 | } | ||
| 55 | |||
| 56 | func (app *App) createUI() { | ||
| 57 | var err error | ||
| 58 | |||
| 59 | // main window | ||
| 60 | app.window, err = gtk.WindowNew(gtk.WINDOW_TOPLEVEL) | ||
| 61 | if err != nil { | ||
| 62 | log.Fatal("Unable to create window:", err) | ||
| 63 | } | ||
| 64 | app.window.SetTitle(appTitle) | ||
| 65 | app.window.SetDefaultSize(550, 450) | ||
| 66 | app.window.SetResizable(false) | ||
| 67 | app.window.Connect("destroy", gtk.MainQuit) | ||
| 68 | |||
| 69 | // vertical box | ||
| 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 | |||
| 76 | // color selection area | ||
| 77 | colorBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 10) | ||
| 78 | colorBox.SetHAlign(gtk.ALIGN_CENTER) | ||
| 79 | |||
| 80 | label, _ := gtk.LabelNew("Select Color:") | ||
| 81 | colorBox.PackStart(label, false, false, 0) | ||
| 82 | |||
| 83 | // initialize color | ||
| 84 | app.currentColor = gdk.NewRGBA() | ||
| 85 | app.currentColor.Parse("#69BAA7") | ||
| 86 | |||
| 87 | app.colorButton, err = gtk.ColorButtonNewWithRGBA(app.currentColor) | ||
| 88 | if err != nil { | ||
| 89 | log.Fatal("Unable to create color button:", err) | ||
| 90 | } | ||
| 91 | app.colorButton.SetUseAlpha(true) | ||
| 92 | app.colorButton.SetTitle("Choose a Color") | ||
| 93 | app.colorButton.Connect("color-set", func() { | ||
| 94 | app.currentColor = app.colorButton.GetRGBA() | ||
| 95 | }) | ||
| 96 | |||
| 97 | colorBox.PackStart(app.colorButton, false, false, 0) | ||
| 98 | |||
| 99 | hexEntry, _ := gtk.EntryNew() | ||
| 100 | hexEntry.SetEditable(false) | ||
| 101 | hexEntry.SetWidthChars(10) | ||
| 102 | hexEntry.SetText(rgbaToHex(app.currentColor)) | ||
| 103 | colorBox.PackStart(hexEntry, false, false, 0) | ||
| 104 | |||
| 105 | // color picker button | ||
| 106 | pickerBtn, _ := gtk.ButtonNewWithLabel("Pick from Screen") | ||
| 107 | pickerBtn.Connect("clicked", func() { | ||
| 108 | if color, err := app.pickColorFromScreen(); err == nil { | ||
| 109 | app.colorButton.SetRGBA(color) | ||
| 110 | app.currentColor = color | ||
| 111 | hexEntry.SetText(rgbaToHex(color)) | ||
| 112 | } | ||
| 113 | }) | ||
| 114 | colorBox.PackStart(pickerBtn, false, false, 0) | ||
| 115 | |||
| 116 | // bump hex entry when color changes | ||
| 117 | app.colorButton.Connect("color-set", func() { | ||
| 118 | app.currentColor = app.colorButton.GetRGBA() | ||
| 119 | hexEntry.SetText(rgbaToHex(app.currentColor)) | ||
| 120 | }) | ||
| 121 | |||
| 122 | mainBox.PackStart(colorBox, false, false, 0) | ||
| 123 | |||
| 124 | expander, _ := gtk.ExpanderNew("Saved Colors") | ||
| 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 | |||
| 188 | aboutBtn, _ := gtk.ButtonNewWithLabel("About") | ||
| 189 | aboutBtn.Connect("clicked", app.onAboutClicked) | ||
| 190 | bottomBox.PackStart(aboutBtn, false, false, 0) | ||
| 191 | |||
| 192 | mainBox.PackStart(bottomBox, false, false, 0) | ||
| 193 | |||
| 194 | app.window.Add(mainBox) | ||
| 195 | app.window.ShowAll() | ||
| 196 | } | ||
| 197 | |||
| 198 | func (app *App) populateList() { | ||
| 199 | app.listStore.Clear() | ||
| 200 | |||
| 201 | for _, color := range app.savedColors { | ||
| 202 | pixbuf := app.createColorSwatch(color.Hex) | ||
| 203 | iter := app.listStore.Append() | ||
| 204 | app.listStore.Set(iter, []int{0, 1, 2}, []interface{}{pixbuf, color.Hex, color.Name}) | ||
| 205 | } | ||
| 206 | } | ||
| 207 | |||
| 208 | func (app *App) createColorSwatch(hexColor string) *gdk.Pixbuf { | ||
| 209 | pixbuf, err := gdk.PixbufNew(gdk.COLORSPACE_RGB, false, 8, 16, 14) | ||
| 210 | if err != nil { | ||
| 211 | return nil | ||
| 212 | } | ||
| 213 | |||
| 214 | rgba := gdk.NewRGBA() | ||
| 215 | rgba.Parse(hexColor) | ||
| 216 | |||
| 217 | r := uint32(rgba.GetRed() * 255) | ||
| 218 | g := uint32(rgba.GetGreen() * 255) | ||
| 219 | b := uint32(rgba.GetBlue() * 255) | ||
| 220 | |||
| 221 | pixels := pixbuf.GetPixels() | ||
| 222 | rowstride := pixbuf.GetRowstride() | ||
| 223 | nChannels := pixbuf.GetNChannels() | ||
| 224 | |||
| 225 | for y := 0; y < 14; y++ { | ||
| 226 | for x := 0; x < 16; x++ { | ||
| 227 | offset := y*rowstride + x*nChannels | ||
| 228 | pixels[offset] = byte(r) | ||
| 229 | pixels[offset+1] = byte(g) | ||
| 230 | pixels[offset+2] = byte(b) | ||
| 231 | } | ||
| 232 | } | ||
| 233 | |||
| 234 | return pixbuf | ||
| 235 | } | ||
| 236 | |||
| 237 | func (app *App) onSelectionChanged(selection *gtk.TreeSelection) { | ||
| 238 | model, iter, ok := selection.GetSelected() | ||
| 239 | if !ok { | ||
| 240 | app.deleteBtn.SetSensitive(false) | ||
| 241 | app.selectedIter = nil | ||
| 242 | return | ||
| 243 | } | ||
| 244 | |||
| 245 | app.selectedIter = iter | ||
| 246 | app.deleteBtn.SetSensitive(true) | ||
| 247 | |||
| 248 | value, _ := model.ToTreeModel().GetValue(iter, 1) | ||
| 249 | hexColor, _ := value.GetString() | ||
| 250 | |||
| 251 | rgba := gdk.NewRGBA() | ||
| 252 | rgba.Parse(hexColor) | ||
| 253 | app.colorButton.SetRGBA(rgba) | ||
| 254 | app.currentColor = rgba | ||
| 255 | } | ||
| 256 | |||
| 257 | func (app *App) onSaveClicked() { | ||
| 258 | dialog, _ := gtk.DialogNew() | ||
| 259 | dialog.SetTitle("Save Color") | ||
| 260 | dialog.SetTransientFor(app.window) | ||
| 261 | dialog.SetModal(true) | ||
| 262 | dialog.SetDefaultSize(300, -1) | ||
| 263 | |||
| 264 | box, _ := dialog.GetContentArea() | ||
| 265 | box.SetSpacing(10) | ||
| 266 | box.SetMarginTop(10) | ||
| 267 | box.SetMarginBottom(10) | ||
| 268 | box.SetMarginStart(10) | ||
| 269 | box.SetMarginEnd(10) | ||
| 270 | |||
| 271 | // get current color | ||
| 272 | hexColor := rgbaToHex(app.currentColor) | ||
| 273 | |||
| 274 | label, _ := gtk.LabelNew(fmt.Sprintf("Color: %s", hexColor)) | ||
| 275 | box.PackStart(label, false, false, 0) | ||
| 276 | |||
| 277 | entryLabel, _ := gtk.LabelNew("Color Name:") | ||
| 278 | entryLabel.SetHAlign(gtk.ALIGN_START) | ||
| 279 | box.PackStart(entryLabel, false, false, 0) | ||
| 280 | |||
| 281 | entry, _ := gtk.EntryNew() | ||
| 282 | entry.SetText("Untitled") | ||
| 283 | entry.SetActivatesDefault(true) | ||
| 284 | box.PackStart(entry, false, false, 0) | ||
| 285 | |||
| 286 | dialog.AddButton("Cancel", gtk.RESPONSE_CANCEL) | ||
| 287 | okBtn, _ := dialog.AddButton("OK", gtk.RESPONSE_OK) | ||
| 288 | okBtn.SetCanDefault(true) | ||
| 289 | okBtn.GrabDefault() | ||
| 290 | |||
| 291 | dialog.ShowAll() | ||
| 292 | |||
| 293 | response := dialog.Run() | ||
| 294 | if response == gtk.RESPONSE_OK { | ||
| 295 | text, _ := entry.GetText() | ||
| 296 | app.savedColors = append([]SavedColor{{Hex: hexColor, Name: text}}, app.savedColors...) | ||
| 297 | app.saveColors() | ||
| 298 | app.populateList() | ||
| 299 | } | ||
| 300 | |||
| 301 | dialog.Destroy() | ||
| 302 | } | ||
| 303 | |||
| 304 | func (app *App) onDeleteClicked() { | ||
| 305 | if app.selectedIter == nil { | ||
| 306 | return | ||
| 307 | } | ||
| 308 | |||
| 309 | model := app.listStore.ToTreeModel() | ||
| 310 | value, _ := model.GetValue(app.selectedIter, 1) | ||
| 311 | hexColor, _ := value.GetString() | ||
| 312 | |||
| 313 | // remove from saved colors | ||
| 314 | for i, color := range app.savedColors { | ||
| 315 | if color.Hex == hexColor { | ||
| 316 | app.savedColors = append(app.savedColors[:i], app.savedColors[i+1:]...) | ||
| 317 | break | ||
| 318 | } | ||
| 319 | } | ||
| 320 | |||
| 321 | app.saveColors() | ||
| 322 | app.populateList() | ||
| 323 | app.deleteBtn.SetSensitive(false) | ||
| 324 | app.selectedIter = nil | ||
| 325 | } | ||
| 326 | |||
| 327 | func (app *App) onCopyClicked() { | ||
| 328 | hexColor := rgbaToHex(app.currentColor) | ||
| 329 | |||
| 330 | clipboard, _ := gtk.ClipboardGet(gdk.SELECTION_CLIPBOARD) | ||
| 331 | clipboard.SetText(hexColor) | ||
| 332 | |||
| 333 | dialog := gtk.MessageDialogNew(app.window, gtk.DIALOG_MODAL, gtk.MESSAGE_INFO, | ||
| 334 | gtk.BUTTONS_OK, fmt.Sprintf("Color %s copied to clipboard!", hexColor)) | ||
| 335 | dialog.Run() | ||
| 336 | dialog.Destroy() | ||
| 337 | } | ||
| 338 | |||
| 339 | func (app *App) onAboutClicked() { | ||
| 340 | dialog, _ := gtk.AboutDialogNew() | ||
| 341 | dialog.SetTransientFor(app.window) | ||
| 342 | dialog.SetProgramName(appTitle) | ||
| 343 | dialog.SetVersion(appVersion) | ||
| 344 | dialog.SetComments("A color picker with screen color grabbing support") | ||
| 345 | dialog.SetAuthors([]string{"kjagave 2025", "Based on gcolor2 by Ned Haughton"}) | ||
| 346 | dialog.SetLicense("GPL-2.0") | ||
| 347 | dialog.Run() | ||
| 348 | dialog.Destroy() | ||
| 349 | } | ||
| 350 | |||
| 351 | func (app *App) pickColorFromScreen() (*gdk.RGBA, error) { | ||
| 352 | // use xcolor for x11 color picking | ||
| 353 | cmd := exec.Command("xcolor", "--format", "hex") | ||
| 354 | output, err := cmd.Output() | ||
| 355 | if err != nil { | ||
| 356 | // fallback to grabc | ||
| 357 | cmd = exec.Command("grabc") | ||
| 358 | output, err = cmd.Output() | ||
| 359 | if err != nil { | ||
| 360 | dialog := gtk.MessageDialogNew(app.window, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, | ||
| 361 | gtk.BUTTONS_OK, "Color picker not found. Please install 'xcolor' or 'grabc':\n\nsudo apt install xcolor\n# or\nsudo apt install grabc") | ||
| 362 | dialog.Run() | ||
| 363 | dialog.Destroy() | ||
| 364 | return nil, err | ||
| 365 | } | ||
| 366 | } | ||
| 367 | |||
| 368 | hexColor := strings.TrimSpace(string(output)) | ||
| 369 | if !strings.HasPrefix(hexColor, "#") { | ||
| 370 | hexColor = "#" + hexColor | ||
| 371 | } | ||
| 372 | |||
| 373 | rgba := gdk.NewRGBA() | ||
| 374 | if !rgba.Parse(hexColor) { | ||
| 375 | return nil, fmt.Errorf("invalid color format: %s", hexColor) | ||
| 376 | } | ||
| 377 | |||
| 378 | return rgba, nil | ||
| 379 | } | ||
| 380 | |||
| 381 | func (app *App) loadColors() { | ||
| 382 | data, err := os.ReadFile(app.configFile) | ||
| 383 | if err != nil { | ||
| 384 | app.savedColors = []SavedColor{} | ||
| 385 | return | ||
| 386 | } | ||
| 387 | |||
| 388 | if err := json.Unmarshal(data, &app.savedColors); err != nil { | ||
| 389 | log.Printf("Error loading colors: %v", err) | ||
| 390 | app.savedColors = []SavedColor{} | ||
| 391 | } | ||
| 392 | } | ||
| 393 | |||
| 394 | func (app *App) saveColors() { | ||
| 395 | data, err := json.MarshalIndent(app.savedColors, "", " ") | ||
| 396 | if err != nil { | ||
| 397 | log.Printf("Error marshaling colors: %v", err) | ||
| 398 | return | ||
| 399 | } | ||
| 400 | |||
| 401 | if err := os.WriteFile(app.configFile, data, 0644); err != nil { | ||
| 402 | log.Printf("Error saving colors: %v", err) | ||
| 403 | } | ||
| 404 | } | ||
| 405 | |||
| 406 | func rgbaToHex(rgba *gdk.RGBA) string { | ||
| 407 | r := uint8(rgba.GetRed() * 255) | ||
| 408 | g := uint8(rgba.GetGreen() * 255) | ||
| 409 | b := uint8(rgba.GetBlue() * 255) | ||
| 410 | return fmt.Sprintf("#%02X%02X%02X", r, g, b) | ||
| 411 | } | ||
