diff --git a/colors/adi1090x.toml b/colors/adi1090x.toml index e487eca..4f68f36 100644 --- a/colors/adi1090x.toml +++ b/colors/adi1090x.toml @@ -1,3 +1,4 @@ +name = "Adi1090x" background = "#131b20" text = "#283039" -accent = "#a8bf3e" \ No newline at end of file +accent = "#a8bf3e" diff --git a/colors/cherry.toml b/colors/cherry.toml index f1568a8..325a782 100644 --- a/colors/cherry.toml +++ b/colors/cherry.toml @@ -1,3 +1,4 @@ +name = "Cherry" background = "#ffdcdc" text = "#fda8ac" accent = "#ef5e81" \ No newline at end of file diff --git a/colors/midnight_abyss.toml b/colors/midnight_abyss.toml index 66451fe..85b2872 100644 --- a/colors/midnight_abyss.toml +++ b/colors/midnight_abyss.toml @@ -1,3 +1,4 @@ +name = "Midnight Abyss" background = "#000000" text = "#272c38bd" accent = "#30b962" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6af06ac..bb0bd93 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ pillow==9.3.0 inquirer==3.1.3 toml==0.10.2 importlib==1.0.4 +textual==0.38.1 diff --git a/src/app.tcss b/src/app.tcss new file mode 100644 index 0000000..90f7bae --- /dev/null +++ b/src/app.tcss @@ -0,0 +1,35 @@ +/* Wtf ????? Terminal CSS ??? >:0 */ + +Wizard { + padding: 1 4; + width: auto; + margin: 0 2; + border: solid $primary-background-lighten-3; +} + +BackNextButtons { + margin-top: 1; + layout: horizontal; + width: auto; +} + +Screen { + align: center middle; +} + +.full-width { + width: 100%; +} + +Input, Select { + border: round skyblue; + background: transparent; +} + +.invalid { + border: round tomato; +} + +.hidden { + display: none; +} \ No newline at end of file diff --git a/src/main.py b/src/main.py index 0379c1c..64ea358 100644 --- a/src/main.py +++ b/src/main.py @@ -1,59 +1,88 @@ -import inquirer as inq -from importlib import import_module import os from time import time from utils import * -# Look for DEBUG in the environment variables -DEBUG = os.environ.get("DEBUG", "false").lower() == "true" +from textual.app import App +from textual.widgets import Header, Footer, Label, LoadingIndicator +from textual.validation import Length +from textual import log +from textual.events import Key -def main(): +VERSION = "1.0.0" + +from wizard import * - # Load the styles in the styles directory - styles = dict() - for style in os.listdir(os.path.join(BASE_DIR, "src", "styles")): - # Only keep .py files - if not style.endswith(".py"): - continue - - # Try to import the script - # If it fails, ignore it - if DEBUG: - module = import_module(f"styles.{remove_ext(style)}") - else: - try: - module = import_module(f"styles.{remove_ext(style)}") - except: - print(f"Error while importing {style}, ignoring...") - continue - - # Only keep files with the active attribute set to True - # This allows to ignore some scripts that may be in the styles directory - if not module.active: - continue - - styles[module.name] = module - - questions = [ - inq.Text("name", message="Project's name"), - inq.List("style", message="Select a style", choices=list(styles.keys())) +class OctoLogoApp(App): + BINDINGS = [ + ("ctrl+q", "quit", "Quit"), + ("ctrl+t", "toggle_dark", "Toggle Dark Mode") ] + answers = dict() + + CSS_PATH = os.path.join(BASE_DIR, "src", "app.tcss") + TITLE = "Octo Logo Wizard" + finished: bool = False + save_to: str | None = None + result: Image.Image | None = None + loading_wid: LoadingIndicator = LoadingIndicator(classes="hidden") + + async def on_key(self, event: Key): + if event.key == "enter" and self.finished: + await self.action_quit() + elif event.key == "v" and self.finished: + self.result.show() + - answers = inq.prompt(questions) + def on_wizard_finished(self, message: Wizard.Finished): + # Get the wizard answers and the wizard's id + self.answers.update(message.answers) + finished_wizard_id = message.wizard_id - # Force the user to enter a name - if not len(answers["name"]) > 0: - print("Error : You must enter a name") - quit() + # remove the wizard + self.query_one(f"#{finished_wizard_id}").remove() - selected_style = styles[answers["style"]] + # When the basic info wizard is finished, mount the style-specific wizard + if finished_wizard_id == "basic_info_wizard": + style_wizard = Wizard(id="style_wizard") + style_wizard.questions = styles[self.answers['style']].module.questions + style_wizard.title = "Style Settings" + self.mount(style_wizard) + # When the style-specific wizard is finished, create the image and save it + elif finished_wizard_id == "style_wizard": + style = styles[self.answers['style']].module + self.result = style.get_image(self.answers) + self.save_to = f'output/{self.answers["name"]}_{int(time())}.png' + self.loading_wid.remove_class("hidden") + self.set_timer(2, self.final_message) - image = selected_style.get_image(answers["name"]) + # Final message + def final_message(self): + self.loading_wid.add_class("hidden") + self.mount(Label(f"Logo saved to [bold]{self.save_to}[/bold].\n[blue blink]-> Press v to view the result[/blue blink]\n[red]Press enter to quit[/red]")) + self.result.save(self.save_to) + self.finished = True + + + def compose(self): + self.app.title = f"Octo Logo v{VERSION}" + + yield Header(show_clock=True) + yield Footer() + + basic_info_wizard = Wizard(id="basic_info_wizard") + basic_info_wizard.questions = [ + TextQuestion("name", "Your project's name", [Length(1, failure_description="Your project's name cannot be blank")], "super-octo-project" ), + SelectQuestion("style", "Logo Style", style_names, "first_letter_underlined") + ] + basic_info_wizard.title = "Basic Information" + yield basic_info_wizard + yield self.loading_wid + +def main(): - # Save result or show if debug is enabled - save_to = f'output/{answers["name"]}_{int(time())}.png' - image.show() if DEBUG else image.save(save_to) - print(f"Logo saved to {save_to}") + app = OctoLogoApp() + app.run() + quit(0) if __name__ == "__main__": main() diff --git a/src/styles/all_underlined.py b/src/styles/all_underlined.py index 26dd88e..dd85425 100644 --- a/src/styles/all_underlined.py +++ b/src/styles/all_underlined.py @@ -1,7 +1,8 @@ from . import underline_core -name = "All text underlined" +display_name = "All text underlined" active = True +questions = underline_core.questions -def get_image(name): - return underline_core.get_image(name, "all") \ No newline at end of file +def get_image(answers): + return underline_core.get_image(answers, "all") \ No newline at end of file diff --git a/src/styles/first_letter_underlined.py b/src/styles/first_letter_underlined.py index 7970d9a..2263850 100644 --- a/src/styles/first_letter_underlined.py +++ b/src/styles/first_letter_underlined.py @@ -1,7 +1,8 @@ from . import underline_core -name = "First letter underlined" +display_name = "First letter underlined" active = True +questions = underline_core.questions -def get_image(name): - return underline_core.get_image(name, "first_letter") \ No newline at end of file +def get_image(answers): + return underline_core.get_image(answers, "first_letter") \ No newline at end of file diff --git a/src/styles/underline_core.py b/src/styles/underline_core.py index 82e4f9e..44584e6 100644 --- a/src/styles/underline_core.py +++ b/src/styles/underline_core.py @@ -1,64 +1,44 @@ -import toml +from wizard import * +from textual.validation import * + from PIL import Image, ImageDraw, ImageFont, ImageColor -import inquirer as inq import sys sys.path.append("..") from utils import * +questions = [ + SelectQuestion("font", "Select a font", [(font, font) for font in font_list], "Iosevka-Nerd-Font-Complete.ttf"), + SelectQuestion("color", "Select a color scheme", color_scheme_names, "adi1090x"), + TextQuestion("padding_x", "Padding x (px)", [Number()], "200", "200"), + TextQuestion("padding_y", "Padding y (px)", [Number()], "20", "20"), + TextQuestion("gap", "Gap between text and bar (px)", [Number()], "20", "20"), + TextQuestion("bar_size", "Bar weight (px)", [Number()], "20", "20"), + TextQuestion("additionnal_bar_width", "Additionnal bar width (px)", [Number()], "20", "20"), +] + active = False -def get_image(name, type): +def get_image(answers, type): if not type in ['all', 'first_letter']: raise ValueError("Invalid type") - questions = [ - inq.List("font", message="Select a font", choices=font_list), - inq.List("color", message="Select a color scheme", choices=color_list, default="adi1090x"), - inq.Text("padding_x", message="Padding x (px)", default=200), - inq.Text("padding_y", message="Padding y (px)", default=20), - inq.Text( - "gap", message="Gap between the first letter and the bar (px)", default=20 - ), - inq.Text("bar_size", message="Bar size (px)", default=20), - inq.Text( - "additionnal_bar_width", message="Addionnal bar width (px)", default=5 - ), - ] - - answers = inq.prompt(questions) - - # Convert the answers to integers - try: - padding_x = int(answers["padding_x"]) - padding_y = int(answers["padding_y"]) - gap = int(answers["gap"]) - bar_size = int(answers["bar_size"]) - additionnal_bar_width = int(answers["additionnal_bar_width"]) - except ValueError: - print("px values must be integer") - exit(1) - # Load the selected font font_size = 500 font = ImageFont.truetype(os.path.join(FONTS_DIR, answers["font"]), font_size) - # Load the selected color scheme - color_scheme_file = os.path.join(COLORS_DIR, f'{answers["color"]}.toml') - color_scheme = toml.load(color_scheme_file) - - background = ImageColor.getrgb(color_scheme["background"]) - text = ImageColor.getrgb(color_scheme["text"]) - accent = ImageColor.getrgb(color_scheme["accent"]) + background = ImageColor.getrgb(color_schemes[answers['color']]["background"]) + text = ImageColor.getrgb(color_schemes[answers['color']]["text"]) + accent = ImageColor.getrgb(color_schemes[answers['color']]["accent"]) # Get the width and height of the texts - text_width, text_height = get_text_size(name, font) + text_width, text_height = get_text_size(answers['name'], font) font_height = get_font_height(font) # Get the correct image width and height - image_width = 2 * padding_x + text_width - image_height = 2 * padding_y + font_height + image_width = 2 * int(answers['padding_x']) + text_width + image_height = 2 * int(answers['padding_y']) + font_height # Create the image image = Image.new("RGB", (image_width, image_height), background) @@ -66,26 +46,26 @@ def get_image(name, type): # Get the anchor position and type anchor_type = "lm" - anchor_x = padding_x - anchor_y = image_height / 2 - (gap + bar_size) / 2 + anchor_x = int(answers['padding_x']) + anchor_y = image_height / 2 - (int(answers['gap']) + int(answers['bar_size'])) / 2 anchor_pos = (anchor_x, anchor_y) # Get the bbox of the first letter first_letter_bbox = draw.textbbox( - anchor_pos, name[0], font=font, anchor=anchor_type + anchor_pos, answers['name'][0], font=font, anchor=anchor_type ) # Get the underline position - underline_start_x = first_letter_bbox[0] - additionnal_bar_width - underline_start_y = first_letter_bbox[3] + gap + underline_start_x = first_letter_bbox[0] - int(answers['additionnal_bar_width']) + underline_start_y = first_letter_bbox[3] + int(answers['gap']) # The end of the underline depends on the type # If the type is 'all', the underline will go from the start of the first letter to the end of the text # If the type is 'first_letter', the underline will go from the start of the first letter to the end of the first letter - underline_end_x = additionnal_bar_width + (first_letter_bbox[2] if type == 'first_letter' else padding_x + text_width) - underline_end_y = underline_start_y + bar_size + underline_end_x = int(answers['additionnal_bar_width']) + (first_letter_bbox[2] if type == 'first_letter' else int(answers['padding_x']) + text_width) + underline_end_y = underline_start_y + int(answers['bar_size']) underline_start = (underline_start_x, underline_start_y) underline_end = (underline_end_x, underline_end_y) @@ -93,13 +73,13 @@ def get_image(name, type): underline_pos = [underline_start, underline_end] # Underline the first letter - draw.rectangle(underline_pos, fill=accent, width=bar_size) + draw.rectangle(underline_pos, fill=accent, width=answers['bar_size']) # Draw the text draw.text( anchor_pos, - name, + answers['name'], font=font, fill=text, anchor=anchor_type, @@ -108,11 +88,10 @@ def get_image(name, type): # Redraw the first letter draw.text( anchor_pos, - name[0], + answers['name'][0], font=font, fill=accent, anchor=anchor_type, ) - return image diff --git a/src/utils.py b/src/utils.py index 9d8d17d..55eda6c 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,5 +1,8 @@ +from importlib import import_module +from typing import Any import os -from PIL import Image, ImageDraw, ImageFont, ImageColor +import toml +from PIL import Image, ImageDraw def get_text_size(text, font): text_bbox = ImageDraw.Draw(Image.new("RGBA", (1, 1), (0, 0, 0, 0))).textbbox( @@ -12,23 +15,77 @@ def get_text_size(text, font): def get_font_height(font): - return font.getsize("azertyuiopqsdfghjklmwxcvbnAZERTYUIOPQASDFGHJKLMWXCVBN")[1] + return font.getsize("azertyuiopqsdfghjklmwxcvbnAZERTYUIOPQASDFGHJKLMWXCVBN0123456789")[1] def remove_ext(filename): + """ + Remove the extension from a filename if there is one + """ return filename.split(".")[0] +class Style(): + display_name: str + module: Any + + def __init__(self, display_name: str, module: Any) -> None: + self.display_name = display_name + self.module = module + + BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) FONTS_DIR = os.path.join(BASE_DIR, "fonts") COLORS_DIR = os.path.join(BASE_DIR, "colors") # Get all the fonts in the fonts directory -font_list = os.listdir(FONTS_DIR) +def get_font_list() -> list[str]: + return os.listdir(FONTS_DIR) + +font_list = get_font_list() # Get all the color schemes in the colors directory # keep only files with the .toml extension # and remove the extension -color_list = [ - remove_ext(color) for color in os.listdir(COLORS_DIR) if color.endswith(".toml") -] \ No newline at end of file +def get_color_schemes() -> dict[str, dict[str, str]]: + colors = dict() + for color_file_name in os.listdir(COLORS_DIR): + # Only keep .toml files + if not color_file_name.endswith(".toml"): + continue + + color_parsed = toml.load(os.path.join(COLORS_DIR, color_file_name)) + + colors[remove_ext(color_file_name)] = color_parsed + + return colors + +color_schemes = get_color_schemes() +color_scheme_names: dict[str, str] = [(color_schemes[color_scheme]['name'], color_scheme) for color_scheme in color_schemes] + +def get_styles() -> dict[str, Style]: + """ + return an array of style_id => Style {.module, .display_name} + """ + # Load the styles in the styles directory + styles = dict() + for style in os.listdir(os.path.join(BASE_DIR, "src", "styles")): + # Only keep .py files + if not style.endswith(".py"): + continue + + module = import_module(f"styles.{remove_ext(style)}") + + # Only keep files with the active attribute set to True + # This allows to ignore some scripts that may be in the styles directory + if not module.active: + continue + + styles[remove_ext(style)] = Style(module.display_name, module) + + return styles + + +styles = get_styles() + +style_names: dict[str, str] = [(styles[style].display_name, style) for style in styles] \ No newline at end of file diff --git a/src/wizard.py b/src/wizard.py new file mode 100644 index 0000000..7c0880d --- /dev/null +++ b/src/wizard.py @@ -0,0 +1,204 @@ +from textual import log +from typing import Any +from textual.validation import Validator +from textual.widgets import Input, Select, Static, Button, Label +from textual.validation import ValidationResult +from textual.reactive import reactive +from textual.message import Message +from textual import on + + +class QuestionBase(): + name: str + label: str + + def __init__(self, name: str, label: str) -> None: + self.name = name + self.type = type + self.label = label + +class TextQuestion(QuestionBase): + validators: list[Validator] | None + placeholder: str + default_value: str + type: str = "text" + + def __init__( + self, + name: str, + label: str, + validators: list[Validator]| None = None, + placeholder: str = "", + default_value: str = "" + ) -> None: + super().__init__(name, label) + self.validators = validators + self.placeholder = placeholder + self.default_value = default_value + + def as_widget(self): + _input = Input(classes="full-width", id=self.name, placeholder=self.placeholder, validators=self.validators) + _input.border_title = self.label + _input.value = self.default_value + return _input + + +def get_value_by_name_from_option_list(option_list, text) -> Any: + return filter(lambda x: x[0] == text, option_list).__next__()[1] + + +class SelectQuestion(QuestionBase): + options: list + type: str = "select" + default_value: Any | None = None + + def __init__( + self, + name: str, + label: str, + options: list, + default_value: str | None = None + ) -> None: + super().__init__(name, label) + self.options = options + self.default_value = default_value + + def as_widget(self): + _select = Select(classes="full-width", id=self.name, options=self.options, allow_blank=False, value=self.default_value) + _select.border_title = self.label + + return _select + +class BackNextButtons(Static): + def compose(self): + yield Button("Back", variant="warning", id="back") + yield Button("Next", variant="success", id="next") + +class Wizard(Static): + question_index = reactive(-1) + answers: dict = dict() + selected_question = None + questions: list[SelectQuestion | TextQuestion] + input_message: Label + title: str = "Wizard" + + # The message sent when the "next" button is clicked while on the last question + class Finished(Message): + answers = dict + wizard_id: str + def __init__(self, answers, wizard_id): + self.answers = answers + self.wizard_id = wizard_id + super().__init__() + + @on(Button.Pressed, "#back") + def on_back(self): + # When the back button is pressed, just go to the previous question + # It cannot be pressed if there arent any questions before + self.question_index -= 1 + + @on(Button.Pressed, "#next") + async def on_next(self): + # If the selected question is an input then fire the submit event so that + # validation is made and the next question is shown. + # Else, just go to the next question since a select cannot be invalid + if isinstance(self.selected_question, Input): + await self.selected_question.action_submit() + else: + self.question_index += 1 + + def handle_validation_result(self, validation_result: ValidationResult): + if validation_result is None or validation_result.is_valid: + # If the validation is OK then hide the error message and set the input color back to normal + # Also, reenable the next button + self.query_one("#next").disabled = False + self.selected_question.remove_class("invalid") + self.input_message.add_class("hidden") + + else: + # If the validation comports an error then disable the next button, + # Show and set the content of the error message and set the input's color to red + self.query_one("#next").disabled = True + self.input_message.remove_class("hidden") + self.input_message.renderable = validation_result.failure_descriptions[0] + self.selected_question.add_class("invalid") + + + def on_input_changed(self, message: Input.Changed): + # When an input is changed, save its new value into the self.answers dict + self.answers[message.input.id] = message.value + + # Show error messages if any + self.handle_validation_result(message.validation_result) + + def on_input_submitted(self, message: Input.Submitted): + # Handle the validation result to show + # a message if there are any errors + self.handle_validation_result(message.validation_result) + + # When the input is submitted, if it is valid then go to the next question + if (message.validation_result.is_valid): + self.question_index += 1 + + + def on_select_changed(self, message: Select.Changed): + # When a select is changed update the value in the self.answers dict + self.answers[message.select.id] = message.value + + def compose(self): + # Render directly every input + # They are all hidden by default + for i, question in enumerate(self.questions): + wid = question.as_widget() + + # For every select, the value in the answers dict will not be updated if the user just keeps the default value + # and click next without changing the value, the on_select_changed function will not be called and the + # answers dict will not contain the key corresponding to the select which can result in a KeyError + if isinstance(wid, Select): + self.answers[wid.id] = question.default_value + + # Hide every questions except the first one + if i != 0: + wid.add_class("hidden") + + # mount the question + yield wid + + # The error message below inputs if there are any errors + self.input_message = Label("This is the input error message", id="input_message", classes="hidden") + self.input_message.styles.color = "tomato" + self.input_message.styles.max_width = "100%" + yield self.input_message + + # ---------------------------- + yield BackNextButtons() + + + def on_mount(self): + # Trigger the watch_question_index function to make the first input appear + self.question_index = 0 + + def watch_question_index(self): + + # Remove the selected class from the previous shown input if any + if not self.selected_question is None: + self.selected_question.add_class("hidden") + + # If the question index has been incremented but it is now out of bound then + # the user clicked next on the last question + if self.question_index == len(self.questions): + self.post_message(self.Finished(self.answers, self.id)) + return + + # Put the question index in the border title + self.border_title = f"{self.title} [{self.question_index + 1}/{len(self.questions)}]" + + + # Show the input corresponding to the new value of self.question_index + self.selected_question = self.query_one(f"#{self.questions[self.question_index].name}") + self.selected_question.remove_class("hidden") + self.selected_question.focus() + + # If the first question has just been selected then disable + # the "Back" button since there arent any questions before + self.query_one("#back").disabled = self.question_index == 0 \ No newline at end of file