Files
2026-04-14 14:26:10 -04:00

259 lines
7.6 KiB
Python

from functools import lru_cache
from PIL import Image, ImageColor, ImageDraw
FONT_FAMILY = "Georgia"
COLORS = {
"bg_primary": "#151515",
"bg_secondary": "#1d1d1d",
"bg_tertiary": "#212121",
"bg_card": "#2b2b2b",
"bg_hover": "#36312c",
"bg_error": "#2f2926",
"text_primary": "#f5f2ec",
"text_highlight": "#f0a621",
"text_body": "#c8c8c8",
"text_secondary": "#ffffff",
"text_muted": "#979797",
"accent_green": "#3a8e35",
"accent_red": "#ff4a46",
"accent_yellow": "#f0a621",
"accent_blue": "#d7b56a",
"border_light": "#4d453b",
"border_dark": "#0f0f0f",
"stripe": "#1d1d1d",
"stripe_soft": "#232323",
}
@lru_cache(maxsize=4)
def _load_rgba_source(image_path):
"""Load and cache a source image in RGBA form for repeated resizes."""
return Image.open(image_path).convert("RGBA")
def _rgba(color, alpha=255):
red, green, blue = ImageColor.getrgb(color)
return red, green, blue, alpha
def create_striped_texture(
width,
height,
*,
base_color=None,
stripe_color=None,
stripe_width=16,
stripe_gap=18,
stripe_alpha=110,
corner_radius=0,
border_color=None,
border_width=0,
border_alpha=255,
):
"""Create a dark diagonal striped texture matching the pack UI style."""
width = max(1, int(width))
height = max(1, int(height))
base = Image.new(
"RGBA",
(width, height),
_rgba(base_color or COLORS["bg_card"]),
)
stripes = Image.new("RGBA", (width, height), (0, 0, 0, 0))
stripe_draw = ImageDraw.Draw(stripes)
span = width + height
step = max(1, stripe_width + stripe_gap)
for offset in range(-height, span + height, step):
stripe_draw.line(
(offset, 0, offset + height, height),
fill=_rgba(stripe_color or COLORS["stripe"], stripe_alpha),
width=max(1, stripe_width),
)
image = Image.alpha_composite(base, stripes)
if border_color and border_width > 0:
border_draw = ImageDraw.Draw(image)
inset = max(1, border_width // 2)
bounds = (inset, inset, width - inset - 1, height - inset - 1)
if corner_radius > 0:
border_draw.rounded_rectangle(
bounds,
radius=max(1, corner_radius - inset),
outline=_rgba(border_color, border_alpha),
width=border_width,
)
else:
border_draw.rectangle(
bounds,
outline=_rgba(border_color, border_alpha),
width=border_width,
)
if corner_radius > 0:
mask = Image.new("L", (width, height), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.rounded_rectangle(
(0, 0, width - 1, height - 1),
radius=corner_radius,
fill=255,
)
clipped = Image.new("RGBA", (width, height), (0, 0, 0, 0))
clipped.paste(image, (0, 0), mask)
image = clipped
return image
def create_striped_panel(
width,
height,
*,
panel_color=None,
stripe_color=None,
stripe_width=18,
stripe_gap=18,
stripe_alpha=110,
halo_padding=22,
halo_alpha=135,
panel_alpha=240,
corner_radius=18,
border_color=None,
border_width=1,
border_alpha=255,
):
"""Create a mostly solid panel with stripes that extend just beyond its edges."""
width = max(1, int(width))
height = max(1, int(height))
halo_padding = max(0, int(halo_padding))
total_width = width + (halo_padding * 2)
total_height = height + (halo_padding * 2)
image = Image.new("RGBA", (total_width, total_height), (0, 0, 0, 0))
stripes = Image.new("RGBA", (total_width, total_height), (0, 0, 0, 0))
stripe_draw = ImageDraw.Draw(stripes)
span = total_width + total_height
step = max(1, stripe_width + stripe_gap)
for offset in range(-total_height, span + total_height, step):
stripe_draw.line(
(offset, 0, offset + total_height, total_height),
fill=_rgba(stripe_color or COLORS["stripe"], halo_alpha),
width=max(1, stripe_width),
)
halo_mask = Image.new("L", (total_width, total_height), 0)
halo_draw = ImageDraw.Draw(halo_mask)
halo_draw.rounded_rectangle(
(0, 0, total_width - 1, total_height - 1),
radius=max(1, corner_radius + halo_padding),
fill=255,
)
halo_image = Image.new("RGBA", (total_width, total_height), (0, 0, 0, 0))
halo_image.paste(stripes, (0, 0), halo_mask)
image = Image.alpha_composite(image, halo_image)
panel_left = halo_padding
panel_top = halo_padding
panel_right = panel_left + width - 1
panel_bottom = panel_top + height - 1
panel = Image.new("RGBA", (total_width, total_height), (0, 0, 0, 0))
panel_draw = ImageDraw.Draw(panel)
panel_draw.rounded_rectangle(
(panel_left, panel_top, panel_right, panel_bottom),
radius=corner_radius,
fill=_rgba(panel_color or COLORS["bg_card"], panel_alpha),
)
if border_color and border_width > 0:
inset = max(1, border_width // 2)
panel_draw.rounded_rectangle(
(
panel_left + inset,
panel_top + inset,
panel_right - inset,
panel_bottom - inset,
),
radius=max(1, corner_radius - inset),
outline=_rgba(border_color, border_alpha),
width=border_width,
)
image = Image.alpha_composite(image, panel)
return image
def load_cover_background(image_path, width, height, *, overlay_color=None, overlay_alpha=125):
"""Load an image, scale it to cover, and apply a dark overlay for readability."""
width = max(1, int(width))
height = max(1, int(height))
image = _load_rgba_source(image_path)
source_ratio = image.width / image.height
target_ratio = width / height
if source_ratio > target_ratio:
new_height = height
new_width = int(height * source_ratio)
else:
new_width = width
new_height = int(width / source_ratio)
image = image.resize((new_width, new_height), Image.Resampling.BICUBIC)
left = (new_width - width) // 2
top = (new_height - height) // 2
image = image.crop((left, top, left + width, top + height))
overlay = Image.new(
"RGBA",
(width, height),
_rgba(overlay_color or COLORS["bg_primary"], overlay_alpha),
)
return Image.alpha_composite(image, overlay)
def style_text_button(
button,
foreground,
background,
*,
hover_foreground=None,
disabled_foreground=None,
):
"""Style a tkinter Button to look like a color-coded text action."""
button.configure(
bg=background,
fg=foreground,
activebackground=background,
activeforeground=hover_foreground or COLORS["text_primary"],
relief="flat",
bd=0,
borderwidth=0,
highlightthickness=0,
disabledforeground=disabled_foreground or COLORS["text_muted"],
cursor="hand2",
)
def center_window(window, width, height, parent=None):
"""Center a window either on its parent or the current screen."""
width = int(width)
height = int(height)
window.update_idletasks()
if parent is not None and parent.winfo_exists():
x = parent.winfo_rootx() + (parent.winfo_width() // 2) - (width // 2)
y = parent.winfo_rooty() + (parent.winfo_height() // 2) - (height // 2)
else:
x = (window.winfo_screenwidth() // 2) - (width // 2)
y = (window.winfo_screenheight() // 2) - (height // 2)
window.geometry(f"{width}x{height}+{max(0, x)}+{max(0, y)}")