From 82e103b02a904052d49238211b583ddc3cce810d Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Sun, 16 Nov 2025 15:25:12 +0000 Subject: [PATCH] feat: Load custom themes from config storage This allows custom themes to be configured with a JSON configuration file located in the same location as where the todo data is stored, config will always be stored on the filesystem regardless of the chosen storage, but uses the storage mechanism to read config. --- go.mod | 36 ++++++++------- go.sum | 80 +++++++++++++++++++--------------- internal/cmd/t.go | 16 +++++-- internal/config/config.go | 75 +++++++++++++++++++++++++++++++ internal/config/config_test.go | 59 +++++++++++++++++++++++++ internal/paths/paths.go | 40 +++++++++++++++++ internal/storage/file.go | 21 +-------- internal/theme/theme.go | 38 +++++++++------- internal/tui/tui.go | 6 +-- internal/tui/tui_test.go | 3 +- main.go | 52 +++++++++++++++++++--- 11 files changed, 326 insertions(+), 100 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/paths/paths.go diff --git a/go.mod b/go.mod index ced1074..39b45d8 100644 --- a/go.mod +++ b/go.mod @@ -3,43 +3,47 @@ module github.com/unfunco/t go 1.25 require ( + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/fang v0.4.3 + github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea github.com/spf13/cobra v1.10.1 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.3.2 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.3.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251114211333-9deacb990ee7 // indirect + github.com/charmbracelet/x/ansi v0.11.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.14 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20251114205511-64e30b5ee1c5 // indirect + github.com/charmbracelet/x/exp/color v0.0.0-20251006100439-2151805163c8 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.5.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/mango v0.1.0 // indirect - github.com/muesli/mango-cobra v1.2.0 // indirect - github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/mango v0.2.0 // indirect + github.com/muesli/mango-cobra v1.3.0 // indirect + github.com/muesli/mango-pflag v0.2.0 // indirect github.com/muesli/roff v0.1.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index ed27544..8922194 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A= @@ -6,36 +8,42 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= -github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= -github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY= -github.com/charmbracelet/fang v0.4.3/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg= +github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= +github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= +github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= +github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea/go.mod h1:ngHerf1JLJXBrDXdphn5gFrBPriCL437uwukd5c93pM= -github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef h1:VrWaUi2LXYLjfjCHowdSOEc6dQ9Ro14KY7Bw4IWd19M= -github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef/go.mod h1:AThRsQH1t+dfyOKIwXRoJBniYFQUkUpQq4paheHMc2o= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/ultraviolet v0.0.0-20251114211333-9deacb990ee7 h1:PeoXe8fG5yb6uaimw4Om9eiXL74MzFSwSj3coBtDPmY= +github.com/charmbracelet/ultraviolet v0.0.0-20251114211333-9deacb990ee7/go.mod h1:6lfcr3MNP+kZR25sF1nQwJFuQnNYBlFy3PGX5rvslXc= +github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk= +github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0= +github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= +github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20251114205511-64e30b5ee1c5 h1:hB4cwUAX4XX7BFsk9OU/Kxhg7UFkvAIVI/6KKzK9o30= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20251114205511-64e30b5ee1c5/go.mod h1:URW1lCfdUqp9aey3HTV+jRvaGLuaj2d0jVk2NTRpklY= +github.com/charmbracelet/x/exp/color v0.0.0-20251006100439-2151805163c8 h1:X5wanVZ1RmBfIQFuIp4trGdL1F6eGYL7ekJaK47e6V4= +github.com/charmbracelet/x/exp/color v0.0.0-20251006100439-2151805163c8/go.mod h1:Fq7bG2T217JwA21s/gGh/uCMT5++zGDCt8l0MvbRxcA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I= +github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -49,46 +57,46 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= -github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= -github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= -github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= -github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= -github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= +github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= +github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= +github.com/muesli/mango-pflag v0.2.0 h1:QViokgKDZQCzKhYe1zH8D+UlPJzBSGoP9yx0hBG0t5k= +github.com/muesli/mango-pflag v0.2.0/go.mod h1:X9LT1p/pbGA1wjvEbtwnixujKErkP0jVmrxwrw3fL0Y= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/t.go b/internal/cmd/t.go index c4a881c..9784e80 100644 --- a/internal/cmd/t.go +++ b/internal/cmd/t.go @@ -19,6 +19,7 @@ import ( "github.com/unfunco/t/internal/list" "github.com/unfunco/t/internal/model" "github.com/unfunco/t/internal/storage" + "github.com/unfunco/t/internal/theme" "github.com/unfunco/t/internal/tui" "github.com/unfunco/t/internal/version" ) @@ -30,15 +31,21 @@ var ( ErrEmptyTitle = errors.New("todo title cannot be blank") ) -// NewDefaultTCommand returns a new t command configured with the standard -// input, output, and error file descriptors. -func NewDefaultTCommand() *cobra.Command { - return NewTCommand(os.Stdin, os.Stdout, os.Stderr) +// NewDefaultTCommandWithTheme returns a new t command using the provided theme +// and configured with the standard IO file descriptors. +func NewDefaultTCommandWithTheme(th theme.Theme) *cobra.Command { + return NewTCommandWithTheme(os.Stdin, os.Stdout, os.Stderr, th) } // NewTCommand returns a new t command configured with the given input, output, // and error file descriptors. func NewTCommand(in io.Reader, out, errOut io.Writer) *cobra.Command { + return NewTCommandWithTheme(in, out, errOut, theme.Default()) +} + +// NewTCommandWithTheme returns a new t command configured with the provided +// input, output, error descriptors and theme. +func NewTCommandWithTheme(in io.Reader, out, errOut io.Writer, th theme.Theme) *cobra.Command { var ( today bool tomorrow bool @@ -88,6 +95,7 @@ func NewTCommand(in io.Reader, out, errOut io.Writer) *cobra.Command { } m := tui.New( + th, lists[list.TodayID], lists[list.TomorrowID], lists[list.TodosID], diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..40d30e2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2025 Daniel Morris +// SPDX-License-Identifier: MIT + +// Package config handles loading and saving application configuration. +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/unfunco/t/internal/paths" + "github.com/unfunco/t/internal/theme" +) + +const configFilename = "config.json" + +// Config captures the configurable application properties. +type Config struct { + Theme theme.Config `json:"theme"` +} + +// Load retrieves the configuration from the default data directory. +func Load() (Config, error) { + dataDir, err := paths.DefaultConfigDir() + if err != nil { + return Config{}, fmt.Errorf("determine config directory: %w", err) + } + + return LoadFromDir(dataDir) +} + +// LoadFromDir retrieves the configuration from the provided directory. +func LoadFromDir(configDir string) (Config, error) { + if configDir == "" { + return Config{}, fmt.Errorf("config directory cannot be empty") + } + + cfg := Config{ + Theme: theme.DefaultConfig(), + } + + configPath := filepath.Join(configDir, configFilename) + + data, err := os.ReadFile(configPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return cfg, nil + } + + return Config{}, fmt.Errorf("read config: %w", err) + } + + if len(data) == 0 { + return cfg, nil + } + + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("decode config: %w", err) + } + + return cfg, nil +} + +// Path returns the default configuration file path. +func Path() (string, error) { + configDir, err := paths.DefaultConfigDir() + if err != nil { + return "", fmt.Errorf("determine config directory: %w", err) + } + + return filepath.Join(configDir, configFilename), nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..b99d5af --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2025 Daniel Morris +// SPDX-License-Identifier: MIT + +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/unfunco/t/internal/theme" +) + +func TestLoadFromDirReturnsDefaultsWhenMissing(t *testing.T) { + dir := t.TempDir() + + cfg, err := LoadFromDir(dir) + if err != nil { + t.Fatalf("LoadFromDir() error = %v", err) + } + + if want := theme.DefaultConfig(); cfg.Theme != want { + t.Fatalf("theme mismatch, want %+v got %+v", want, cfg.Theme) + } +} + +func TestLoadFromDirReadsConfigFile(t *testing.T) { + dir := t.TempDir() + content := []byte(`{ + "theme": { + "text": "#010101", + "muted": "#020202", + "highlight": "#030303", + "success": "#040404", + "worry": "#050505" + } +}`) + + if err := os.WriteFile(filepath.Join(dir, "config.json"), content, 0o600); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + cfg, err := LoadFromDir(dir) + if err != nil { + t.Fatalf("LoadFromDir() error = %v", err) + } + + want := theme.Config{ + Text: "#010101", + Muted: "#020202", + Highlight: "#030303", + Success: "#040404", + Worry: "#050505", + } + + if cfg.Theme != want { + t.Fatalf("theme mismatch, want %+v got %+v", want, cfg.Theme) + } +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go new file mode 100644 index 0000000..6545849 --- /dev/null +++ b/internal/paths/paths.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025 Daniel Morris +// SPDX-License-Identifier: MIT + +// Package paths centralises platform-specific filesystem locations. +package paths + +import ( + "fmt" + "os" + "path/filepath" +) + +const app = "t" + +// DefaultDataDir returns the standard directory used for mutable data. +func DefaultDataDir() (string, error) { + return resolveDir("XDG_DATA_HOME", func(home string) string { + return filepath.Join(home, ".local", "share") + }) +} + +// DefaultConfigDir returns the standard directory used for configuration. +func DefaultConfigDir() (string, error) { + return resolveDir("XDG_CONFIG_HOME", func(home string) string { + return filepath.Join(home, ".config") + }) +} + +func resolveDir(envVar string, fallback func(home string) string) (string, error) { + if dir := os.Getenv(envVar); dir != "" { + return filepath.Join(dir, app), nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + return filepath.Join(fallback(home), app), nil +} diff --git a/internal/storage/file.go b/internal/storage/file.go index 6ed8e8d..4dac7bc 100644 --- a/internal/storage/file.go +++ b/internal/storage/file.go @@ -11,6 +11,7 @@ import ( "github.com/unfunco/t/internal/list" "github.com/unfunco/t/internal/model" + "github.com/unfunco/t/internal/paths" ) // File persists todos on disk. @@ -23,7 +24,7 @@ var _ Storage = (*File)(nil) // NewFileStorage creates file-backed storage rooted in the default data // directory, typically ~/.local/share/t. func NewFileStorage() (*File, error) { - dataDir, err := getDataDir() + dataDir, err := paths.DefaultDataDir() if err != nil { return nil, fmt.Errorf("failed to get data directory: %w", err) } @@ -40,24 +41,6 @@ func NewFileStorageWithDir(dataDir string) (*File, error) { return &File{dataDir}, nil } -// getDataDir returns the path to the data directory. -func getDataDir() (string, error) { - var configDir string - - if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" { - configDir = xdgDataHome - } else { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get user home directory: %w", err) - } - - configDir = filepath.Join(homeDir, ".local", "share") - } - - return filepath.Join(configDir, "t"), nil -} - // ensureDataDir creates the data directory if it doesn't exist. func (s *File) ensureDataDir() error { if err := os.MkdirAll(s.dataDir, 0o700); err != nil { diff --git a/internal/theme/theme.go b/internal/theme/theme.go index f0cb57a..e5c4f8d 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -14,11 +14,22 @@ import ( // Config represents the raw theme configuration values. type Config struct { - Text string - Muted string - Highlight string - Success string - Worry string + Text string `json:"text"` + Muted string `json:"muted"` + Highlight string `json:"highlight"` + Success string `json:"success"` + Worry string `json:"worry"` +} + +// DefaultConfig returns the built-in theme configuration. +func DefaultConfig() Config { + return Config{ + Text: "#FFFFFF", + Muted: "#696969", + Highlight: "#58C5C7", + Success: "#99CC00", + Worry: "#FF7676", + } } // Theme defines the theme for the TUI and CLI help docs. @@ -29,11 +40,11 @@ type Theme struct { Success PaletteColor Worry PaletteColor - // UI characters - CursorChar string // Character shown next to selected todo item + CursorChar string // Character shown next to selected todo item. } -// PaletteColor keeps a hex colour string for LipGloss and an RGBA version for Fang. +// PaletteColor keeps a hex colour string for LipGloss and an RGBA version +// for Fang. type PaletteColor struct { raw string rgba color.RGBA @@ -51,13 +62,7 @@ func (c PaletteColor) RGBA() color.RGBA { // Default returns the default theme. func Default() Theme { - return MustFromConfig(Config{ - Text: "#FFFFFF", - Muted: "#696969", - Highlight: "#58C5C7", - Success: "#99CC00", - Worry: "#FF7676", - }) + return MustFromConfig(DefaultConfig()) } // MustFromConfig creates a Theme from a Config and panics if parsing fails. @@ -66,6 +71,7 @@ func MustFromConfig(cfg Config) Theme { if err != nil { panic(fmt.Sprintf("invalid theme configuration: %v", err)) } + return t } @@ -133,7 +139,7 @@ func normalizeHex(input string) (string, error) { case 3: s = fmt.Sprintf("%c%c%c%c%c%c", s[0], s[0], s[1], s[1], s[2], s[2]) case 6: - // Sound. + // All good in the hood. default: return "", fmt.Errorf("hex colour must be 3 or 6 characters, got %d", len(s)) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 2751b73..966fab7 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -154,8 +154,8 @@ type Model struct { editingIndex int } -// New creates a new TUI model with the provided todo lists. -func New(todayList, tomorrowList, todoList *model.TodoList) Model { +// New creates a new TUI model with the provided todo lists and theme. +func New(th theme.Theme, todayList, tomorrowList, todoList *model.TodoList) Model { ti := textinput.New() ti.Placeholder = "Todo title" ti.CharLimit = 100 @@ -171,7 +171,7 @@ func New(todayList, tomorrowList, todoList *model.TodoList) Model { keys: DefaultKeyMap(), activeTab: TabToday, cursor: 0, - theme: theme.Default(), + theme: th, todayList: todayList, tomorrowList: tomorrowList, todoList: todoList, diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index b35291c..4ed70de 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/unfunco/t/internal/model" + "github.com/unfunco/t/internal/theme" ) var todoCounter int @@ -43,7 +44,7 @@ func newTestModel() Model { Name: "Todos", Todos: []model.Todo{newTestTodo("General task", "")}, } - return New(todayList, tomorrowList, todoList) + return New(theme.Default(), todayList, tomorrowList, todoList) } func TestNew(t *testing.T) { diff --git a/main.go b/main.go index 27ad496..0c4bd4f 100644 --- a/main.go +++ b/main.go @@ -2,29 +2,71 @@ package main import ( "context" + "fmt" "image/color" "os" + "charm.land/lipgloss/v2" "github.com/charmbracelet/fang" - "github.com/charmbracelet/lipgloss/v2" "github.com/unfunco/t/internal/cmd" + "github.com/unfunco/t/internal/config" "github.com/unfunco/t/internal/theme" ) func main() { + configPath := "" + if path, err := config.Path(); err == nil { + configPath = path + } + + cfg := config.Config{ + Theme: theme.DefaultConfig(), + } + if loadedCfg, err := config.Load(); err != nil { + logConfigWarning(configPath, err) + } else { + cfg = loadedCfg + } + + th := theme.Default() + if parsedTheme, err := theme.FromConfig(cfg.Theme); err != nil { + logThemeWarning(configPath, err) + } else { + th = parsedTheme + } + if err := fang.Execute( context.Background(), - cmd.NewDefaultTCommand(), - fang.WithColorSchemeFunc(customColorScheme), + cmd.NewDefaultTCommandWithTheme(th), + fang.WithColorSchemeFunc(func(c lipgloss.LightDarkFunc) fang.ColorScheme { + return customColorScheme(c, th) + }), fang.WithNotifySignal(os.Interrupt, os.Kill), ); err != nil { os.Exit(1) } } -func customColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { +func logConfigWarning(configPath string, err error) { + if configPath == "" { + _, _ = fmt.Fprintf(os.Stderr, "warning: failed to load config: %v; using defaults\n", err) + return + } + + _, _ = fmt.Fprintf(os.Stderr, "warning: failed to load config at %s: %v; using defaults\n", configPath, err) +} + +func logThemeWarning(configPath string, err error) { + if configPath == "" { + _, _ = fmt.Fprintf(os.Stderr, "warning: invalid theme configuration: %v; using default theme\n", err) + return + } + + _, _ = fmt.Fprintf(os.Stderr, "warning: invalid theme configuration in %s: %v; using default theme\n", configPath, err) +} + +func customColorScheme(c lipgloss.LightDarkFunc, th theme.Theme) fang.ColorScheme { scheme := fang.AnsiColorScheme(c) - th := theme.Default() scheme.Base = th.Text.RGBA() scheme.Description = th.Muted.RGBA()