cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm leaflet readability golang

feat: ui package with ascii logo

+980 -11
+3
.gitignore
··· 30 30 # Editor/IDE 31 31 # .idea/ 32 32 # .vscode/ 33 + 34 + # Build artifacts 35 + /tmp
+14 -1
README.md
··· 1 1 # Noteleaf 2 2 3 - A task & time management CLI built with Golang & Charm.sh libs. 3 + ```sh 4 + ,, ,... 5 + `7MN. `7MF' mm `7MM .d' "" 6 + MMN. M MM MM dM` 7 + M YMb M ,pW"Wq.mmMMmm .gP"Ya MM .gP"Ya ,6"Yb. mMMmm 8 + M `MN. M 6W' `Wb MM ,M' Yb MM ,M' Yb 8) MM MM 9 + M `MM.M 8M M8 MM 8M"""""" MM 8M"""""" ,pm9MM MM 10 + M YMM YA. ,A9 MM YM. , MM YM. , 8M MM MM 11 + .JML. YM `Ybmd9' `Mbmo`Mbmmd'.JMML.`Mbmmd' `Moo9^Yo..JMML. 12 + ``` 13 + 14 + A note, task & time management CLI built with Golang & Charm.sh libs. Inspired by TaskWarrior & todo.txt CLI applications. 4 15 5 16 ## Development 17 + 18 + Requires Go v1.24+
+10 -1
ROADMAP.md
··· 6 6 - `projects` - List all project names 7 7 - `tags` - List all tag names 8 8 9 - - `create` - Add new task with description and optional metadata 9 + - `create|new` - Add new task with description and optional metadata 10 10 11 11 - `view` - View task by ID 12 12 - `done` - Mark task as completed ··· 63 63 - `config` - Manage configuration settings 64 64 65 65 - `undo` - Reverse last operation 66 + 67 + ## Notes 68 + 69 + - `create|new` - Creates a new markdown note and optionally opens in configured editor 70 + - Creates a note from existing markdown file content 71 + - `list` - Opens interactive TUI browser for navigating and viewing notes 72 + - `read|view` - Displays formatted note content with syntax highlighting 73 + - `edit|update` - Opens configured editor OR Replaces note content with new markdown file 74 + - `remove|rm|delete|del` - Permanently removes the note file and metadata
+12 -1
cmd/cli/main.go
··· 4 4 "context" 5 5 "fmt" 6 6 "os" 7 + "strings" 7 8 8 9 "github.com/charmbracelet/fang" 9 10 "github.com/spf13/cobra" 10 11 "stormlightlabs.org/noteleaf/cmd/handlers" 11 12 "stormlightlabs.org/noteleaf/internal/store" 13 + "stormlightlabs.org/noteleaf/internal/ui" 12 14 "stormlightlabs.org/noteleaf/internal/utils" 13 15 ) 14 16 ··· 45 47 } 46 48 47 49 func main() { 48 - // Initialize logging early 49 50 logger := utils.NewLogger("info", "text") 50 51 utils.Logger = logger 51 52 ··· 58 59 rootCmd := &cobra.Command{ 59 60 Use: "noteleaf", 60 61 Short: "A TaskWarrior-inspired CLI with media queues and reading lists", 62 + Run: func(cmd *cobra.Command, args []string) { 63 + if len(args) == 0 { 64 + fmt.Println(ui.Collosal.ColoredInViewport()) 65 + cmd.Help() 66 + return 67 + } 68 + 69 + output := strings.Join(args, " ") 70 + fmt.Println(output) 71 + }, 61 72 } 62 73 63 74 rootCmd.AddCommand(&cobra.Command{
+13 -5
go.mod
··· 12 12 require github.com/google/uuid v1.6.0 13 13 14 14 require ( 15 + github.com/charmbracelet/bubbletea v1.3.4 // indirect 16 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 17 + github.com/mattn/go-localereader v0.0.1 // indirect 18 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 19 + golang.org/x/sync v0.13.0 // indirect 20 + ) 21 + 22 + require ( 15 23 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 24 + github.com/charmbracelet/bubbles v0.21.0 16 25 github.com/charmbracelet/colorprofile v0.3.1 // indirect 17 - github.com/charmbracelet/lipgloss v1.1.0 // indirect 18 - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 // indirect 26 + github.com/charmbracelet/lipgloss v1.1.0 27 + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 19 28 github.com/charmbracelet/log v0.4.2 20 29 github.com/charmbracelet/x/ansi v0.9.3 // indirect 21 30 github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 22 31 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect 23 - github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect 24 32 github.com/charmbracelet/x/term v0.2.1 // indirect 25 33 github.com/go-logfmt/logfmt v0.6.0 // indirect 26 34 github.com/inconshreveable/mousetrap v1.1.0 // indirect 27 - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 35 + github.com/lucasb-eyer/go-colorful v1.2.0 28 36 github.com/mattn/go-isatty v0.0.20 // indirect 29 37 github.com/mattn/go-runewidth v0.0.16 // indirect 30 38 github.com/muesli/cancelreader v0.2.2 // indirect ··· 32 40 github.com/muesli/mango-cobra v1.2.0 // indirect 33 41 github.com/muesli/mango-pflag v0.1.0 // indirect 34 42 github.com/muesli/roff v0.1.0 // indirect 35 - github.com/muesli/termenv v0.16.0 // indirect 43 + github.com/muesli/termenv v0.16.0 36 44 github.com/rivo/uniseg v0.4.7 // indirect 37 45 github.com/spf13/pflag v1.0.6 // indirect 38 46 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+13
go.sum
··· 4 4 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 5 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 6 6 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 7 + github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 8 + github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 9 + github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= 10 + github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= 7 11 github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 8 12 github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 9 13 github.com/charmbracelet/fang v0.3.0 h1:Be6TB+ExS8VWizTQRJgjqbJBudKrmVUet65xmFPGhaA= ··· 27 31 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 28 32 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 29 33 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 34 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 35 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 30 36 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 31 37 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 32 38 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= ··· 37 43 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 38 44 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 39 45 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 46 + github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 47 + github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 40 48 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 41 49 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 42 50 github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 43 51 github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 52 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 53 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 44 54 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 45 55 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 46 56 github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= ··· 69 79 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 70 80 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 71 81 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 82 + golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 83 + golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 84 + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 72 85 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 73 86 golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 74 87 golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+8
internal/ui/art/georgia.txt
··· 1 + ,, ,... 2 + `7MN. `7MF' mm `7MM .d' "" 3 + MMN. M MM MM dM` 4 + M YMb M ,pW"Wq.mmMMmm .gP"Ya MM .gP"Ya ,6"Yb. mMMmm 5 + M `MN. M 6W' `Wb MM ,M' Yb MM ,M' Yb 8) MM MM 6 + M `MM.M 8M M8 MM 8M"""""" MM 8M"""""" ,pm9MM MM 7 + M YMM YA. ,A9 MM YM. , MM YM. , 8M MM MM 8 + .JML. YM `Ybmd9' `Mbmo`Mbmmd'.JMML.`Mbmmd' `Moo9^Yo..JMML.
+394
internal/ui/colors.go
··· 1 + // This module contains colors from github.com/charmbracelet/x/exp/charmtone 2 + // 3 + // See: https://github.com/charmbracelet/x/blob/main/exp/charmtone/charmtone.go 4 + package ui 5 + 6 + import ( 7 + "fmt" 8 + "image/color" 9 + "slices" 10 + 11 + "github.com/lucasb-eyer/go-colorful" 12 + ) 13 + 14 + var _ color.Color = Key(0) 15 + 16 + // Key is a type for color keys. 17 + type Key int 18 + 19 + const ( 20 + Cumin Key = iota 21 + Tang 22 + Yam 23 + Paprika 24 + Bengal 25 + Uni 26 + Sriracha 27 + Coral 28 + Salmon 29 + Chili 30 + Cherry 31 + Tuna 32 + Macaron 33 + Pony 34 + Cheeky 35 + Flamingo 36 + Dolly 37 + Blush 38 + Urchin 39 + Mochi 40 + Lilac 41 + Prince 42 + Violet 43 + Mauve 44 + Grape 45 + Plum 46 + Orchid 47 + Jelly 48 + Charple 49 + Hazy 50 + Ox 51 + Sapphire 52 + Guppy 53 + Oceania 54 + Thunder 55 + Anchovy 56 + Damson 57 + Malibu 58 + Sardine 59 + Zinc 60 + Turtle 61 + Lichen 62 + Guac 63 + Julep 64 + Bok 65 + Mustard 66 + Citron 67 + Zest 68 + Pepper 69 + BBQ 70 + Charcoal 71 + Iron 72 + Oyster 73 + Squid 74 + Smoke 75 + Ash 76 + Salt 77 + Butter 78 + 79 + // Diffs: additions. The brightest color in this set is Julep, defined above. 80 + Pickle 81 + Gator 82 + Spinach 83 + 84 + // Diffs: deletions. The brightest color in this set is Cherry, defined above. 85 + Pom 86 + Steak 87 + Toast 88 + 89 + // Provisional. 90 + NeueGuac 91 + NeueZinc 92 + ) 93 + 94 + func (k Key) Hex() string { 95 + switch k { 96 + case Cumin: 97 + return "#BF976F" 98 + case Tang: 99 + return "#FF985A" 100 + case Yam: 101 + return "#FFB587" 102 + case Paprika: 103 + return "#D36C64" 104 + case Bengal: 105 + return "#FF6E63" 106 + case Uni: 107 + return "#FF937D" 108 + case Sriracha: 109 + return "#EB4268" 110 + case Coral: 111 + return "#FF577D" 112 + case Salmon: 113 + return "#FF7F90" 114 + case Chili: 115 + return "#E23080" 116 + case Cherry: 117 + return "#FF388B" 118 + case Tuna: 119 + return "#FF6DAA" 120 + case Macaron: 121 + return "#E940B0" 122 + case Pony: 123 + return "#FF4FBF" 124 + case Cheeky: 125 + return "#FF79D0" 126 + case Flamingo: 127 + return "#F947E3" 128 + case Dolly: 129 + return "#FF60FF" 130 + case Blush: 131 + return "#FF84FF" 132 + case Urchin: 133 + return "#C337E0" 134 + case Mochi: 135 + return "#EB5DFF" 136 + case Lilac: 137 + return "#F379FF" 138 + case Prince: 139 + return "#9C35E1" 140 + case Violet: 141 + return "#C259FF" 142 + case Mauve: 143 + return "#D46EFF" 144 + case Grape: 145 + return "#7134DD" 146 + case Plum: 147 + return "#9953FF" 148 + case Orchid: 149 + return "#AD6EFF" 150 + case Jelly: 151 + return "#4A30D9" 152 + case Charple: 153 + return "#6B50FF" 154 + case Hazy: 155 + return "#8B75FF" 156 + case Ox: 157 + return "#3331B2" 158 + case Sapphire: 159 + return "#4949FF" 160 + case Guppy: 161 + return "#7272FF" 162 + case Oceania: 163 + return "#2B55B3" 164 + case Thunder: 165 + return "#4776FF" 166 + case Anchovy: 167 + return "#719AFC" 168 + case Damson: 169 + return "#007AB8" 170 + case Malibu: 171 + return "#00A4FF" 172 + case Sardine: 173 + return "#4FBEFE" 174 + case Zinc: 175 + return "#10B1AE" 176 + case Turtle: 177 + return "#0ADCD9" 178 + case Lichen: 179 + return "#5CDFEA" 180 + case Guac: 181 + return "#12C78F" 182 + case Julep: 183 + return "#00FFB2" 184 + case Bok: 185 + return "#68FFD6" 186 + case Mustard: 187 + return "#F5EF34" 188 + case Citron: 189 + return "#E8FF27" 190 + case Zest: 191 + return "#E8FE96" 192 + case Pepper: 193 + return "#201F26" 194 + case BBQ: 195 + return "#2d2c35" 196 + case Charcoal: 197 + return "#3A3943" 198 + case Iron: 199 + return "#4D4C57" 200 + case Oyster: 201 + return "#605F6B" 202 + case Squid: 203 + return "#858392" 204 + case Smoke: 205 + return "#BFBCC8" 206 + case Ash: 207 + return "#DFDBDD" 208 + case Salt: 209 + return "#F1EFEF" 210 + case Butter: 211 + return "#FFFAF1" 212 + // Diffs: additions. 213 + case Pickle: 214 + return "#00A475" 215 + case Gator: 216 + return "#18463D" 217 + case Spinach: 218 + return "#1C3634" 219 + // Diffs: deletions. 220 + case Pom: 221 + return "#AB2454" 222 + case Steak: 223 + return "#582238" 224 + case Toast: 225 + return "#412130" 226 + // Provisional. 227 + case NeueGuac: 228 + return "#00b875" 229 + case NeueZinc: 230 + return "#0e9996" 231 + default: 232 + return "" 233 + } 234 + } 235 + 236 + func (k Key) String() string { 237 + switch k { 238 + case Cumin: 239 + return "Cumin" 240 + case Tang: 241 + return "Tang" 242 + case Yam: 243 + return "Yam" 244 + case Paprika: 245 + return "Paprika" 246 + case Bengal: 247 + return "Bengal" 248 + case Uni: 249 + return "Uni" 250 + case Sriracha: 251 + return "Sriracha" 252 + case Coral: 253 + return "Coral" 254 + case Salmon: 255 + return "Salmon" 256 + case Chili: 257 + return "Chili" 258 + case Cherry: 259 + return "Cherry" 260 + case Tuna: 261 + return "Tuna" 262 + case Macaron: 263 + return "Macaron" 264 + case Pony: 265 + return "Pony" 266 + case Cheeky: 267 + return "Cheeky" 268 + case Flamingo: 269 + return "Flamingo" 270 + case Dolly: 271 + return "Dolly" 272 + case Blush: 273 + return "Blush" 274 + case Urchin: 275 + return "Urchin" 276 + case Mochi: 277 + return "Mochi" 278 + case Lilac: 279 + return "Lilac" 280 + case Prince: 281 + return "Prince" 282 + case Violet: 283 + return "Violet" 284 + case Mauve: 285 + return "Mauve" 286 + case Grape: 287 + return "Grape" 288 + case Plum: 289 + return "Plum" 290 + case Orchid: 291 + return "Orchid" 292 + case Jelly: 293 + return "Jelly" 294 + case Charple: 295 + return "Charple" 296 + case Hazy: 297 + return "Hazy" 298 + case Ox: 299 + return "Ox" 300 + case Sapphire: 301 + return "Sapphire" 302 + case Guppy: 303 + return "Guppy" 304 + case Oceania: 305 + return "Oceania" 306 + case Thunder: 307 + return "Thunder" 308 + case Anchovy: 309 + return "Anchovy" 310 + case Damson: 311 + return "Damson" 312 + case Malibu: 313 + return "Malibu" 314 + case Sardine: 315 + return "Sardine" 316 + case Zinc: 317 + return "Zinc" 318 + case Turtle: 319 + return "Turtle" 320 + case Lichen: 321 + return "Lichen" 322 + case Guac: 323 + return "Guac" 324 + case Julep: 325 + return "Julep" 326 + case Bok: 327 + return "Bok" 328 + case Mustard: 329 + return "Mustard" 330 + case Citron: 331 + return "Citron" 332 + case Zest: 333 + return "Zest" 334 + case Pepper: 335 + return "Pepper" 336 + case BBQ: 337 + return "BBQ" 338 + case Charcoal: 339 + return "Charcoal" 340 + case Iron: 341 + return "Iron" 342 + case Oyster: 343 + return "Oyster" 344 + case Squid: 345 + return "Squid" 346 + case Smoke: 347 + return "Smoke" 348 + case Ash: 349 + return "Ash" 350 + case Salt: 351 + return "Salt" 352 + case Butter: 353 + return "Butter" 354 + case Pickle: 355 + return "Pickle" 356 + case Gator: 357 + return "Gator" 358 + case Spinach: 359 + return "Spinach" 360 + case Pom: 361 + return "Pom" 362 + case Steak: 363 + return "Steak" 364 + case Toast: 365 + return "Toast" 366 + case NeueGuac: 367 + return "NeueGuac" 368 + case NeueZinc: 369 + return "NeueZinc" 370 + default: 371 + return fmt.Sprintf("Key(%d)", int(k)) 372 + } 373 + } 374 + 375 + // RGBA returns the red, green, blue, and alpha values of the color for interface [color.Color] 376 + func (k Key) RGBA() (r, g, b, a uint32) { 377 + c, err := colorful.Hex(k.Hex()) 378 + if err != nil { 379 + panic(fmt.Sprintf("invalid color key %d: %s: %v", k, k.String(), err)) 380 + } 381 + return c.RGBA() 382 + } 383 + 384 + func (k Key) IsPrimary() bool { 385 + return slices.Contains(PrimaryColors, k) 386 + } 387 + 388 + func (k Key) IsSecondary() bool { 389 + return slices.Contains(SecondaryColors, k) 390 + } 391 + 392 + func (k Key) IsTertiary() bool { 393 + return slices.Contains(TertiaryColors, k) 394 + }
+143
internal/ui/logo.go
··· 1 + // See https://patorjk.com/software/taag/ 2 + package ui 3 + 4 + import ( 5 + _ "embed" 6 + "strings" 7 + 8 + "github.com/charmbracelet/bubbles/viewport" 9 + "github.com/charmbracelet/lipgloss" 10 + ) 11 + 12 + type Logo int 13 + 14 + const ( 15 + Collosal Logo = iota 16 + Georgia 17 + Alligator 18 + ANSI 19 + ANSIShadow 20 + ) 21 + 22 + const collosal string = ` 23 + 888b 888 888 888 .d888 24 + 8888b 888 888 888 d88P" 25 + 88888b 888 888 888 888 26 + 888Y88b 888 .d88b. 888888 .d88b. 888 .d88b. 8888b. 888888 27 + 888 Y88b888 d88""88b 888 d8P Y8b 888 d8P Y8b "88b 888 28 + 888 Y88888 888 888 888 88888888 888 88888888 .d888888 888 29 + 888 Y8888 Y88..88P Y88b. Y8b. 888 Y8b. 888 888 888 30 + 888 Y888 "Y88P" "Y888 "Y8888 888 "Y8888 "Y888888 888 31 + ` 32 + 33 + const alligator string = ` 34 + :::: ::: :::::::: ::::::::::: :::::::::: ::: :::::::::: ::: :::::::::: 35 + :+:+: :+: :+: :+: :+: :+: :+: :+: :+: :+: :+: 36 + :+:+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ 37 + +#+ +:+ +#+ +#+ +:+ +#+ +#++:++# +#+ +#++:++# +#++:++#++: :#::+::# 38 + +#+ +#+#+# +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ 39 + #+# #+#+# #+# #+# #+# #+# #+# #+# #+# #+# #+# 40 + ### #### ######## ### ########## ########## ########## ### ### ### 41 + ` 42 + 43 + const ansi = ` 44 + ███ ██ ██████ ████████ ███████ ██ ███████ █████ ███████ 45 + ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ 46 + ██ ██ ██ ██ ██ ██ █████ ██ █████ ███████ █████ 47 + ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ 48 + ██ ████ ██████ ██ ███████ ███████ ███████ ██ ██ ██ 49 + ` 50 + 51 + const ansiShadow = ` 52 + ███╗ ██╗ ██████╗ ████████╗███████╗██╗ ███████╗ █████╗ ███████╗ 53 + ████╗ ██║██╔═══██╗╚══██╔══╝██╔════╝██║ ██╔════╝██╔══██╗██╔════╝ 54 + ██╔██╗ ██║██║ ██║ ██║ █████╗ ██║ █████╗ ███████║█████╗ 55 + ██║╚██╗██║██║ ██║ ██║ ██╔══╝ ██║ ██╔══╝ ██╔══██║██╔══╝ 56 + ██║ ╚████║╚██████╔╝ ██║ ███████╗███████╗███████╗██║ ██║██║ 57 + ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ 58 + ` 59 + 60 + //go:embed art/georgia.txt 61 + var georgia string 62 + 63 + func (l Logo) String() string { 64 + switch l { 65 + case Collosal: 66 + return collosal 67 + case Georgia: 68 + return georgia 69 + case Alligator: 70 + return alligator 71 + case ANSI: 72 + return ansi 73 + case ANSIShadow: 74 + return ansiShadow 75 + default: 76 + return collosal 77 + } 78 + } 79 + 80 + // Colored returns a colored version of the logo using lipgloss with vertical spiral design 81 + // 82 + // Creates a vertical spiral effect by coloring character by character: 83 + // 84 + // Combine line position and character position & use modulo to build wave-like transitions 85 + func (l Logo) Colored() string { 86 + logo := l.String() 87 + lines := strings.Split(logo, "\n") 88 + 89 + emeraldStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#10b981")) 90 + skyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#0284c7")) 91 + 92 + var coloredLines []string 93 + for lineIdx, line := range lines { 94 + if strings.TrimSpace(line) == "" { 95 + coloredLines = append(coloredLines, line) 96 + continue 97 + } 98 + 99 + var coloredLine strings.Builder 100 + for charIdx, char := range line { 101 + 102 + spiralPos := (lineIdx*3 + charIdx) % 8 103 + 104 + if spiralPos < 4 { 105 + coloredLine.WriteString(emeraldStyle.Render(string(char))) 106 + } else { 107 + coloredLine.WriteString(skyStyle.Render(string(char))) 108 + } 109 + } 110 + 111 + coloredLines = append(coloredLines, coloredLine.String()) 112 + } 113 + 114 + return strings.Join(coloredLines, "\n") 115 + } 116 + 117 + // ColoredInViewport returns the colored logo rendered inside a viewport bubble 118 + func (l Logo) ColoredInViewport(renderer ...*lipgloss.Renderer) string { 119 + coloredLogo := l.Colored() 120 + lines := strings.Split(coloredLogo, "\n") 121 + 122 + maxWidth := 0 123 + for _, line := range lines { 124 + stripped := lipgloss.Width(line) 125 + if stripped > maxWidth { 126 + maxWidth = stripped 127 + } 128 + } 129 + 130 + vp := viewport.New(maxWidth+4, len(lines)) 131 + vp.SetContent(coloredLogo) 132 + 133 + style := lipgloss.NewStyle(). 134 + Border(lipgloss.RoundedBorder()). 135 + BorderForeground(lipgloss.Color("#6b7280")). 136 + Padding(1) 137 + 138 + if len(renderer) > 0 && renderer[0] != nil { 139 + style = style.Renderer(renderer[0]) 140 + } 141 + 142 + return style.Render(vp.View()) 143 + }
+160
internal/ui/palette.go
··· 1 + package ui 2 + 3 + import ( 4 + "image/color" 5 + 6 + "github.com/charmbracelet/fang" 7 + "github.com/charmbracelet/lipgloss" 8 + lipglossv2 "github.com/charmbracelet/lipgloss/v2" 9 + ) 10 + 11 + var PrimaryColors []Key = []Key{ 12 + Guac, 13 + Julep, 14 + Bok, 15 + Pickle, 16 + NeueGuac, 17 + } 18 + 19 + var SecondaryColors []Key = []Key{ 20 + Malibu, 21 + Sardine, 22 + Lichen, 23 + } 24 + 25 + var TertiaryColors []Key = []Key{ 26 + Violet, 27 + Mauve, 28 + Plum, 29 + Orchid, 30 + Charple, 31 + Hazy, 32 + } 33 + 34 + var ProvisionalColors []Key = []Key{NeueGuac, NeueZinc} 35 + 36 + var AdditionColors []Key = []Key{Pickle, Gator, Spinach} 37 + 38 + var DeletionColors []Key = []Key{Pom, Steak, Toast} 39 + 40 + var NoteleafColorScheme fang.ColorSchemeFunc = noteleafColorScheme 41 + 42 + func noteleafColorScheme(c lipglossv2.LightDarkFunc) fang.ColorScheme { 43 + return fang.ColorScheme{ 44 + Base: c(Salt, Pepper), // Light/Dark base text 45 + Title: c(Guac, Julep), // Green primary for titles 46 + Description: c(Squid, Smoke), // Muted gray for descriptions 47 + Codeblock: c(Butter, BBQ), // Light/Dark background for code 48 + Program: c(Malibu, Sardine), // Blue for program names 49 + DimmedArgument: c(Oyster, Ash), // Subtle gray for dimmed text 50 + Comment: c(Pickle, NeueGuac), // Green for comments 51 + Flag: c(Violet, Mauve), // Purple for flags 52 + FlagDefault: c(Lichen, Turtle), // Teal for flag defaults 53 + Command: c(Julep, Guac), // Bright green for commands 54 + QuotedString: c(Citron, Mustard), // Yellow for quoted strings 55 + Argument: c(Sapphire, Guppy), // Blue for arguments 56 + Help: c(Smoke, Iron), // Gray for help text 57 + Dash: c(Iron, Oyster), // Medium gray for dashes 58 + ErrorHeader: [2]color.Color{Cherry, Sriracha}, // Red for error headers (fg, bg) 59 + ErrorDetails: c(Coral, Salmon), // Red/pink for error details 60 + } 61 + } 62 + 63 + // Palette provides semantic color access 64 + type Palette struct { 65 + scheme fang.ColorScheme 66 + } 67 + 68 + // NewPalette creates a new palette instance 69 + func NewPalette(isDark bool) *Palette { 70 + return &Palette{ 71 + scheme: noteleafColorScheme(lipglossv2.LightDark(isDark)), 72 + } 73 + } 74 + 75 + var ( 76 + Success = lipgloss.NewStyle(). 77 + Foreground(lipgloss.Color(Julep.Hex())). 78 + Bold(true) 79 + 80 + Error = lipgloss.NewStyle(). 81 + Foreground(lipgloss.Color(Cherry.Hex())). 82 + Bold(true) 83 + 84 + Info = lipgloss.NewStyle(). 85 + Foreground(lipgloss.Color(Malibu.Hex())) 86 + 87 + Warning = lipgloss.NewStyle(). 88 + Foreground(lipgloss.Color(Citron.Hex())). 89 + Bold(true) 90 + 91 + Path = lipgloss.NewStyle(). 92 + Foreground(lipgloss.Color(Mustard.Hex())). 93 + Italic(true) 94 + ) 95 + 96 + var ( 97 + TaskTitle = lipgloss.NewStyle(). 98 + Foreground(lipgloss.Color(Salt.Hex())). 99 + Bold(true) 100 + 101 + TaskID = lipgloss.NewStyle(). 102 + Foreground(lipgloss.Color(Squid.Hex())). 103 + Width(8) 104 + ) 105 + 106 + var ( 107 + StatusPending = lipgloss.NewStyle(). 108 + Foreground(lipgloss.Color(Citron.Hex())) 109 + 110 + StatusCompleted = lipgloss.NewStyle(). 111 + Foreground(lipgloss.Color(Julep.Hex())) 112 + ) 113 + 114 + var ( 115 + PriorityHigh = lipgloss.NewStyle(). 116 + Foreground(lipgloss.Color(Cherry.Hex())). 117 + Bold(true) 118 + 119 + PriorityMedium = lipgloss.NewStyle(). 120 + Foreground(lipgloss.Color(Citron.Hex())) 121 + 122 + PriorityLow = lipgloss.NewStyle(). 123 + Foreground(lipgloss.Color(Squid.Hex())) 124 + ) 125 + 126 + var ( 127 + MovieStyle = lipgloss.NewStyle(). 128 + Foreground(lipgloss.Color(Coral.Hex())). 129 + Bold(true) 130 + 131 + TVStyle = lipgloss.NewStyle(). 132 + Foreground(lipgloss.Color(Violet.Hex())). 133 + Bold(true) 134 + 135 + BookStyle = lipgloss.NewStyle(). 136 + Foreground(lipgloss.Color(Guac.Hex())). 137 + Bold(true) 138 + 139 + MusicStyle = lipgloss.NewStyle(). 140 + Foreground(lipgloss.Color(Lichen.Hex())). 141 + Bold(true) 142 + ) 143 + 144 + // Table and UI styles 145 + var ( 146 + TableStyle = lipgloss.NewStyle(). 147 + BorderStyle(lipgloss.NormalBorder()). 148 + BorderForeground(lipgloss.Color(Smoke.Hex())) 149 + 150 + SelectedStyle = lipgloss.NewStyle(). 151 + Foreground(lipgloss.Color(Salt.Hex())). 152 + Background(lipgloss.Color(Squid.Hex())). 153 + Bold(true) 154 + 155 + HeaderStyle = lipgloss.NewStyle(). 156 + BorderStyle(lipgloss.NormalBorder()). 157 + BorderForeground(lipgloss.Color(Smoke.Hex())). 158 + BorderBottom(true). 159 + Bold(false) 160 + )
+207
internal/ui/ui_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "bytes" 5 + "os" 6 + "regexp" 7 + "strings" 8 + "testing" 9 + 10 + "github.com/charmbracelet/lipgloss" 11 + "github.com/muesli/termenv" 12 + ) 13 + 14 + func stripAnsi(str string) string { 15 + return regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`).ReplaceAllString(str, "") 16 + } 17 + 18 + func withColor(t *testing.T, fn func(r *lipgloss.Renderer)) { 19 + t.Helper() 20 + 21 + var buf bytes.Buffer 22 + r := lipgloss.NewRenderer(&buf) 23 + r.SetColorProfile(termenv.TrueColor) 24 + lipgloss.SetDefaultRenderer(r) 25 + 26 + fn(r) 27 + 28 + lipgloss.SetDefaultRenderer(lipgloss.NewRenderer(os.Stdout)) 29 + } 30 + 31 + func TestUI(t *testing.T) { 32 + t.Run("Hex", func(t *testing.T) { 33 + tests := []struct { 34 + key Key 35 + expected string 36 + }{ 37 + {Cumin, "#BF976F"}, 38 + {Cherry, "#FF388B"}, 39 + {Julep, "#00FFB2"}, 40 + {Butter, "#FFFAF1"}, 41 + {Key(-1), ""}, 42 + } 43 + 44 + for _, tt := range tests { 45 + t.Run(tt.key.String(), func(t *testing.T) { 46 + if hex := tt.key.Hex(); hex != tt.expected { 47 + t.Errorf("expected hex %q, got %q", tt.expected, hex) 48 + } 49 + }) 50 + } 51 + }) 52 + 53 + t.Run("String", func(t *testing.T) { 54 + tests := []struct { 55 + key Key 56 + expected string 57 + }{ 58 + {Cumin, "Cumin"}, 59 + {Cherry, "Cherry"}, 60 + {Key(-1), "Key(-1)"}, 61 + } 62 + 63 + for _, tt := range tests { 64 + t.Run(tt.expected, func(t *testing.T) { 65 + if str := tt.key.String(); str != tt.expected { 66 + t.Errorf("expected string %q, got %q", tt.expected, str) 67 + } 68 + }) 69 + } 70 + }) 71 + 72 + t.Run("RGBA", func(t *testing.T) { 73 + t.Run("valid key", func(t *testing.T) { 74 + r, g, b, a := Cumin.RGBA() 75 + if a == 0 { 76 + t.Error("Alpha should not be zero for a valid color") 77 + } 78 + if !(r > 0 && g > 0 && b > 0) { 79 + t.Error("RGB components should be greater than zero for Cumin") 80 + } 81 + }) 82 + 83 + t.Run("invalid key", func(t *testing.T) { 84 + defer func() { 85 + if r := recover(); r == nil { 86 + t.Errorf("The code did not panic") 87 + } 88 + }() 89 + Key(-1).RGBA() 90 + }) 91 + }) 92 + 93 + t.Run("Color Categories", func(t *testing.T) { 94 + if !Guac.IsPrimary() { 95 + t.Error("Guac should be a primary color") 96 + } 97 + if Malibu.IsPrimary() { 98 + t.Error("Malibu should not be a primary color") 99 + } 100 + if !Malibu.IsSecondary() { 101 + t.Error("Malibu should be a secondary color") 102 + } 103 + if Guac.IsSecondary() { 104 + t.Error("Guac should not be a secondary color") 105 + } 106 + if !Violet.IsTertiary() { 107 + t.Error("Violet should be a tertiary color") 108 + } 109 + if Guac.IsTertiary() { 110 + t.Error("Guac should not be a tertiary color") 111 + } 112 + }) 113 + 114 + t.Run("New Palette", func(t *testing.T) { 115 + t.Run("dark mode", func(t *testing.T) { 116 + p := NewPalette(true) 117 + if p == nil { 118 + t.Fatal("NewPalette(true) returned nil") 119 + } 120 + r, g, b, a := p.scheme.Base.RGBA() 121 + pr, pg, pb, pa := Pepper.RGBA() 122 + if r != pr || g != pg || b != pb || a != pa { 123 + t.Errorf("dark mode base color mismatch: expected Pepper, got %v", p.scheme.Base) 124 + } 125 + }) 126 + 127 + t.Run("light mode", func(t *testing.T) { 128 + p := NewPalette(false) 129 + if p == nil { 130 + t.Fatal("NewPalette(false) returned nil") 131 + } 132 + r, g, b, a := p.scheme.Base.RGBA() 133 + sr, sg, sb, sa := Salt.RGBA() 134 + if r != sr || g != sg || b != sb || a != sa { 135 + t.Errorf("light mode base color mismatch: expected Salt, got %v", p.scheme.Base) 136 + } 137 + }) 138 + }) 139 + 140 + t.Run("Logo", func(t *testing.T) { 141 + logos := []struct { 142 + logo Logo 143 + name string 144 + contains string 145 + }{ 146 + {Collosal, "Collosal", "888b 888"}, 147 + {Georgia, "Georgia", "`7MN. `7MF'"}, 148 + {Alligator, "Alligator", ":::: ::: ::::::::"}, 149 + {ANSI, "ANSI", "███ ██"}, 150 + {ANSIShadow, "ANSIShadow", "███╗ ██╗"}, 151 + } 152 + for _, l := range logos { 153 + t.Run(l.name, func(t *testing.T) { 154 + s := l.logo.String() 155 + if s == "" { 156 + t.Error("logo string should not be empty") 157 + } 158 + if !strings.Contains(s, l.contains) { 159 + t.Errorf("logo string does not contain expected content: %q", l.contains) 160 + } 161 + }) 162 + } 163 + 164 + t.Run("Colored", func(t *testing.T) { 165 + withColor(t, func(r *lipgloss.Renderer) { 166 + logo := Georgia 167 + plain := logo.String() 168 + colored := logo.Colored() 169 + 170 + if colored == "" { 171 + t.Fatal("colored logo is empty") 172 + } 173 + if plain == colored { 174 + t.Error("Colored logo should be different from plain") 175 + } 176 + if !strings.Contains(colored, "\u001b[") { 177 + t.Error("Colored logo should contain ANSI escape codes") 178 + } 179 + }) 180 + }) 181 + 182 + t.Run("Colored in Viewport", func(t *testing.T) { 183 + withColor(t, func(r *lipgloss.Renderer) { 184 + logo := Collosal 185 + viewport := logo.ColoredInViewport(r) 186 + t.Logf("viewport output:\n%s", viewport) 187 + 188 + if viewport == "" { 189 + t.Fatal("viewport is empty") 190 + } 191 + 192 + cleanedViewport := stripAnsi(viewport) 193 + if !strings.Contains(cleanedViewport, "888") { 194 + t.Error("Viewport should contain parts of the logo") 195 + } 196 + 197 + borderChars := []string{"╭", "╮", "╯", "╰"} 198 + for _, char := range borderChars { 199 + if !strings.Contains(viewport, char) { 200 + t.Errorf("Viewport should contain rounded border character %s", char) 201 + } 202 + } 203 + }) 204 + }) 205 + }) 206 + 207 + }
+3 -3
justfile
··· 21 21 22 22 # Build the binary to /tmp/ 23 23 build: 24 - mkdir -p /tmp/ 25 - go build -o /tmp/noteleaf ./cmd/cli/ 26 - @echo "Binary built: /tmp/noteleaf/noteleaf" 24 + mkdir -p ./tmp/ 25 + go build -o ./tmp/noteleaf ./cmd/cli/ 26 + @echo "Binary built: ./tmp/noteleaf" 27 27 28 28 # Clean build artifacts 29 29 clean: