From 89094fecf4cb1c018f15c976641cd18c255eac28 Mon Sep 17 00:00:00 2001 From: Benjamin Chausse Date: Sat, 23 Nov 2024 18:12:03 -0500 Subject: Semi-working POC --- internal/colors/cmyk.go | 46 ++++++++++++++ internal/colors/hsl.go | 1 + internal/colors/precise.go | 38 ++++++++++++ internal/colors/rgb.go | 25 ++++++++ internal/picker/keys.go | 29 +++++++++ internal/picker/picker.go | 137 ++++++++++++++++++++++++++++++++++++++++++ internal/quit/quit.go | 17 ++++++ internal/slider/evaluation.go | 46 ++++++++++++++ internal/slider/keys.go | 52 ++++++++++++++++ internal/slider/slider.go | 71 ++++++++++++++++++++++ internal/switcher/keys.go | 33 ++++++++++ internal/switcher/switcher.go | 89 +++++++++++++++++++++++++++ internal/util/util.go | 12 ++++ 13 files changed, 596 insertions(+) create mode 100644 internal/colors/cmyk.go create mode 100644 internal/colors/hsl.go create mode 100644 internal/colors/precise.go create mode 100644 internal/colors/rgb.go create mode 100644 internal/picker/keys.go create mode 100644 internal/picker/picker.go create mode 100644 internal/quit/quit.go create mode 100644 internal/slider/evaluation.go create mode 100644 internal/slider/keys.go create mode 100644 internal/slider/slider.go create mode 100644 internal/switcher/keys.go create mode 100644 internal/switcher/switcher.go create mode 100644 internal/util/util.go (limited to 'internal') diff --git a/internal/colors/cmyk.go b/internal/colors/cmyk.go new file mode 100644 index 0000000..3460c4e --- /dev/null +++ b/internal/colors/cmyk.go @@ -0,0 +1,46 @@ +package colors + +import "math" + +type CMYK struct { + C int // 0-100 + M int // 0-100 + Y int // 0-100 + K int // 0-100 +} + +func (c CMYK) ToPrecise() PreciseColor { + return PreciseColor{ + R: (1 - float64(c.C)/100) * (1 - float64(c.K)/100), + G: (1 - float64(c.M)/100) * (1 - float64(c.K)/100), + B: (1 - float64(c.Y)/100) * (1 - float64(c.K)/100), + } +} + +func (c CMYK) FromPrecise(p PreciseColor) ColorSpace { + // Extract RGB components from the PreciseColor + r := p.R + g := p.G + b := p.B + + // Calculate the K (key/black) component + k := 1 - math.Max(math.Max(r, g), b) + + // Avoid division by zero when K is 1 (pure black) + if k == 1 { + return CMYK{C: 0, M: 0, Y: 0, K: 100} + } + + // Calculate the CMY components based on the remaining color values + cyan := (1 - r - k) / (1 - k) + magenta := (1 - g - k) / (1 - k) + yellow := (1 - b - k) / (1 - k) + + // Scale to 0-100 and return + return CMYK{ + C: int(math.Round(cyan * 100)), + M: int(math.Round(magenta * 100)), + Y: int(math.Round(yellow * 100)), + K: int(math.Round(k * 100)), + } +} diff --git a/internal/colors/hsl.go b/internal/colors/hsl.go new file mode 100644 index 0000000..7477042 --- /dev/null +++ b/internal/colors/hsl.go @@ -0,0 +1 @@ +package colors diff --git a/internal/colors/precise.go b/internal/colors/precise.go new file mode 100644 index 0000000..fafa104 --- /dev/null +++ b/internal/colors/precise.go @@ -0,0 +1,38 @@ +package colors + +import ( + "fmt" + "math" + "strings" +) + +type ColorSpace interface { + ToPrecise() PreciseColor + FromPrecise(PreciseColor) ColorSpace +} + +// PreciseColor is a color with floating point values for red, green, and blue. +// The extra precision minimizes rounding errors when converting between different +// color spaces. It is used as an intermediate representation when converting between +// different color spaces. +type PreciseColor struct { + R, G, B float64 +} + +func (c PreciseColor) ToPrecise() PreciseColor { + return c +} + +func (c PreciseColor) FromPrecise(p PreciseColor) ColorSpace { + return p +} + +func ToHexString(cs ColorSpace) string { + p := cs.ToPrecise() + + return strings.ToUpper(fmt.Sprintf("#%02x%02x%02x", + int(math.Round(p.R*255)), + int(math.Round(p.G*255)), + int(math.Round(p.B*255)), + )) +} diff --git a/internal/colors/rgb.go b/internal/colors/rgb.go new file mode 100644 index 0000000..e81aefc --- /dev/null +++ b/internal/colors/rgb.go @@ -0,0 +1,25 @@ +package colors + +import "math" + +type RGB struct { + R int // 0-255 + G int // 0-255 + B int // 0-255 +} + +func (c RGB) ToPrecise() PreciseColor { + return PreciseColor{ + R: float64(c.R) / 255, + G: float64(c.G) / 255, + B: float64(c.B) / 255, + } +} + +func (c RGB) FromPrecise(p PreciseColor) ColorSpace { + return RGB{ + R: int(math.Round(p.R * 255)), + G: int(math.Round(p.G * 255)), + B: int(math.Round(p.B * 255)), + } +} diff --git a/internal/picker/keys.go b/internal/picker/keys.go new file mode 100644 index 0000000..f5b7135 --- /dev/null +++ b/internal/picker/keys.go @@ -0,0 +1,29 @@ +package picker + +import "github.com/charmbracelet/bubbles/key" + +type keybinds struct { + next, prev key.Binding +} + +func newKeybinds() keybinds { + return keybinds{ + next: key.NewBinding( + key.WithKeys("j", "down"), + key.WithHelp("j", "previous slider"), + ), + prev: key.NewBinding( + key.WithKeys("k", "up"), + key.WithHelp("k", "next slider"), + ), + } +} + +func Keys() []key.Binding { + k := newKeybinds() + return []key.Binding{k.next, k.prev} +} + +func (m Model) AllKeys() []key.Binding { + return append(Keys(), m.sliders[m.active].AllKeys()...) +} diff --git a/internal/picker/picker.go b/internal/picker/picker.go new file mode 100644 index 0000000..e3c396e --- /dev/null +++ b/internal/picker/picker.go @@ -0,0 +1,137 @@ +package picker + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbletea-app-template/internal/colors" + "github.com/charmbracelet/bubbletea-app-template/internal/slider" +) + +type Model struct { + title string + active int + sliders []slider.Model +} + +func (m *Model) Next() int { + m.active = m.fixSel(m.active + 1) + return m.active +} + +func (m *Model) Prev() int { + m.active = m.fixSel(m.active - 1) + return m.active +} + +func (m *Model) Sel(i int) int { + m.active = m.fixSel(i) + return m.active +} + +func (m Model) fixSel(val int) int { + size := len(m.sliders) + return (val%size + size) % size +} + +func New(sliders []slider.Model, title string) *Model { + return &Model{ + title: title, + active: 0, + sliders: sliders, + } +} + +func (m Model) Title() string { + return m.title +} + +func (m Model) GetColor() colors.ColorSpace { + switch m.title { + case "RGB": + return colors.RGB{ + R: m.sliders[0].Val(), + G: m.sliders[1].Val(), + B: m.sliders[2].Val(), + } + case "CMYK": + return colors.CMYK{ + C: m.sliders[0].Val(), + M: m.sliders[1].Val(), + Y: m.sliders[2].Val(), + K: m.sliders[3].Val(), + } + // TODO: HSL + default: // Default to white if we don't know the color space + return colors.RGB{ + R: 255, + G: 255, + B: 255, + } + } +} + +func (m Model) SetColor(c colors.ColorSpace) { + p := c.ToPrecise() + switch m.title { + case "RGB": + rgb := colors.RGB{}.FromPrecise(p).(colors.RGB) + m.sliders[0].Set(rgb.R) + m.sliders[1].Set(rgb.G) + m.sliders[2].Set(rgb.B) + case "CMYK": + cmyk := colors.CMYK{}.FromPrecise(p).(colors.CMYK) + m.sliders[0].Set(cmyk.C) + m.sliders[1].Set(cmyk.M) + m.sliders[2].Set(cmyk.Y) + m.sliders[3].Set(cmyk.K) + } +} + +func (m Model) Init() tea.Cmd { + cmds := []tea.Cmd{} + for _, s := range m.sliders { + cmds = append(cmds, s.Init()) + } + return tea.Batch(cmds...) +} + +func (m Model) View() string { + var s string + for i, slider := range m.sliders { + if i == m.active { + s += fmt.Sprintf("\n-> %s", slider.View()) + } else { + s += fmt.Sprintf("\n %s", slider.View()) + } + } + return s +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + keys := newKeybinds() + cmds := []tea.Cmd{} + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, keys.next): + m.Next() + case key.Matches(msg, keys.prev): + m.Prev() + default: + newActive, cmd := m.sliders[m.active].Update(msg) + m.sliders[m.active] = newActive.(slider.Model) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + } + // Keys are only sent to the active sliders + // However, other messages (ex: tick, resize) must be sent to all + for i, s := range m.sliders { + newSlider, cmd := s.Update(msg) + m.sliders[i] = newSlider.(slider.Model) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) +} diff --git a/internal/quit/quit.go b/internal/quit/quit.go new file mode 100644 index 0000000..de1e3b4 --- /dev/null +++ b/internal/quit/quit.go @@ -0,0 +1,17 @@ +package quit + +import tea "github.com/charmbracelet/bubbletea" + +type Model struct{} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m Model) View() string { + return "Goodbye!\n" +} diff --git a/internal/slider/evaluation.go b/internal/slider/evaluation.go new file mode 100644 index 0000000..363efe7 --- /dev/null +++ b/internal/slider/evaluation.go @@ -0,0 +1,46 @@ +package slider + +func (m Model) Val() int { return m.current } + +func (m *Model) Set(v int) { + m.current = v + m.fixRange() +} + +func (m *Model) Inc(v int) { + m.current += v + m.fixRange() +} + +func (m *Model) Dec(v int) { + m.current -= v + m.fixRange() +} + +func (m *Model) Pcnt() float64 { + return float64(m.current) / float64(m.max) +} + +func (m *Model) SetPcnt(p float64) { + m.current = int(float64(m.max) * p) + m.fixRange() +} + +func (m *Model) IncPcnt(p float64) { + m.current += int(float64(m.max) * p) + m.fixRange() +} + +func (m *Model) DecPcnt(p float64) { + m.current -= int(float64(m.max) * p) + m.fixRange() +} + +func (m *Model) fixRange() { + if m.current > m.max { + m.current = m.max + } + if m.current < 0 { + m.current = 0 + } +} diff --git a/internal/slider/keys.go b/internal/slider/keys.go new file mode 100644 index 0000000..9f96903 --- /dev/null +++ b/internal/slider/keys.go @@ -0,0 +1,52 @@ +package slider + +import "github.com/charmbracelet/bubbles/key" + +type keybinds struct { + incRegular key.Binding + decRegular key.Binding + incPrecise key.Binding + decPrecise key.Binding + // quitApp key.Binding +} + +func newKeybinds() keybinds { + return keybinds{ + incRegular: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("l", "Increase (coarse)"), + ), + decRegular: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("h", "Decrease (coarse)"), + ), + incPrecise: key.NewBinding( + key.WithKeys("shift+right", "L"), + key.WithHelp("L", "Increase (fine)"), + ), + decPrecise: key.NewBinding( + key.WithKeys("shift+left", "H"), + key.WithHelp("H", "Decrease (fine)"), + ), + } +} + +// Join all keybindings into a single slice +// a parent can use to know what Keys +// it's children have. +func Keys() []key.Binding { + k := newKeybinds() + return []key.Binding{ + k.incRegular, + k.decRegular, + k.incPrecise, + k.decPrecise, + } +} + +// AllKeys returns key.Bindings for the Model +// and all of its active children. The parent +// can use this to generate help text. +func (m Model) AllKeys() []key.Binding { + return Keys() +} diff --git a/internal/slider/slider.go b/internal/slider/slider.go new file mode 100644 index 0000000..d726e7b --- /dev/null +++ b/internal/slider/slider.go @@ -0,0 +1,71 @@ +package slider + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" +) + +type Model struct { + label byte + progress progress.Model + max int + current int + mappings keybinds +} + +func New(label byte, maxVal int, opts ...progress.Option) Model { + slider := Model{ + label: label, + progress: progress.New( + progress.WithoutPercentage(), + ), + max: maxVal, + current: maxVal / 2, + mappings: newKeybinds(), + } + for _, opt := range opts { + opt(&slider.progress) + } + return slider +} + +func (m Model) Title() string { return fmt.Sprintf("%c", m.label) } + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + keys := newKeybinds() + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, keys.incRegular): + m.IncPcnt(0.05) + case key.Matches(msg, keys.decRegular): + m.DecPcnt(0.05) + case key.Matches(msg, keys.incPrecise): + m.Inc(1) + case key.Matches(msg, keys.decPrecise): + m.Dec(1) + } + return m, m.progress.SetPercent(m.Pcnt()) + case progress.FrameMsg: + progressModel, cmd := m.progress.Update(msg) + m.progress = progressModel.(progress.Model) + return m, cmd + default: + return m, nil + } +} + +func (m Model) ViewValue(current int) string { + return fmt.Sprintf("(%3d/%d)", current, m.max) +} + +func (m Model) View() string { + return fmt.Sprintf("%v: %v %v", m.Title(), m.progress.View(), m.ViewValue(m.current)) +} diff --git a/internal/switcher/keys.go b/internal/switcher/keys.go new file mode 100644 index 0000000..94eb6fb --- /dev/null +++ b/internal/switcher/keys.go @@ -0,0 +1,33 @@ +package switcher + +import "github.com/charmbracelet/bubbles/key" + +type keybinds struct { + next, prev, quit key.Binding +} + +func newKeybinds() keybinds { + return keybinds{ + next: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next picker"), + ), + prev: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "previous picker"), + ), + quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + } +} + +func Keys() []key.Binding { + k := newKeybinds() + return []key.Binding{k.next, k.prev} +} + +func (m Model) AllKeys() []key.Binding { + return append(Keys(), m.pickers[m.active].AllKeys()...) +} diff --git a/internal/switcher/switcher.go b/internal/switcher/switcher.go new file mode 100644 index 0000000..d542df1 --- /dev/null +++ b/internal/switcher/switcher.go @@ -0,0 +1,89 @@ +package switcher + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbletea-app-template/internal/picker" + "github.com/charmbracelet/bubbletea-app-template/internal/quit" +) + +type Model struct { + active int + pickers []picker.Model +} + +func New(pickers []picker.Model) Model { + return Model{ + active: 0, + pickers: pickers, + } +} + +func (m Model) fixSel(val int) int { + size := len(m.pickers) + return (val%size + size) % size +} + +func (m *Model) Next() int { + m.active = m.fixSel(m.active + 1) + return m.active +} + +func (m *Model) Prev() int { + m.active = m.fixSel(m.active - 1) + return m.active +} + +func (m Model) Init() tea.Cmd { + cmds := []tea.Cmd{} + for _, p := range m.pickers { + cmds = append(cmds, p.Init()) + } + return tea.Batch(cmds...) +} + +func (m Model) View() string { + v := "|" + for i, p := range m.pickers { + if i == m.active { + v += fmt.Sprintf(">%s<|", p.Title()) + } else { + v += fmt.Sprintf(" %s |", p.Title()) + } + } + return fmt.Sprintf("%s\n%s", v, m.pickers[m.active].View()) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + keys := newKeybinds() + cmds := []tea.Cmd{} + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, keys.next): + cs := m.pickers[m.active].GetColor() + m.Next() + m.pickers[m.active].SetColor(cs) + case key.Matches(msg, keys.prev): + cs := m.pickers[m.active].GetColor() + m.Prev() + m.pickers[m.active].SetColor(cs) + case key.Matches(msg, keys.quit): + return quit.Model{}, tea.Quit + // return m, tea.Quit + default: + newActive, cmd := m.pickers[m.active].Update(msg) + m.pickers[m.active] = newActive.(picker.Model) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + } + for i, p := range m.pickers { + newActive, cmd := p.Update(msg) + m.pickers[i] = newActive.(picker.Model) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) +} diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 0000000..9fea40c --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,12 @@ +package util + +const ErrKey = "error_message" + +func HexMap() [16]byte { + return [16]byte{ + '0', '1', '2', '3', + '4', '5', '6', '7', + '8', '9', 'a', 'b', + 'c', 'd', 'e', 'f', + } +} -- cgit v1.2.3