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.
+
+
+
+
+
+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