diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6f28a04 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,28 @@ +name: Tests + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + - "codex/**" + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Check syntax + run: python3 -m py_compile themaker.py test_themaker.py + + - name: Run tests + run: python3 -m unittest diff --git a/.gitignore b/.gitignore index d517af6..201f8df 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ __pycache__/ *.py[cod] .venv/ venv/ +.idea/ used_urls.log colors/ *.itermcolors +!examples/ +!examples/*.itermcolors diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..22a067b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 @ylub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..27b97ed --- /dev/null +++ b/README.md @@ -0,0 +1,224 @@ +# THEMaker + +THEMaker turns a Coolors palette or a list of hex colors into terminal color +themes. + +It currently exports: + +- iTerm2 `.itermcolors` +- macOS Terminal `.terminal` +- Kitty `.conf` +- Alacritty `.toml` +- WezTerm `.lua` +- Source `.yaml` for terminal color scheme repos +- Portable JSON theme data for someone else or another tool to export later + +THEMaker uses only the Python standard library. + +![THEMaker ANSI color suggestions](docs/themaker1.png) + +![THEMaker terminal preview](docs/themaker2.png) + +Screenshots captured in iTerm2. + +## Quick Start + +Run the wizard: + +```bash +python3 themaker.py +``` + +Check the version: + +```bash +python3 themaker.py --version +``` + +Use a palette from the command line and continue in the wizard: + +```bash +python3 themaker.py --palette "25ced1 ffffff fceade ff8a5b ea526f" +``` + +Hex colors may be written as 6-digit RGB, 8-digit RGBA, or short 4-digit RGBA. +Alpha values are accepted for convenience and ignored when exporting terminal +themes. + +Choose export formats up front: + +```bash +python3 themaker.py --palette "25ced1 ffffff fceade ff8a5b ea526f" --format "iterm kitty" +``` + +Save somewhere else: + +```bash +python3 themaker.py --out ./exports +``` + +Edit an existing iTerm theme: + +```bash +python3 themaker.py --edit colors/example.itermcolors +``` + +List existing editable iTerm themes in an output folder: + +```bash +python3 themaker.py --list-themes --out colors +``` + +Show credits and project information: + +```bash +python3 themaker.py --about +``` + +Skip the startup banner in interactive mode: + +```bash +python3 themaker.py --no-splash +``` + +## Export Choices + +At the save step, THEMaker asks what to export: + +- `all` exports every supported format +- `one` asks for a single format +- `some` asks for multiple formats +- `data` saves only portable JSON theme data + +Supported format names are: + +```text +iterm terminal kitty alacritty wezterm yaml data +``` + +| Format | Extension | Use | +| --- | --- | --- | +| `iterm` | `.itermcolors` | Import into iTerm2. | +| `terminal` | `.terminal` | Import into macOS Terminal. | +| `kitty` | `.conf` | Include or copy into Kitty config. | +| `alacritty` | `.toml` | Import or copy into Alacritty config. | +| `wezterm` | `.lua` | Require or copy into WezTerm config. | +| `yaml` | `.yaml` | Submit source schemes to terminal color scheme repos. | +| `data` | `.json` | Save portable theme data for another tool or maintainer. | + +If a target file already exists, the wizard asks before overwriting it. + +## Examples + +The `examples/` folder contains one sample palette exported to every supported +format. These files are useful for checking the output shape before importing a +theme into your own terminal. + +## Using Exported Themes + +iTerm2: + +On macOS, opening the generated `.itermcolors` file will import it into iTerm2. +You can also import it manually: + +1. Open Settings. +2. Go to Profiles, then Colors. +3. Open Color Presets. +4. Choose Import and select the `.itermcolors` file. + +macOS Terminal: + +Open the generated `.terminal` file to import the preset into Terminal. +You can also import it manually from Terminal settings: + +1. Open Settings. +2. Go to Profiles. +3. Open the More menu. +4. Choose Import and select the `.terminal` file. + +Kitty: + +```bash +include /path/to/theme.conf +``` + +Add that line to your Kitty config, or copy the generated color lines into it. + +Alacritty: + +```toml +import = ["/path/to/theme.toml"] +``` + +Add that to your Alacritty config, or copy the generated `[colors]` sections +into it. + +WezTerm: + +```lua +local theme = require("theme") + +return { + colors = theme, +} +``` + +Put the generated `.lua` file somewhere WezTerm can require it, or copy the +returned table into your WezTerm config. + +YAML: + +The `.yaml` export follows the source scheme guidance from +[`yaml/README.md` in `mbadolato/iTerm2-Color-Schemes`](https://github.com/mbadolato/iTerm2-Color-Schemes/blob/master/yaml/README.md). +It writes `color_01` through `color_16`, plus the optional extra keys described +there, including `badge`, `bold`, `cursor_guide`, `cursor_text`, `link`, +`selection_text`, `selection`, `tab`, and `underline`. + +## Contributing Schemes + +For terminal color scheme repositories that accept source YAML files, export +with: + +```bash +python3 themaker.py --format yaml +``` + +Review the generated file before submitting it, especially the theme name, +normal ANSI colors, bright ANSI colors, selection colors, and cursor colors. + +## Bright And Sibling Suggestions + +After ANSI colors are chosen, THEMaker can suggest brighter ANSI colors for the +same theme. These are previewed as `current -> suggested` and are only applied +if you confirm. + +When you customize ANSI roles, THEMaker also offers extra color suggestions. +Those include palette complements and a few palette-fit accent colors tuned to +the selected theme family. + +After exporting a theme, THEMaker can also suggest sibling themes from the same +palette, such as a bright pastel variant or a softer dark variant. These are +also opt-in. + +## Tests + +Run: + +```bash +python3 -m unittest +``` + +## Releases + +The first public release should be tagged as `v0.2.0` after the public export +branch is merged. + +## Credits + +Created by [@ylub](https://github.com/ylub). + +Project repo: [github.com/ylub/themaker](https://github.com/ylub/themaker). + +Built with help from Codex. + +Inspired by palette ideas from [Coolors](https://coolors.co). diff --git a/docs/themaker1.png b/docs/themaker1.png new file mode 100644 index 0000000..9fc2b32 Binary files /dev/null and b/docs/themaker1.png differ diff --git a/docs/themaker2.png b/docs/themaker2.png new file mode 100644 index 0000000..91a5648 Binary files /dev/null and b/docs/themaker2.png differ diff --git a/examples/coolors-dark-balanced-sample.conf b/examples/coolors-dark-balanced-sample.conf new file mode 100644 index 0000000..da5c751 --- /dev/null +++ b/examples/coolors-dark-balanced-sample.conf @@ -0,0 +1,25 @@ +# Generated by THEMaker 0.2.0 +# Palette: #25CED1 #FFFFFF #FCEADE #FF8A5B #EA526F +foreground #F8F8F2 +background #101418 +cursor #FFFFFF +cursor_text_color #101418 +selection_foreground #101418 +selection_background #334155 + +color0 #101418 +color1 #EA526F +color2 #25CED1 +color3 #FCEADE +color4 #25CED1 +color5 #FF8A5B +color6 #FFFFFF +color7 #F8F8F2 +color8 #FCEADE +color9 #FA5676 +color10 #42EEF1 +color11 #FCEADE +color12 #42EEF1 +color13 #FF8352 +color14 #FFFFFF +color15 #FFFFFF diff --git a/examples/coolors-dark-balanced-sample.itermcolors b/examples/coolors-dark-balanced-sample.itermcolors new file mode 100644 index 0000000..770a49b --- /dev/null +++ b/examples/coolors-dark-balanced-sample.itermcolors @@ -0,0 +1,315 @@ + + + + + Ansi 0 Color + + Alpha Component + 1.0 + Blue Component + 0.09411764705882353 + Color Space + sRGB + Green Component + 0.0784313725490196 + Red Component + 0.06274509803921569 + + Ansi 1 Color + + Alpha Component + 1.0 + Blue Component + 0.43529411764705883 + Color Space + sRGB + Green Component + 0.3215686274509804 + Red Component + 0.9176470588235294 + + Ansi 10 Color + + Alpha Component + 1.0 + Blue Component + 0.9450980392156862 + Color Space + sRGB + Green Component + 0.9333333333333333 + Red Component + 0.25882352941176473 + + Ansi 11 Color + + Alpha Component + 1.0 + Blue Component + 0.8705882352941177 + Color Space + sRGB + Green Component + 0.9176470588235294 + Red Component + 0.9882352941176471 + + Ansi 12 Color + + Alpha Component + 1.0 + Blue Component + 0.9450980392156862 + Color Space + sRGB + Green Component + 0.9333333333333333 + Red Component + 0.25882352941176473 + + Ansi 13 Color + + Alpha Component + 1.0 + Blue Component + 0.3215686274509804 + Color Space + sRGB + Green Component + 0.5137254901960784 + Red Component + 1.0 + + Ansi 14 Color + + Alpha Component + 1.0 + Blue Component + 1.0 + Color Space + sRGB + Green Component + 1.0 + Red Component + 1.0 + + Ansi 15 Color + + Alpha Component + 1.0 + Blue Component + 1.0 + Color Space + sRGB + Green Component + 1.0 + Red Component + 1.0 + + Ansi 2 Color + + Alpha Component + 1.0 + Blue Component + 0.8196078431372549 + Color Space + sRGB + Green Component + 0.807843137254902 + Red Component + 0.1450980392156863 + + Ansi 3 Color + + Alpha Component + 1.0 + Blue Component + 0.8705882352941177 + Color Space + sRGB + Green Component + 0.9176470588235294 + Red Component + 0.9882352941176471 + + Ansi 4 Color + + Alpha Component + 1.0 + Blue Component + 0.8196078431372549 + Color Space + sRGB + Green Component + 0.807843137254902 + Red Component + 0.1450980392156863 + + Ansi 5 Color + + Alpha Component + 1.0 + Blue Component + 0.3568627450980392 + Color Space + sRGB + Green Component + 0.5411764705882353 + Red Component + 1.0 + + Ansi 6 Color + + Alpha Component + 1.0 + Blue Component + 1.0 + Color Space + sRGB + Green Component + 1.0 + Red Component + 1.0 + + Ansi 7 Color + + Alpha Component + 1.0 + Blue Component + 0.9490196078431372 + Color Space + sRGB + Green Component + 0.9725490196078431 + Red Component + 0.9725490196078431 + + Ansi 8 Color + + Alpha Component + 1.0 + Blue Component + 0.8705882352941177 + Color Space + sRGB + Green Component + 0.9176470588235294 + Red Component + 0.9882352941176471 + + Ansi 9 Color + + Alpha Component + 1.0 + Blue Component + 0.4627450980392157 + Color Space + sRGB + Green Component + 0.33725490196078434 + Red Component + 0.9803921568627451 + + Background Color + + Alpha Component + 1.0 + Blue Component + 0.09411764705882353 + Color Space + sRGB + Green Component + 0.0784313725490196 + Red Component + 0.06274509803921569 + + Bold Color + + Alpha Component + 1.0 + Blue Component + 0.9490196078431372 + Color Space + sRGB + Green Component + 0.9725490196078431 + Red Component + 0.9725490196078431 + + Cursor Color + + Alpha Component + 1.0 + Blue Component + 1.0 + Color Space + sRGB + Green Component + 1.0 + Red Component + 1.0 + + Cursor Text Color + + Alpha Component + 1.0 + Blue Component + 0.09411764705882353 + Color Space + sRGB + Green Component + 0.0784313725490196 + Red Component + 0.06274509803921569 + + Foreground Color + + Alpha Component + 1.0 + Blue Component + 0.9490196078431372 + Color Space + sRGB + Green Component + 0.9725490196078431 + Red Component + 0.9725490196078431 + + Selected Text Color + + Alpha Component + 1.0 + Blue Component + 0.09411764705882353 + Color Space + sRGB + Green Component + 0.0784313725490196 + Red Component + 0.06274509803921569 + + Selection Color + + Alpha Component + 1.0 + Blue Component + 0.3333333333333333 + Color Space + sRGB + Green Component + 0.2549019607843137 + Red Component + 0.2 + + TheMaker Family + dark + TheMaker Generator + THEMaker 0.2.0 + TheMaker Mode + balanced + TheMaker Original Palette + #25CED1 #FFFFFF #FCEADE #FF8A5B #EA526F + TheMaker Palette Source + 25ced1 ffffff fceade ff8a5b ea526f + + diff --git a/examples/coolors-dark-balanced-sample.json b/examples/coolors-dark-balanced-sample.json new file mode 100644 index 0000000..28306a7 --- /dev/null +++ b/examples/coolors-dark-balanced-sample.json @@ -0,0 +1,38 @@ +{ + "generator": "THEMaker 0.2.0", + "name": "coolors-dark-balanced-sample", + "palette": "#25CED1 #FFFFFF #FCEADE #FF8A5B #EA526F", + "palette_source": "25ced1 ffffff fceade ff8a5b ea526f", + "family": "dark", + "family_label": "Dark", + "mode": "balanced", + "colors": { + "background": "101418", + "foreground": "F8F8F2", + "bold": "F8F8F2", + "cursor": "FFFFFF", + "cursor_text": "101418", + "selection": "334155", + "selected_text": "101418", + "ansi": [ + "101418", + "EA526F", + "25CED1", + "FCEADE", + "25CED1", + "FF8A5B", + "FFFFFF", + "F8F8F2" + ], + "bright": [ + "FCEADE", + "FA5676", + "42EEF1", + "FCEADE", + "42EEF1", + "FF8352", + "FFFFFF", + "FFFFFF" + ] + } +} diff --git a/examples/coolors-dark-balanced-sample.lua b/examples/coolors-dark-balanced-sample.lua new file mode 100644 index 0000000..2e3aa94 --- /dev/null +++ b/examples/coolors-dark-balanced-sample.lua @@ -0,0 +1,12 @@ +-- Generated by THEMaker 0.2.0 +-- Palette: #25CED1 #FFFFFF #FCEADE #FF8A5B #EA526F +return { + foreground = "#F8F8F2", + background = "#101418", + cursor_bg = "#FFFFFF", + cursor_fg = "#101418", + selection_bg = "#334155", + selection_fg = "#101418", + ansi = { "#101418", "#EA526F", "#25CED1", "#FCEADE", "#25CED1", "#FF8A5B", "#FFFFFF", "#F8F8F2" }, + brights = { "#FCEADE", "#FA5676", "#42EEF1", "#FCEADE", "#42EEF1", "#FF8352", "#FFFFFF", "#FFFFFF" }, +} diff --git a/examples/coolors-dark-balanced-sample.terminal b/examples/coolors-dark-balanced-sample.terminal new file mode 100644 index 0000000..efd0490 --- /dev/null +++ b/examples/coolors-dark-balanced-sample.terminal @@ -0,0 +1,285 @@ + + + + + ANSIBlackColor + + Alpha Component + 1.0 + Blue Component + 0.09411764705882353 + Color Space + sRGB + Green Component + 0.0784313725490196 + Red Component + 0.06274509803921569 + + ANSIBlueColor + + Alpha Component + 1.0 + Blue Component + 0.8196078431372549 + Color Space + sRGB + Green Component + 0.807843137254902 + Red Component + 0.1450980392156863 + + ANSIBrightBlackColor + + Alpha Component + 1.0 + Blue Component + 0.8705882352941177 + Color Space + sRGB + Green Component + 0.9176470588235294 + Red Component + 0.9882352941176471 + + ANSIBrightBlueColor + + Alpha Component + 1.0 + Blue Component + 0.9450980392156862 + Color Space + sRGB + Green Component + 0.9333333333333333 + Red Component + 0.25882352941176473 + + ANSIBrightCyanColor + + Alpha Component + 1.0 + Blue Component + 1.0 + Color Space + sRGB + Green Component + 1.0 + Red Component + 1.0 + + ANSIBrightGreenColor + + Alpha Component + 1.0 + Blue Component + 0.9450980392156862 + Color Space + sRGB + Green Component + 0.9333333333333333 + Red Component + 0.25882352941176473 + + ANSIBrightMagentaColor + + Alpha Component + 1.0 + Blue Component + 0.3215686274509804 + Color Space + sRGB + Green Component + 0.5137254901960784 + Red Component + 1.0 + + ANSIBrightRedColor + + Alpha Component + 1.0 + Blue Component + 0.4627450980392157 + Color Space + sRGB + Green Component + 0.33725490196078434 + Red Component + 0.9803921568627451 + + ANSIBrightWhiteColor + + Alpha Component + 1.0 + Blue Component + 1.0 + Color Space + sRGB + Green Component + 1.0 + Red Component + 1.0 + + ANSIBrightYellowColor + + Alpha Component + 1.0 + Blue Component + 0.8705882352941177 + Color Space + sRGB + Green Component + 0.9176470588235294 + Red Component + 0.9882352941176471 + + ANSICyanColor + + Alpha Component + 1.0 + Blue Component + 1.0 + Color Space + sRGB + Green Component + 1.0 + Red Component + 1.0 + + ANSIGreenColor + + Alpha Component + 1.0 + Blue Component + 0.8196078431372549 + Color Space + sRGB + Green Component + 0.807843137254902 + Red Component + 0.1450980392156863 + + ANSIMagentaColor + + Alpha Component + 1.0 + Blue Component + 0.3568627450980392 + Color Space + sRGB + Green Component + 0.5411764705882353 + Red Component + 1.0 + + ANSIRedColor + + Alpha Component + 1.0 + Blue Component + 0.43529411764705883 + Color Space + sRGB + Green Component + 0.3215686274509804 + Red Component + 0.9176470588235294 + + ANSIWhiteColor + + Alpha Component + 1.0 + Blue Component + 0.9490196078431372 + Color Space + sRGB + Green Component + 0.9725490196078431 + Red Component + 0.9725490196078431 + + ANSIYellowColor + + Alpha Component + 1.0 + Blue Component + 0.8705882352941177 + Color Space + sRGB + Green Component + 0.9176470588235294 + Red Component + 0.9882352941176471 + + BackgroundColor + + Alpha Component + 1.0 + Blue Component + 0.09411764705882353 + Color Space + sRGB + Green Component + 0.0784313725490196 + Red Component + 0.06274509803921569 + + BoldTextColor + + Alpha Component + 1.0 + Blue Component + 0.9490196078431372 + Color Space + sRGB + Green Component + 0.9725490196078431 + Red Component + 0.9725490196078431 + + CursorColor + + Alpha Component + 1.0 + Blue Component + 1.0 + Color Space + sRGB + Green Component + 1.0 + Red Component + 1.0 + + ProfileCurrentVersion + 2.07 + SelectionColor + + Alpha Component + 1.0 + Blue Component + 0.3333333333333333 + Color Space + sRGB + Green Component + 0.2549019607843137 + Red Component + 0.2 + + TextColor + + Alpha Component + 1.0 + Blue Component + 0.9490196078431372 + Color Space + sRGB + Green Component + 0.9725490196078431 + Red Component + 0.9725490196078431 + + name + coolors-dark-balanced-sample + type + Window Settings + + diff --git a/examples/coolors-dark-balanced-sample.toml b/examples/coolors-dark-balanced-sample.toml new file mode 100644 index 0000000..2af00be --- /dev/null +++ b/examples/coolors-dark-balanced-sample.toml @@ -0,0 +1,33 @@ +# Generated by THEMaker 0.2.0 +# Palette: #25CED1 #FFFFFF #FCEADE #FF8A5B #EA526F +[colors.primary] +background = "#101418" +foreground = "#F8F8F2" + +[colors.cursor] +text = "#101418" +cursor = "#FFFFFF" + +[colors.selection] +text = "#101418" +background = "#334155" + +[colors.normal] +black = "#101418" +red = "#EA526F" +green = "#25CED1" +yellow = "#FCEADE" +blue = "#25CED1" +magenta = "#FF8A5B" +cyan = "#FFFFFF" +white = "#F8F8F2" + +[colors.bright] +black = "#FCEADE" +red = "#FA5676" +green = "#42EEF1" +yellow = "#FCEADE" +blue = "#42EEF1" +magenta = "#FF8352" +cyan = "#FFFFFF" +white = "#FFFFFF" diff --git a/examples/coolors-dark-balanced-sample.yaml b/examples/coolors-dark-balanced-sample.yaml new file mode 100644 index 0000000..b758f7a --- /dev/null +++ b/examples/coolors-dark-balanced-sample.yaml @@ -0,0 +1,33 @@ +# Generated by THEMaker 0.2.0 +# Palette: #25CED1 #FFFFFF #FCEADE #FF8A5B #EA526F +name: "coolors-dark-balanced-sample" +author: "@ylub" +background: "#101418" +foreground: "#F8F8F2" +cursor: "#FFFFFF" +badge: "#FFFFFF" +bold: "#F8F8F2" +cursor_guide: "#FFFFFF" +cursor_text: "#101418" +link: "#FFFFFF" +selection_text: "#101418" +selection: "#334155" +tab: "#101418" +underline: "#F8F8F2" + +color_01: "#101418" +color_02: "#EA526F" +color_03: "#25CED1" +color_04: "#FCEADE" +color_05: "#25CED1" +color_06: "#FF8A5B" +color_07: "#FFFFFF" +color_08: "#F8F8F2" +color_09: "#FCEADE" +color_10: "#FA5676" +color_11: "#42EEF1" +color_12: "#FCEADE" +color_13: "#42EEF1" +color_14: "#FF8352" +color_15: "#FFFFFF" +color_16: "#FFFFFF" diff --git a/test_themaker.py b/test_themaker.py new file mode 100644 index 0000000..3b94d74 --- /dev/null +++ b/test_themaker.py @@ -0,0 +1,152 @@ +import plistlib +import tempfile +import unittest +from pathlib import Path + +import themaker + +PALETTE = "25ced1 ffffff fceade ff8a5b ea526f" + + +class TheMakerTests(unittest.TestCase): + def build_model(self): + colors = themaker.parse_palette_input(PALETTE) + family = themaker.THEME_FAMILIES[0] + roles = themaker.palette_roles(colors, family) + return themaker.make_theme_model( + colors, + "101418", + "F8F8F2", + "balanced", + family, + roles, + PALETTE, + "sample", + ) + + def test_parse_palette_input_accepts_hex_and_coolors_url(self): + expected = ["25CED1", "FFFFFF", "FCEADE", "FF8A5B", "EA526F"] + self.assertEqual(themaker.parse_palette_input(PALETTE), expected) + self.assertEqual( + themaker.parse_palette_input( + "https://coolors.co/25ced1-ffffff-fceade-ff8a5b-ea526f" + ), + expected, + ) + + def test_parse_palette_input_accepts_alpha_hex(self): + self.assertEqual( + themaker.parse_palette_input("#abcd 11223344 25ced1ff ffffff80 00000000"), + ["AABBCC", "112233", "25CED1", "FFFFFF", "000000"], + ) + + def test_parse_palette_input_rejects_short_palette(self): + with self.assertRaises(ValueError): + themaker.parse_palette_input("25ced1 ffffff") + + def test_theme_model_has_terminal_colors(self): + model = self.build_model() + self.assertEqual(model["palette"], "#25CED1 #FFFFFF #FCEADE #FF8A5B #EA526F") + self.assertEqual(len(model["colors"]["ansi"]), 8) + self.assertEqual(len(model["colors"]["bright"]), 8) + + def test_bright_terminal_slots_differ_from_normal_colors(self): + colors = themaker.parse_palette_input("f25757 8085b3 84a07c c3d350 eff68d") + family = themaker.THEME_FAMILIES[0] + roles = { + "black": "101418", + "red": "FF5252", + "green": "87F069", + "yellow": "F3FF52", + "blue": "616FEF", + "magenta": "D269F0", + "cyan": "52FFFF", + "bright_black": "E0F06A", + } + model = themaker.make_theme_model( + colors, + "101418", + "EAF6FF", + "balanced", + family, + roles, + "", + "one second test", + ) + for normal, bright in zip( + model["colors"]["ansi"][1:7], model["colors"]["bright"][1:7] + ): + self.assertNotEqual(normal, bright) + self.assertEqual(model["colors"]["bright"][7], "FFFFFF") + + def test_exporters_write_expected_files(self): + model = self.build_model() + with tempfile.TemporaryDirectory() as directory: + out_dir = Path(directory) + written, skipped = themaker.export_theme_files( + model, + out_dir, + ["iterm", "terminal", "kitty", "alacritty", "wezterm", "yaml", "data"], + ) + self.assertEqual(skipped, []) + self.assertEqual(len(written), 7) + self.assertTrue((out_dir / "sample.itermcolors").exists()) + self.assertTrue((out_dir / "sample.terminal").exists()) + self.assertIn("foreground #F8F8F2", (out_dir / "sample.conf").read_text()) + self.assertIn("[colors.primary]", (out_dir / "sample.toml").read_text()) + self.assertIn("return {", (out_dir / "sample.lua").read_text()) + yaml_text = (out_dir / "sample.yaml").read_text() + self.assertIn('color_01: "#101418"', yaml_text) + self.assertIn('color_16: "#FFFFFF"', yaml_text) + self.assertIn('badge: "#FFFFFF"', yaml_text) + self.assertIn('selection_text: "#101418"', yaml_text) + self.assertIn('"palette"', (out_dir / "sample.json").read_text()) + + with (out_dir / "sample.itermcolors").open("rb") as handle: + plist = plistlib.load(handle) + self.assertEqual(plist[themaker.ORIGINAL_PALETTE_KEY], model["palette"]) + with (out_dir / "sample.terminal").open("rb") as handle: + terminal_plist = plistlib.load(handle) + self.assertEqual(terminal_plist["TextColor"], themaker.rgb_plist("F8F8F2")) + self.assertEqual( + terminal_plist["ANSIBrightRedColor"], + themaker.rgb_plist(model["colors"]["bright"][1]), + ) + + def test_existing_files_skip_without_overwrite(self): + model = self.build_model() + with tempfile.TemporaryDirectory() as directory: + out_dir = Path(directory) + themaker.export_theme_files(model, out_dir, ["kitty"]) + written, skipped = themaker.export_theme_files(model, out_dir, ["kitty"]) + self.assertEqual(written, []) + self.assertEqual(skipped, [out_dir / "sample.conf"]) + + def test_bright_role_suggestions_are_valid_hex(self): + colors = themaker.parse_palette_input(PALETTE) + family = themaker.THEME_FAMILIES[0] + roles = themaker.palette_roles(colors, family) + suggestions = themaker.bright_role_suggestions(roles, family) + self.assertEqual( + set(suggestions), {"red", "green", "yellow", "blue", "magenta", "cyan"} + ) + for color in suggestions.values(): + self.assertEqual(themaker.clean_hex(color), color) + self.assertTrue(any(suggestions[role] != roles[role] for role in suggestions)) + + def test_extra_color_options_include_complements_and_palette_fit_colors(self): + colors = themaker.parse_palette_input("ef4444 22c55e ec4899 a3a3a3 f8fafc") + family = themaker.THEME_FAMILIES[0] + options = themaker.extra_color_options(colors, family, "balanced") + names = [name for name, _color in options] + self.assertTrue(any("Complement" in name for name in names)) + self.assertTrue(any("Palette-fit" in name for name in names)) + self.assertLessEqual( + sum(1 for name in names if name.startswith("Palette-fit")), 4 + ) + for _name, color in options: + self.assertEqual(themaker.clean_hex(color), color) + + +if __name__ == "__main__": + unittest.main() diff --git a/themaker.py b/themaker.py index b5ff94e..8ae33c3 100644 --- a/themaker.py +++ b/themaker.py @@ -1,17 +1,46 @@ #!/usr/bin/env python3 +import argparse import colorsys +import json import plistlib import re from datetime import datetime from urllib.parse import urlparse from pathlib import Path +APP_NAME = "THEMaker" +APP_VERSION = "0.2.0" +APP_AUTHOR = "@ylub" +PROJECT_URL = "https://github.com/ylub/themaker" +COOLORS_URL = "https://coolors.co" DEFAULT_FG = "F8F8F2" APP_DIR = Path(__file__).resolve().parent URL_LOG_FILE = APP_DIR / "used_urls.log" OUTPUT_DIR = APP_DIR / "colors" ORIGINAL_PALETTE_KEY = "TheMaker Original Palette" PALETTE_SOURCE_KEY = "TheMaker Palette Source" +GENERATOR_KEY = "TheMaker Generator" +FAMILY_KEY = "TheMaker Family" +MODE_KEY = "TheMaker Mode" +EXPORT_FORMATS = ( + "iterm", + "terminal", + "kitty", + "alacritty", + "wezterm", + "yaml", + "data", +) +TERMINAL_ROLE_NAMES = ( + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", +) DEFAULT_PREVIEW_LABELS = { "normal": "normal text", "accent": "accent", @@ -26,6 +55,14 @@ "magenta": "highlight", "cyan": "accent", } +ANSI_ROLE_SAMPLE_WORDS = { + "red": "error", + "green": "success", + "yellow": "warning", + "blue": "command", + "magenta": "highlight", + "cyan": "accent", +} COMMAND_WORDS = {"back", "quit", "exit", "q", "help", "restart"} THEME_FAMILIES = [ @@ -139,8 +176,43 @@ ] +SPLASH = r""" + _______ _ _ ______ __ __ _ +|__ __| | | | ____| \/ | | | + | | | |__| | |__ | \ / | __ _| | _____ _ __ + | | | __ | __| | |\/| |/ _` | |/ / _ \ '__| + | | | | | | |____| | | | (_| | < __/ | + |_| |_| |_|______|_| |_|\__,_|_|\_\___|_| +""" + + +def about_text(): + return "\n".join( + [ + f"{APP_NAME} {APP_VERSION}", + "Terminal color themes from Coolors palettes or hex colors.", + f"Created by {APP_AUTHOR} on GitHub.", + f"Project: {PROJECT_URL}", + "Built with help from Codex.", + f"Inspired by palette ideas from Coolors: {COOLORS_URL}", + ] + ) + + +def show_splash(wait=False): + print(SPLASH.strip("\n")) + print(about_text()) + if wait: + input("\nPress Enter to start.") + print() + + def clean_hex(h): h = h.strip().lstrip("#") + if re.fullmatch(r"[0-9a-fA-F]{4}", h): + return "".join(channel * 2 for channel in h[:3]).upper() + if re.fullmatch(r"[0-9a-fA-F]{8}", h): + return h[:6].upper() if not re.fullmatch(r"[0-9a-fA-F]{6}", h): raise ValueError(f"Invalid hex color: {h}") return h.upper() @@ -148,7 +220,7 @@ def clean_hex(h): def hex_to_rgb(hex_color): h = clean_hex(hex_color) - return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) + return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) def rgb_to_hex(rgb): @@ -168,18 +240,25 @@ def rgb_plist(hex_color): def plist_rgb_to_hex(value): return rgb_to_hex( - tuple(round(value.get(component, 0) * 255) for component in ( - "Red Component", - "Green Component", - "Blue Component", - )) + tuple( + round(value.get(component, 0) * 255) + for component in ( + "Red Component", + "Green Component", + "Blue Component", + ) + ) ) def parse_palette_input(value): raw_value = value.strip() parsed_url = urlparse(raw_value) - palette_text = parsed_url.path.rstrip("/").split("/")[-1] if parsed_url.scheme or parsed_url.netloc else raw_value + palette_text = ( + parsed_url.path.rstrip("/").split("/")[-1] + if parsed_url.scheme or parsed_url.netloc + else raw_value + ) palette_text = palette_text.replace("-", " ").replace(",", " ") colors = [clean_hex(part) for part in palette_text.split()] @@ -253,27 +332,124 @@ def complementary_options(colors, family, mode): ) -def bright_variant_color(hex_color, family): +def hue_for_color(hex_color): + red, green, blue = hex_to_rgb(hex_color) + if max(red, green, blue) - min(red, green, blue) < 32: + return None + r, g, b = (channel / 255 for channel in (red, green, blue)) + hue, _lightness, saturation = colorsys.rgb_to_hls(r, g, b) + return None if saturation < 0.12 else hue + + +def tune_suggested_color(hex_color, family, mode): r, g, b = (channel / 255 for channel in hex_to_rgb(hex_color)) hue, lightness, saturation = colorsys.rgb_to_hls(r, g, b) family_name = family["name"] - saturation = max(saturation, 0.78) - if family_name in {"light", "pastel"}: - lightness = min(max(lightness, 0.42), 0.66) - else: - lightness = min(max(lightness, 0.54), 0.72) - if family_name == "pastel": - saturation = min(saturation, 0.62) - lightness = max(lightness, 0.72) + saturation = min(max(saturation, 0.45), 0.62) + lightness = 0.76 if mode == "bright" else 0.82 + elif family_name == "light": + saturation = min(max(saturation, 0.58), 0.82) + lightness = 0.46 if mode == "bright" else 0.42 elif family_name == "neon": saturation = max(saturation, 0.92) + lightness = 0.62 + elif family_name == "high contrast": + saturation = max(saturation, 0.95) + lightness = 0.58 + else: + saturation = min(max(saturation, 0.72), 0.92) + lightness = 0.64 if mode == "bright" else 0.58 rgb = colorsys.hls_to_rgb(hue, lightness, min(saturation, 1.0)) return rgb_to_hex(tuple(round(channel * 255) for channel in rgb)) +def shifted_palette_color(hex_color, shift, family, mode): + r, g, b = (channel / 255 for channel in hex_to_rgb(hex_color)) + hue, _lightness, saturation = colorsys.rgb_to_hls(r, g, b) + if saturation < 0.12: + return clean_hex(hex_color) + hue = (hue + shift) % 1.0 + rgb = colorsys.hls_to_rgb(hue, 0.58, max(saturation, 0.72)) + return tune_suggested_color( + rgb_to_hex(tuple(round(channel * 255) for channel in rgb)), + family, + mode, + ) + + +def palette_fit_options(colors, family, mode): + options = [] + for index, color in enumerate(colors[:5], 1): + if hue_for_color(color) is None: + continue + options.append( + ( + f"Palette-fit cool accent from palette {index}", + shifted_palette_color(color, 0.08, family, mode), + ) + ) + options.append( + ( + f"Palette-fit warm accent from palette {index}", + shifted_palette_color(color, -0.08, family, mode), + ) + ) + return unique_color_options(options)[:4] + + +def extra_color_options(colors, family, mode): + return unique_color_options( + complementary_options(colors, family, mode) + + palette_fit_options(colors, family, mode) + ) + + +def bright_variant_color(hex_color, family): + red, green, blue = hex_to_rgb(hex_color) + if max(red, green, blue) - min(red, green, blue) < 32: + return clean_hex(hex_color) + + r, g, b = (channel / 255 for channel in (red, green, blue)) + hue, lightness, saturation = colorsys.rgb_to_hls(r, g, b) + family_name = family["name"] + + if saturation < 0.08: + return clean_hex(hex_color) + + original = clean_hex(hex_color) + saturation = min(max(saturation + 0.16, 0.82), 1.0) + if family_name == "pastel": + lightness = 0.74 if lightness > 0.78 else min(lightness + 0.10, 0.78) + saturation = min(saturation, 0.68) + elif family_name == "light": + lightness = 0.48 if lightness > 0.62 else min(lightness + 0.08, 0.62) + else: + lightness = 0.66 if lightness > 0.60 else min(lightness + 0.12, 0.68) + + if family_name == "neon": + saturation = max(saturation, 0.92) + lightness = 0.64 + + rgb = colorsys.hls_to_rgb(hue, lightness, min(saturation, 1.0)) + bright = rgb_to_hex(tuple(round(channel * 255) for channel in rgb)) + if bright == original: + lightness = min(max(lightness + 0.08, 0.35), 0.72) + rgb = colorsys.hls_to_rgb(hue, lightness, min(saturation, 1.0)) + bright = rgb_to_hex(tuple(round(channel * 255) for channel in rgb)) + return bright + + +def bright_foreground_color(foreground, background, family): + bright = bright_variant_color(foreground, family) + cleaned = clean_hex(foreground) + if bright == cleaned and brightness(background) < 390 and cleaned != "FFFFFF": + return "FFFFFF" + return bright + + def ansi_bg(hex_color, text=" "): return ansi_swatch(hex_color, text) @@ -283,10 +459,19 @@ def ansi_fg(hex_color, text): return f"\033[38;2;{r};{g};{b}m{text}\033[0m" +def ansi_text_on(background, foreground, text): + bg_r, bg_g, bg_b = hex_to_rgb(background) + fg_r, fg_g, fg_b = hex_to_rgb(foreground) + return ( + f"\033[48;2;{bg_r};{bg_g};{bg_b}m" + f"\033[38;2;{fg_r};{fg_g};{fg_b}m{text}\033[0m" + ) + + def preview_palette(colors): print("\nPalette:") for i, c in enumerate(colors, 1): - print(f" {i}. #{c} {ansi_bg(c)}") + print(f" {i}. #{c} {ansi_bg(c)} {ansi_fg(c, 'sample text')}") def palette_as_hex(colors): @@ -296,13 +481,15 @@ def palette_as_hex(colors): def preview_palette_choices(colors): print("\nPalette choices:") for i, color in enumerate(colors, 1): - print(f" {i}. #{color} {ansi_bg(color)}") + print(f" {i}. #{color} {ansi_bg(color)} {ansi_fg(color, 'sample text')}") -def preview_complementary_choices(options): - print("\nComplementary choices:") +def preview_extra_color_choices(options): + print("\nExtra color suggestions:") for i, (name, color) in enumerate(options, 1): - print(f" c{i}. #{color} {ansi_bg(color)} {name}") + print( + f" c{i}. #{color} {ansi_bg(color)} {ansi_fg(color, 'sample text')} {name}" + ) def color_choice_label(colors, color): @@ -322,7 +509,7 @@ def preview_families(): def preview_backgrounds(family): print(f"\nSuggested {family['label'].lower()} backgrounds:") for i, (name, color) in enumerate(family["backgrounds"], 1): - print(f" {i}. #{color} {ansi_bg(color)} {name}") + print(f" {i}. #{color} {ansi_bg(color)} {ansi_bg(color, ' sample ')} {name}") def unique_color_options(options): @@ -367,14 +554,22 @@ def foreground_options(colors, family, background): def preview_foregrounds(options, background, error_color, labels): print("\nSuggested normal text colors:") for index, (name, color) in enumerate(options, 1): - error_note = " close to error" if color_distance(color, error_color) < 90 else "" - bg_note = " low background contrast" if color_distance(color, background) < 90 else "" - print(f" {index}. #{color} {ansi_fg(color, labels['normal'])} {name}{error_note}{bg_note}") + error_note = ( + " close to error" if color_distance(color, error_color) < 90 else "" + ) + bg_note = ( + " low background contrast" if color_distance(color, background) < 90 else "" + ) + print( + f" {index}. #{color} {ansi_bg(color)} " + f"{ansi_fg(color, labels['normal'])} " + f"{ansi_text_on(background, color, labels['normal'])} {name}{error_note}{bg_note}" + ) def palette_roles(colors, family): sorted_colors = sorted(colors, key=brightness) - darkest, second_darkest = sorted_colors[0], sorted_colors[1] + second_darkest = sorted_colors[1] middle = sorted_colors[len(sorted_colors) // 2] second_brightest, brightest = sorted_colors[-2], sorted_colors[-1] c1, c2, c3, c4, c5 = colors[:5] @@ -417,35 +612,87 @@ def palette_roles(colors, family): def preview_theme(colors, background, foreground, roles, labels): mapping = [ - ("Background", background), - ("Foreground", foreground), - ("Black / ANSI 0 / base", roles["black"]), - ("Red / ANSI 1 / error", roles["red"]), - ("Green / ANSI 2 / success", roles["green"]), - ("Yellow / ANSI 3 / warning", roles["yellow"]), - ("Blue / ANSI 4 / link", roles["blue"]), - ("Magenta / ANSI 5 / highlight", roles["magenta"]), - ("Cyan / ANSI 6 / accent", roles["cyan"]), - ("White / ANSI 7", foreground), + ("Background", background, ansi_text_on(background, foreground, " sample ")), + ( + "Foreground", + foreground, + ansi_text_on(background, foreground, labels["normal"]), + ), + ( + "Black / ANSI 0 / base", + roles["black"], + ansi_text_on(background, roles["black"], "base"), + ), + ( + "Red / ANSI 1 / error", + roles["red"], + ansi_text_on(background, roles["red"], labels["error"]), + ), + ( + "Green / ANSI 2 / success", + roles["green"], + ansi_text_on(background, roles["green"], "success"), + ), + ( + "Yellow / ANSI 3 / warning", + roles["yellow"], + ansi_text_on(background, roles["yellow"], labels["warning"]), + ), + ( + "Blue / ANSI 4 / link", + roles["blue"], + ansi_text_on(background, roles["blue"], "command"), + ), + ( + "Magenta / ANSI 5 / highlight", + roles["magenta"], + ansi_text_on(background, roles["magenta"], "highlight"), + ), + ( + "Cyan / ANSI 6 / accent", + roles["cyan"], + ansi_text_on(background, roles["cyan"], labels["accent"]), + ), + ("White / ANSI 7", foreground, ansi_text_on(background, foreground, "normal")), ] print("\nTheme preview:") - for label, color in mapping: - print(f" {label:<28} #{color} {ansi_bg(color)}") + for label, color, sample in mapping: + print(f" {label:<28} #{color} {ansi_bg(color)} {sample}") print("\nText preview:") - print(f" {ansi_bg(background, ' ')} " - f"{ansi_fg(foreground, labels['normal'])} (foreground) " - f"{ansi_fg(roles['cyan'], labels['accent'])} (ANSI 6/cyan/accent) " - f"{ansi_fg(roles['yellow'], labels['warning'])} (ANSI 3/yellow/warning) " - f"{ansi_fg(roles['red'], labels['error'])} (ANSI 1/red/error)") + print( + f" {ansi_text_on(background, foreground, labels['normal'])} (foreground) " + f"{ansi_text_on(background, roles['cyan'], labels['accent'])} (ANSI 6/cyan/accent) " + f"{ansi_text_on(background, roles['yellow'], labels['warning'])} (ANSI 3/yellow/warning) " + f"{ansi_text_on(background, roles['red'], labels['error'])} (ANSI 1/red/error)" + ) + + print("\nANSI example:") + print( + f" {ansi_text_on(background, roles['blue'], '$ themaker export')} " + f"{ansi_text_on(background, foreground, '--format all')}" + ) + print( + f" {ansi_text_on(background, roles['green'], 'created')} " + f"{ansi_text_on(background, roles['cyan'], 'theme.itermcolors')} " + f"{ansi_text_on(background, roles['magenta'], 'theme.lua')}" + ) + print( + f" {ansi_text_on(background, roles['yellow'], 'warning')} " + f"{ansi_text_on(background, foreground, 'normal text is close to background')}" + ) + print( + f" {ansi_text_on(background, roles['red'], 'error')} " + f"{ansi_text_on(background, foreground, 'invalid hex color')}" + ) if color_distance(foreground, roles["red"]) < 90: print(" Warning: normal text is close to the error color.") if color_distance(foreground, background) < 90: print(" Warning: normal text is close to the background color.") -def make_theme(colors, background, foreground, mode, family, roles, palette_source=""): +def make_terminal_colors(colors, background, foreground, mode, family, roles): sorted_colors = sorted(colors, key=brightness) if mode == "soft": @@ -455,39 +702,313 @@ def make_theme(colors, background, foreground, mode, family, roles, palette_sour else: ansi8 = roles["bright_black"] + bright_roles = { + role: bright_variant_color(roles[role], family) + for role in ("red", "green", "yellow", "blue", "magenta", "cyan") + } + bright_foreground = bright_foreground_color(foreground, background, family) + + return { + "background": background, + "foreground": foreground, + "bold": foreground, + "cursor": roles["cyan"], + "cursor_text": background, + "selection": family["selection"], + "selected_text": background, + "ansi": [ + roles["black"], + roles["red"], + roles["green"], + roles["yellow"], + roles["blue"], + roles["magenta"], + roles["cyan"], + foreground, + ], + "bright": [ + ansi8, + bright_roles["red"], + bright_roles["green"], + bright_roles["yellow"], + bright_roles["blue"], + bright_roles["magenta"], + bright_roles["cyan"], + bright_foreground, + ], + } + + +def make_theme_model( + colors, background, foreground, mode, family, roles, palette_source="", name="" +): + return { + "generator": f"{APP_NAME} {APP_VERSION}", + "name": name, + "palette": palette_as_hex(colors), + "palette_source": palette_source.strip(), + "family": family["name"], + "family_label": family["label"], + "mode": mode, + "colors": make_terminal_colors( + colors, background, foreground, mode, family, roles + ), + } + + +def make_theme(colors, background, foreground, mode, family, roles, palette_source=""): + model = make_theme_model( + colors, background, foreground, mode, family, roles, palette_source + ) + terminal_colors = model["colors"] + theme = { - "Background Color": rgb_plist(background), - "Foreground Color": rgb_plist(foreground), - "Bold Color": rgb_plist(foreground), - "Cursor Color": rgb_plist(roles["cyan"]), - "Cursor Text Color": rgb_plist(background), - "Selection Color": rgb_plist(family["selection"]), - "Selected Text Color": rgb_plist(background), - - "Ansi 0 Color": rgb_plist(roles["black"]), - "Ansi 1 Color": rgb_plist(roles["red"]), - "Ansi 2 Color": rgb_plist(roles["green"]), - "Ansi 3 Color": rgb_plist(roles["yellow"]), - "Ansi 4 Color": rgb_plist(roles["blue"]), - "Ansi 5 Color": rgb_plist(roles["magenta"]), - "Ansi 6 Color": rgb_plist(roles["cyan"]), - "Ansi 7 Color": rgb_plist(foreground), - - "Ansi 8 Color": rgb_plist(ansi8), - "Ansi 9 Color": rgb_plist(roles["red"]), - "Ansi 10 Color": rgb_plist(roles["green"]), - "Ansi 11 Color": rgb_plist(roles["yellow"]), - "Ansi 12 Color": rgb_plist(roles["blue"]), - "Ansi 13 Color": rgb_plist(roles["magenta"]), - "Ansi 14 Color": rgb_plist(roles["cyan"]), - "Ansi 15 Color": rgb_plist(foreground), + "Background Color": rgb_plist(terminal_colors["background"]), + "Foreground Color": rgb_plist(terminal_colors["foreground"]), + "Bold Color": rgb_plist(terminal_colors["bold"]), + "Cursor Color": rgb_plist(terminal_colors["cursor"]), + "Cursor Text Color": rgb_plist(terminal_colors["cursor_text"]), + "Selection Color": rgb_plist(terminal_colors["selection"]), + "Selected Text Color": rgb_plist(terminal_colors["selected_text"]), } + for index, color in enumerate(terminal_colors["ansi"] + terminal_colors["bright"]): + theme[f"Ansi {index} Color"] = rgb_plist(color) theme[ORIGINAL_PALETTE_KEY] = palette_as_hex(colors) if palette_source: theme[PALETTE_SOURCE_KEY] = palette_source.strip() + theme[GENERATOR_KEY] = model["generator"] + theme[FAMILY_KEY] = family["name"] + theme[MODE_KEY] = mode return theme +def format_hex(hex_color): + return f"#{clean_hex(hex_color)}" + + +def write_iterm_theme(path, model): + family = next(item for item in THEME_FAMILIES if item["name"] == model["family"]) + colors = parse_palette_input(model["palette"]) + terminal_colors = model["colors"] + roles = { + role: terminal_colors["ansi"][index] + for index, role in enumerate(TERMINAL_ROLE_NAMES[:-1]) + } + roles["bright_black"] = terminal_colors["bright"][0] + theme = make_theme( + colors, + terminal_colors["background"], + terminal_colors["foreground"], + model["mode"], + family, + roles, + model["palette_source"], + ) + theme[GENERATOR_KEY] = model["generator"] + with path.open("wb") as handle: + plistlib.dump(theme, handle) + + +def write_terminal_theme(path, model): + colors = model["colors"] + ansi_names = ( + "Black", + "Red", + "Green", + "Yellow", + "Blue", + "Magenta", + "Cyan", + "White", + ) + theme = { + "name": model["name"] or "THEMaker Theme", + "type": "Window Settings", + "ProfileCurrentVersion": 2.07, + "BackgroundColor": rgb_plist(colors["background"]), + "TextColor": rgb_plist(colors["foreground"]), + "BoldTextColor": rgb_plist(colors["bold"]), + "CursorColor": rgb_plist(colors["cursor"]), + "SelectionColor": rgb_plist(colors["selection"]), + } + for name, color in zip(ansi_names, colors["ansi"]): + theme[f"ANSI{name}Color"] = rgb_plist(color) + for name, color in zip(ansi_names, colors["bright"]): + theme[f"ANSIBright{name}Color"] = rgb_plist(color) + with path.open("wb") as handle: + plistlib.dump(theme, handle) + + +def write_kitty_theme(path, model): + colors = model["colors"] + lines = [ + f"# Generated by {model['generator']}", + f"# Palette: {model['palette']}", + f"foreground {format_hex(colors['foreground'])}", + f"background {format_hex(colors['background'])}", + f"cursor {format_hex(colors['cursor'])}", + f"cursor_text_color {format_hex(colors['cursor_text'])}", + f"selection_foreground {format_hex(colors['selected_text'])}", + f"selection_background {format_hex(colors['selection'])}", + "", + ] + for index, color in enumerate(colors["ansi"] + colors["bright"]): + lines.append(f"color{index} {format_hex(color)}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def toml_value(value): + return json.dumps(value) + + +def write_alacritty_theme(path, model): + colors = model["colors"] + normal = dict(zip(TERMINAL_ROLE_NAMES, colors["ansi"])) + bright = dict(zip(TERMINAL_ROLE_NAMES, colors["bright"])) + lines = [ + f"# Generated by {model['generator']}", + f"# Palette: {model['palette']}", + "[colors.primary]", + f"background = {toml_value(format_hex(colors['background']))}", + f"foreground = {toml_value(format_hex(colors['foreground']))}", + "", + "[colors.cursor]", + f"text = {toml_value(format_hex(colors['cursor_text']))}", + f"cursor = {toml_value(format_hex(colors['cursor']))}", + "", + "[colors.selection]", + f"text = {toml_value(format_hex(colors['selected_text']))}", + f"background = {toml_value(format_hex(colors['selection']))}", + "", + "[colors.normal]", + ] + lines.extend( + f"{name} = {toml_value(format_hex(color))}" for name, color in normal.items() + ) + lines.append("") + lines.append("[colors.bright]") + lines.extend( + f"{name} = {toml_value(format_hex(color))}" for name, color in bright.items() + ) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def yaml_value(value): + return json.dumps(value) + + +def write_gogh_yaml_theme(path, model): + colors = model["colors"] + yaml_colors = colors["ansi"] + colors["bright"] + extra_keys = { + "badge": colors["cursor"], + "bold": colors["bold"], + "cursor_guide": colors["cursor"], + "cursor_text": colors["cursor_text"], + "link": colors["cursor"], + "selection_text": colors["selected_text"], + "selection": colors["selection"], + "tab": colors["background"], + "underline": colors["foreground"], + } + lines = [ + f"# Generated by {model['generator']}", + f"# Palette: {model['palette']}", + f"name: {yaml_value(model['name'] or 'THEMaker Theme')}", + f"author: {yaml_value('@ylub')}", + f"background: {yaml_value(format_hex(colors['background']))}", + f"foreground: {yaml_value(format_hex(colors['foreground']))}", + f"cursor: {yaml_value(format_hex(colors['cursor']))}", + ] + lines.extend( + f"{key}: {yaml_value(format_hex(color))}" for key, color in extra_keys.items() + ) + lines.append("") + lines.extend( + f"color_{index:02d}: {yaml_value(format_hex(color))}" + for index, color in enumerate(yaml_colors, start=1) + ) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def lua_string(value): + return json.dumps(value) + + +def lua_list(colors): + return "{ " + ", ".join(lua_string(format_hex(color)) for color in colors) + " }" + + +def write_wezterm_theme(path, model): + colors = model["colors"] + lines = [ + f"-- Generated by {model['generator']}", + f"-- Palette: {model['palette']}", + "return {", + f" foreground = {lua_string(format_hex(colors['foreground']))},", + f" background = {lua_string(format_hex(colors['background']))},", + f" cursor_bg = {lua_string(format_hex(colors['cursor']))},", + f" cursor_fg = {lua_string(format_hex(colors['cursor_text']))},", + f" selection_bg = {lua_string(format_hex(colors['selection']))},", + f" selection_fg = {lua_string(format_hex(colors['selected_text']))},", + f" ansi = {lua_list(colors['ansi'])},", + f" brights = {lua_list(colors['bright'])},", + "}", + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_theme_data(path, model): + path.write_text(json.dumps(model, indent=2) + "\n", encoding="utf-8") + + +EXPORTERS = { + "iterm": (".itermcolors", write_iterm_theme), + "terminal": (".terminal", write_terminal_theme), + "kitty": (".conf", write_kitty_theme), + "alacritty": (".toml", write_alacritty_theme), + "wezterm": (".lua", write_wezterm_theme), + "yaml": (".yaml", write_gogh_yaml_theme), + "data": (".json", write_theme_data), +} + + +def export_theme_files(model, output_dir, formats, overwrite=False): + output_dir.mkdir(parents=True, exist_ok=True) + base_name = safe_filename(model["name"] or "THEMaker Theme") + written = [] + skipped = [] + for export_format in formats: + extension, writer = EXPORTERS[export_format] + out_path = output_dir / f"{base_name}{extension}" + if out_path.exists() and not overwrite: + skipped.append(out_path) + continue + writer(out_path, model) + written.append(out_path) + return written, skipped + + +def existing_export_paths(model, output_dir, formats): + base_name = safe_filename(model["name"] or "THEMaker Theme") + return [ + output_dir / f"{base_name}{EXPORTERS[export_format][0]}" + for export_format in formats + if (output_dir / f"{base_name}{EXPORTERS[export_format][0]}").exists() + ] + + +def export_theme_files_interactive(model, output_dir, formats): + existing = existing_export_paths(model, output_dir, formats) + overwrite = False + if existing: + print("\nThese files already exist:") + for path in existing: + print(path) + overwrite = confirm_yes("Overwrite existing files? y/n", default=False) + return export_theme_files(model, output_dir, formats, overwrite=overwrite) + + class WizardBack(Exception): pass @@ -511,7 +1032,10 @@ def print_command_help(): def ask(prompt, default=None, allow_back=True): while True: suffix = f" [{default}]" if default else "" - val = input(f"{prompt}{suffix}: ").strip() + try: + val = input(f"{prompt}{suffix}: ").strip() + except EOFError: + raise WizardQuit command = val.lower() if command in COMMAND_WORDS: if command == "help": @@ -556,15 +1080,17 @@ def print_role_mapping(colors, roles): print("\nANSI role mapping:") for role in ("red", "green", "yellow", "blue", "magenta", "cyan"): preview_name = ANSI_ROLE_PREVIEW[role] + sample_word = ANSI_ROLE_SAMPLE_WORDS[role] print( f" ANSI {role:<7} / {preview_name:<17} " - f"#{roles[role]} {ansi_bg(roles[role])} {color_choice_label(colors, roles[role])}" + f"#{roles[role]} {ansi_bg(roles[role])} " + f"{ansi_fg(roles[role], sample_word)} {color_choice_label(colors, roles[role])}" ) -def choose_role_color(colors, complement_options, role, current_color): +def choose_role_color(colors, extra_options, role, current_color): prompt = ( - f"ANSI {role} color: palette 1-{len(colors)}, complement c1-c{len(complement_options)}, " + f"ANSI {role} color: palette 1-{len(colors)}, suggestion c1-c{len(extra_options)}, " "custom hex, or Enter to keep" ) raw = ask(prompt, "").strip() @@ -574,28 +1100,30 @@ def choose_role_color(colors, complement_options, role, current_color): return colors[int(raw) - 1] if raw.lower().startswith("c") and raw[1:].isdigit(): index = int(raw[1:]) - if 1 <= index <= len(complement_options): - return complement_options[index - 1][1] + if 1 <= index <= len(extra_options): + return extra_options[index - 1][1] try: return clean_hex(raw) except ValueError as error: print(error) - return choose_role_color(colors, complement_options, role, current_color) + return choose_role_color(colors, extra_options, role, current_color) def choose_role_mapping(colors, family, mode, current_roles=None): roles = current_roles.copy() if current_roles else palette_roles(colors, family) print_role_mapping(colors, roles) - customize = ask("Change which palette colors feed the ANSI roles? y/n", "n").strip().lower() + customize = ( + ask("Change which palette colors feed the ANSI roles? y/n", "n").strip().lower() + ) if customize not in {"y", "yes"}: return roles - complements = complementary_options(colors, family, mode) - print("\nUse palette numbers, complementary choices, or type a custom hex.") + extra_options = extra_color_options(colors, family, mode) + print("\nUse palette numbers, extra suggestions, or type a custom hex.") preview_palette_choices(colors) - preview_complementary_choices(complements) + preview_extra_color_choices(extra_options) for role in ("red", "green", "yellow", "blue", "magenta", "cyan"): - roles[role] = choose_role_color(colors, complements, role, roles[role]) + roles[role] = choose_role_color(colors, extra_options, role, roles[role]) print_role_mapping(colors, roles) return roles @@ -613,9 +1141,10 @@ def offer_bright_ansi_suggestions(roles, family): for role in ("red", "green", "yellow", "blue", "magenta", "cyan"): current = roles[role] suggested = suggestions[role] + sample_word = ANSI_ROLE_SAMPLE_WORDS[role] print( - f" {role:<7} #{current} {ansi_bg(current)} -> " - f"#{suggested} {ansi_bg(suggested)}" + f" {role:<7} #{current} {ansi_bg(current)} {ansi_fg(current, sample_word)} -> " + f"#{suggested} {ansi_bg(suggested)} {ansi_fg(suggested, sample_word)}" ) if not confirm_yes("Apply these bright ANSI suggestions? y/n", default=False): @@ -630,7 +1159,9 @@ def offer_bright_ansi_suggestions(roles, family): def preview_label_defaults() -> str: - return " | ".join(DEFAULT_PREVIEW_LABELS[key] for key in ("normal", "accent", "warning", "error")) + return " | ".join( + DEFAULT_PREVIEW_LABELS[key] for key in ("normal", "accent", "warning", "error") + ) def parse_preview_labels(raw_value: str) -> dict: @@ -670,6 +1201,78 @@ def confirm_yes(prompt, default=True): return value in {"y", "yes"} +def parse_export_formats(raw_value): + value = raw_value.strip().lower() + if value == "all": + return list(EXPORT_FORMATS) + if value in {"data", "json"}: + return ["data"] + + aliases = { + "i": "iterm", + "iterm2": "iterm", + "t": "terminal", + "term": "terminal", + "terminalapp": "terminal", + "macos": "terminal", + "mac": "terminal", + "k": "kitty", + "al": "alacritty", + "alac": "alacritty", + "w": "wezterm", + "wez": "wezterm", + "y": "yaml", + "gogh": "yaml", + "iterm-yaml": "yaml", + "source-yaml": "yaml", + "d": "data", + "json": "data", + } + normalized = value.replace(",", " ").split() + formats = [] + for item in normalized: + export_format = aliases.get(item, item) + if export_format not in EXPORT_FORMATS: + raise ValueError(f"Unknown export format: {item}") + if export_format not in formats: + formats.append(export_format) + if not formats: + raise ValueError("Choose at least one export format.") + return formats + + +def choose_export_formats(): + print("\nExport options:") + print( + " all iTerm2, macOS Terminal, Kitty, Alacritty, WezTerm, YAML, and data JSON" + ) + print(" one Choose one format") + print(" some Choose a few formats") + print(" data Save portable JSON data for someone else to export") + print("Formats: iterm, terminal, kitty, alacritty, wezterm, yaml, data") + while True: + raw = ask("Export formats", "all") + if raw.strip().lower() == "one": + raw = ask("Which one format", "iterm") + elif raw.strip().lower() == "some": + raw = ask("Which formats", "iterm kitty") + try: + return parse_export_formats(raw) + except ValueError as error: + print(error) + + +def print_export_results(written, skipped): + if written: + print("\nCreated:") + for path in written: + print(path) + if skipped: + print("\nSkipped existing files:") + for path in skipped: + print(path) + + def list_existing_themes(): if not OUTPUT_DIR.exists(): return [] @@ -769,41 +1372,54 @@ def parallel_theme_suggestions(colors, current_family, current_mode): return suggestions -def offer_parallel_themes(colors, current_family, current_mode, base_name): +def offer_parallel_themes( + colors, current_family, current_mode, base_name, output_dir, formats +): suggestions = parallel_theme_suggestions(colors, current_family, current_mode) if not suggestions: return - print("\nParallel theme ideas from the same palette:") + print("\nSibling theme ideas from the same palette:") for index, (mode, family) in enumerate(suggestions, 1): print(f" {index}. {family['label']} {mode.title()}") - if not confirm_yes("Make one of these parallel themes too? y/n", default=False): + if not confirm_yes("Create one of these sibling themes too? y/n", default=False): return - choice = choose_number("Choose parallel theme", len(suggestions), 1) + choice = choose_number("Choose sibling theme", len(suggestions), 1) mode, family = suggestions[choice - 1] background = family["backgrounds"][0][1] roles = palette_roles(colors, family) foreground = foreground_options(colors, family, background)[0][1] - name = ask("Parallel theme name", f"{base_name} {family['label']} {mode.title()}") - out_path = OUTPUT_DIR / f"{safe_filename(name)}.itermcolors" - theme = make_theme(colors, background, foreground, mode, family, roles, palette_as_hex(colors)) - with out_path.open("wb") as handle: - plistlib.dump(theme, handle) - print("Created parallel theme:") - print(out_path) + name = ask("Sibling theme name", f"{base_name} {family['label']} {mode.title()}") + model = make_theme_model( + colors, + background, + foreground, + mode, + family, + roles, + palette_as_hex(colors), + name, + ) + written, skipped = export_theme_files_interactive(model, output_dir, formats) + print_export_results(written, skipped) -def run_wizard(): - print("\nCoolors → iTerm Theme Wizard") +def run_wizard(initial_state=None, start_stage=None, output_dir=OUTPUT_DIR): + print(f"\n{APP_NAME} Terminal Theme Wizard") print("=" * 32) print("Type help, back, restart, or quit at any prompt.") - try: - state, stage = choose_start_state() - except WizardBack: - state, stage = {}, 0 + if initial_state is None: + try: + state, stage = choose_start_state() + except WizardBack: + state, stage = {}, 0 + else: + state = initial_state.copy() + stage = 0 if start_stage is None else start_stage + state["output_dir"] = output_dir while stage <= 9: try: if stage == 0: @@ -819,7 +1435,9 @@ def run_wizard(): stage += 1 elif stage == 1: preview_families() - family_choice = choose_number("Choose theme family", len(THEME_FAMILIES), 1) + family_choice = choose_number( + "Choose theme family", len(THEME_FAMILIES), 1 + ) state["family"] = THEME_FAMILIES[family_choice - 1] stage += 1 elif stage == 2: @@ -832,16 +1450,22 @@ def run_wizard(): stage += 1 elif stage == 3: if state.get("background"): - print(f"\nCurrent background: #{state['background']} {ansi_bg(state['background'])}") + print( + f"\nCurrent background: #{state['background']} {ansi_bg(state['background'])}" + ) if confirm_yes("Keep current background? y/n", default=True): stage += 1 continue preview_backgrounds(state["family"]) - bg_choice = choose_number("Choose background", len(state["family"]["backgrounds"]), 1) + bg_choice = choose_number( + "Choose background", len(state["family"]["backgrounds"]), 1 + ) state["background"] = state["family"]["backgrounds"][bg_choice - 1][1] stage += 1 elif stage == 4: - custom_bg = ask("Custom background hex, or press Enter to keep chosen", "") + custom_bg = ask( + "Custom background hex, or press Enter to keep chosen", "" + ) if custom_bg: state["background"] = clean_hex(custom_bg) stage += 1 @@ -850,8 +1474,12 @@ def run_wizard(): stage += 1 elif stage == 6: if state.get("foreground"): - print(f"\nCurrent normal text: #{state['foreground']} {ansi_fg(state['foreground'], state['preview_labels']['normal'])}") - if not confirm_yes("Keep current normal text color? y/n", default=True): + print( + f"\nCurrent normal text: #{state['foreground']} {ansi_fg(state['foreground'], state['preview_labels']['normal'])}" + ) + if not confirm_yes( + "Keep current normal text color? y/n", default=True + ): state["foreground"] = choose_foreground( state["colors"], state["family"], @@ -873,7 +1501,9 @@ def run_wizard(): state["mode"], state.get("roles"), ) - state["roles"] = offer_bright_ansi_suggestions(state["roles"], state["family"]) + state["roles"] = offer_bright_ansi_suggestions( + state["roles"], state["family"] + ) stage += 1 elif stage == 8: preview_theme( @@ -890,12 +1520,16 @@ def run_wizard(): state["name"] = ask("Theme name", default_name) stage += 1 elif stage == 9: - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - out_path = OUTPUT_DIR / f"{safe_filename(state['name'])}.itermcolors" - if not confirm_yes("Create this iTerm theme? y/n", default=True): + output_dir = state.get("output_dir", OUTPUT_DIR) + if "formats" not in state: + state["formats"] = choose_export_formats() + format_labels = ", ".join(state["formats"]) + print(f"\nReady to export: {state['name']}") + print(f"Selected formats: {format_labels}") + if not confirm_yes("Create these files? y/n", default=True): print("Cancelled.") return - theme = make_theme( + model = make_theme_model( state["colors"], state["background"], state["foreground"], @@ -903,19 +1537,24 @@ def run_wizard(): state["family"], state["roles"], state.get("palette_source", ""), + state["name"], ) - with out_path.open("wb") as f: - plistlib.dump(theme, f) - print("\nCreated:") - print(out_path) + written, skipped = export_theme_files_interactive( + model, output_dir, state["formats"] + ) + print_export_results(written, skipped) offer_parallel_themes( state["colors"], state["family"], state["mode"], state["name"], + output_dir, + state["formats"], ) - print("\nImport in iTerm:") - print("Settings → Profiles → Colors → Color Presets → Import") + if "iterm" in state["formats"]: + print("\nImport in iTerm:") + print("Settings → Profiles → Colors → Color Presets → Import") + print("\nDone.") return except WizardBack: stage = max(0, stage - 1) @@ -924,13 +1563,96 @@ def run_wizard(): print(error) -def main(): +def build_parser(): + parser = argparse.ArgumentParser( + description=f"{APP_NAME}: make terminal color themes from Coolors URLs or hex palettes." + ) + parser.add_argument( + "--palette", help="Coolors URL or hex colors separated by spaces." + ) + parser.add_argument( + "--edit", + type=Path, + help="Start by editing an existing iTerm .itermcolors file.", + ) + parser.add_argument( + "--out", + type=Path, + default=OUTPUT_DIR, + help="Output directory for exported themes.", + ) + parser.add_argument( + "--format", + dest="formats", + help="Export formats: all, data, or any of iterm, terminal, kitty, alacritty, wezterm, yaml, data.", + ) + parser.add_argument( + "--list-themes", + action="store_true", + help="List existing iTerm themes in the output directory.", + ) + parser.add_argument( + "--about", + action="store_true", + help="Show credits and project information.", + ) + parser.add_argument( + "--no-splash", + action="store_true", + help="Skip the interactive startup banner.", + ) + parser.add_argument( + "--version", action="version", version=f"{APP_NAME} {APP_VERSION}" + ) + return parser + + +def initial_state_from_args(args): + state = {} + stage = 0 + if args.edit: + state = load_theme_state(args.edit) + stage = 1 + elif args.palette: + state["colors"] = parse_palette_input(args.palette) + state["palette_source"] = args.palette + preview_palette(state["colors"]) + stage = 1 + + if args.formats: + state["formats"] = parse_export_formats(args.formats) + return state or None, stage + + +def main(argv=None): + args = build_parser().parse_args(argv) + if args.about: + print(about_text()) + return + if args.list_themes: + themes = ( + sorted(path for path in args.out.glob("*.itermcolors") if path.is_file()) + if args.out.exists() + else [] + ) + if not themes: + print(f"No themes found in {args.out}") + return + for path in themes: + print(path) + return + + initial_state, start_stage = initial_state_from_args(args) + pure_wizard = not any((args.palette, args.edit, args.formats)) + if pure_wizard and not args.no_splash: + show_splash(wait=True) while True: try: - run_wizard() + run_wizard(initial_state, start_stage, args.out) return except WizardRestart: print("\nRestarting.") + initial_state, start_stage = None, 0 except WizardQuit: print("Cancelled.") return