Skip to content

Commit

Permalink
terminal ui with textual (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
SkwalExe authored Sep 28, 2023
1 parent 7f79f4f commit 6e35241
Show file tree
Hide file tree
Showing 11 changed files with 418 additions and 108 deletions.
3 changes: 2 additions & 1 deletion colors/adi1090x.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
name = "Adi1090x"
background = "#131b20"
text = "#283039"
accent = "#a8bf3e"
accent = "#a8bf3e"
1 change: 1 addition & 0 deletions colors/cherry.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
name = "Cherry"
background = "#ffdcdc"
text = "#fda8ac"
accent = "#ef5e81"
1 change: 1 addition & 0 deletions colors/midnight_abyss.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
name = "Midnight Abyss"
background = "#000000"
text = "#272c38bd"
accent = "#30b962"
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ pillow==9.3.0
inquirer==3.1.3
toml==0.10.2
importlib==1.0.4
textual==0.38.1
35 changes: 35 additions & 0 deletions src/app.tcss
Original file line number Diff line number Diff line change
@@ -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;
}
117 changes: 73 additions & 44 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -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()
7 changes: 4 additions & 3 deletions src/styles/all_underlined.py
Original file line number Diff line number Diff line change
@@ -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")
def get_image(answers):
return underline_core.get_image(answers, "all")
7 changes: 4 additions & 3 deletions src/styles/first_letter_underlined.py
Original file line number Diff line number Diff line change
@@ -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")
def get_image(answers):
return underline_core.get_image(answers, "first_letter")
81 changes: 30 additions & 51 deletions src/styles/underline_core.py
Original file line number Diff line number Diff line change
@@ -1,105 +1,85 @@
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)
draw = ImageDraw.Draw(image)

# 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)

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,
Expand All @@ -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
Loading

0 comments on commit 6e35241

Please sign in to comment.