import gi gi.require_version('Gtk', '2.0') gi.require_version('Gdk', '2.0') from gi.repository import Gtk, Gdk, GdkPixbuf, Pango import subprocess import os import json # Configuration PREVIEW_FILENAME = "preview_ultimate.png" class ButtonEngine: def __init__(self): self.magick_binary = None self.find_binary() self.fetch_system_fonts() # --- State --- # General / Background self.bg_type = "Solid" # Solid, Gradient, Pattern, Template self.bg_color = "#ffffff" self.bg_color_end = "#cccccc" # For gradient self.bg_gradient_dir = "Vertical" # Vertical, Horizontal self.bg_pattern = "Checkerboard" self.bg_template_path = None self.border_enabled = True self.border_style = "Solid" # Solid, Raised, Sunken self.border_color = "#000000" # Image self.img_path = None self.img_x = 2 self.img_y = 2 self.img_w = 26 self.img_h = 26 # Text 1 default_font_path = self.resolve_font_path("Sans") self.text1_content = "HELLO" self.text1_color = "#ff0000" self.text1_font = default_font_path self.text1_size = 10 self.text1_x = 4 self.text1_y = 20 self.text1_outline_enabled = False self.text1_outline_color = "#000000" # Text 2 self.text2_content = "WORLD" self.text2_color = "#000000" self.text2_font = default_font_path self.text2_size = 10 self.text2_x = 44 self.text2_y = 20 self.text2_outline_enabled = False self.text2_outline_color = "#ffffff" # Animation self.text1_anim_type = "None" self.text1_anim_speed = 5 self.text2_anim_type = "None" self.text2_anim_speed = 5 self.gif_delay = 10 # centiseconds self.gif_frames = 20 # Global Effects self.text_antialias = True self.effect_noise = False self.effect_scanlines = False self.effect_dither = False def get_state(self): state = {} fields = [ "bg_type", "bg_color", "bg_color_end", "bg_gradient_dir", "bg_pattern", "bg_template_path", "border_enabled", "border_style", "border_color", "img_path", "img_x", "img_y", "img_w", "img_h", "text1_content", "text1_color", "text1_font", "text1_size", "text1_x", "text1_y", "text1_outline_enabled", "text1_outline_color", "text2_content", "text2_color", "text2_font", "text2_size", "text2_x", "text2_y", "text2_outline_enabled", "text2_outline_color", "text1_anim_type", "text1_anim_speed", "text2_anim_type", "text2_anim_speed", "gif_delay", "gif_frames", "text_antialias", "effect_noise", "effect_scanlines", "effect_dither" ] for f in fields: state[f] = getattr(self, f) return state def set_state(self, state): for k, v in state.items(): if hasattr(self, k): setattr(self, k, v) def hex_to_rgba(self, hex_color, alpha): hex_color = hex_color.lstrip('#') if len(hex_color) == 3: hex_color = ''.join([c*2 for c in hex_color]) try: r = int(hex_color[0:2], 16) g = int(hex_color[2:4], 16) b = int(hex_color[4:6], 16) return f"rgba({r},{g},{b},{alpha})" except: return f"rgba(0,0,0,{alpha})" def find_binary(self): for binary in ["magick", "convert"]: try: startupinfo = None if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW subprocess.run([binary, "-version"], check=True, stdout=subprocess.DEVNULL, stderr=None, startupinfo=startupinfo) self.magick_binary = binary return True except (FileNotFoundError, Exception): continue return False def fetch_system_fonts(self): self.system_fonts = ["Arial", "Courier", "Fixed", "Times", "Verdana"] # Fallback if not self.magick_binary: return try: startupinfo = None if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # Run -list font res = subprocess.run([self.magick_binary, "-list", "font"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, startupinfo=startupinfo) if res.returncode == 0: fonts = [] for line in res.stdout.splitlines(): line = line.strip() if line.startswith("Font:"): font_name = line.split(":", 1)[1].strip() fonts.append(font_name) if fonts: self.system_fonts = sorted(fonts) except Exception as e: print(f"Error fetching fonts: {e}") def resolve_font_path(self, font_name): # Use fc-match to get the file path try: startupinfo = None if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW res = subprocess.run(["fc-match", font_name, "-f", "%{file}"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, startupinfo=startupinfo) if res.returncode == 0: path = res.stdout.strip() if path: return path except: pass return font_name # Fallback def generate(self, output_file): if not self.magick_binary: return False animated = (self.text1_anim_type != "None" or self.text2_anim_type != "None") frames = self.gif_frames if animated else 1 cmd_base = [self.magick_binary] if animated: cmd_base.extend(["-delay", str(self.gif_delay), "-loop", "0"]) for f_idx in range(frames): if animated: cmd_base.append("(") # 1. Background if self.bg_type == "Template" and self.bg_template_path: cmd_base.extend([self.bg_template_path, "-resize", "88x31!", "-extent", "88x31"]) elif self.bg_type == "Gradient": cmd_base.extend(["-size", "88x31", "xc:none", "-sparse-color", "Barycentric", f"0,0 {self.bg_color} " + ("%w,0 " if self.bg_gradient_dir == "Horizontal" else "0,%h ") + self.bg_color_end]) elif self.bg_type == "Pattern": pat = self.bg_pattern.lower() if "checker" in pat: pat = "checkerboard" cmd_base.extend(["-size", "88x31", f"pattern:{pat}"]) else: cmd_base.extend(["-size", "88x31", f"xc:{self.bg_color}"]) # 2. Image if self.img_path: cmd_base.extend(["(", self.img_path, "-resize", f"{self.img_w}x{self.img_h}!", ")", "-gravity", "NorthWest", "-geometry", f"+{self.img_x}+{self.img_y}", "-composite"]) # 3. Text Layers def add_text_layer_to_frame(idx, content, font, size, color, x, y, outline, outline_col, anim_type, anim_speed): if not content: return # Animation logic draw_x, draw_y = x, y opacity = 1.0 if anim_type == "Marquee": # Smooth scrolling wrap draw_x = (x + (f_idx * anim_speed)) % 140 - 40 elif anim_type == "Blink": # Smooth Fade: triangular wave # Frequency is scaled by speed phase = (f_idx * anim_speed) % 20 opacity = 1.0 - abs(10 - phase) / 10.0 if opacity < 0.05: return common_opts = ["-font", font, "-pointsize", str(size), "-gravity", "NorthWest"] if not self.text_antialias: common_opts.append("+antialias") else: common_opts.append("-antialias") fill_rgba = self.hex_to_rgba(color, opacity) if outline: out_rgba = self.hex_to_rgba(outline_col, opacity) cmd_base.extend(common_opts + ["-fill", fill_rgba, "-stroke", out_rgba, "-strokewidth", "1", "-annotate", f"+{draw_x}+{draw_y}", content, "-stroke", "none"]) cmd_base.extend(common_opts + ["-fill", fill_rgba, "-annotate", f"+{draw_x}+{draw_y}", content]) add_text_layer_to_frame(1, self.text1_content, self.text1_font, self.text1_size, self.text1_color, self.text1_x, self.text1_y, self.text1_outline_enabled, self.text1_outline_color, self.text1_anim_type, self.text1_anim_speed) add_text_layer_to_frame(2, self.text2_content, self.text2_font, self.text2_size, self.text2_color, self.text2_x, self.text2_y, self.text2_outline_enabled, self.text2_outline_color, self.text2_anim_type, self.text2_anim_speed) # 4. Border if self.border_enabled: if self.border_style == "Raised": cmd_base.extend(["-fill", "none", "-strokewidth", "1", "-stroke", "white", "-draw", "line 0,0 87,0 line 0,0 0,30", "-stroke", "gray50", "-draw", "line 0,30 87,30 line 87,0 87,30"]) elif self.border_style == "Sunken": cmd_base.extend(["-fill", "none", "-strokewidth", "1", "-stroke", "gray50", "-draw", "line 0,0 87,0 line 0,0 0,30", "-stroke", "white", "-draw", "line 0,30 87,30 line 87,0 87,30"]) elif self.border_style == "Dashed": cmd_base.extend(["-fill", "none", "-stroke", self.border_color, "-strokewidth", "1", "-draw", "stroke-dasharray 3 3 rectangle 0,0 87,30"]) elif self.border_style == "Dotted": cmd_base.extend(["-fill", "none", "-stroke", self.border_color, "-strokewidth", "1", "-draw", "stroke-dasharray 1 2 rectangle 0,0 87,30"]) else: cmd_base.extend(["-fill", "none", "-stroke", self.border_color, "-strokewidth", "1", "-draw", "rectangle 0,0 87,30"]) # 5. Effects if self.effect_noise: cmd_base.extend(["+noise", "Gaussian", "-attenuate", "0.5"]) if self.effect_scanlines: lines = " ".join([f"line 0,{i} 88,{i}" for i in range(0, 31, 2)]) cmd_base.extend(["-fill", "rgba(0,0,0,0.2)", "-draw", lines]) if self.effect_dither: cmd_base.extend(["-dither", "FloydSteinberg", "-colors", "16"]) if animated: cmd_base.append(")") cmd_base.append(output_file) try: startupinfo = None if os.name == 'nt': startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW subprocess.run(cmd_base, check=True, stdout=subprocess.DEVNULL, stderr=None, startupinfo=startupinfo) return True except Exception as e: print(f"Engine Error: {e}") return False class UltimateButtonApp: def __init__(self): self.engine = ButtonEngine() self.window = Gtk.Window(type=Gtk.WindowType.TOPLEVEL) self.window.set_title("88x31 Ultimate Studio") self.window.set_resizable(True) self.window.connect("destroy", lambda w: Gtk.main_quit()) self.window.set_border_width(10) self.zoom_level = 1 # 1, 2, 4 self.create_widgets() self.update_preview() self.window.show_all() # Trigger initial visibility state self.on_bg_type_changed(self.cmb_bg_type) def create_combo(self, items): store = Gtk.ListStore(str) for item in items: store.append([item]) combo = Gtk.ComboBox(model=store) cell = Gtk.CellRendererText() combo.pack_start(cell, True) combo.add_attribute(cell, 'text', 0) combo.set_active(0) return combo def get_combo_text(self, combo): itr = combo.get_active_iter() if itr: return combo.get_model()[itr][0] return None def gdk_color_to_hex(self, gdk_color): return "#%02x%02x%02x" % (gdk_color.red >> 8, gdk_color.green >> 8, gdk_color.blue >> 8) def create_widgets(self): main_hbox = Gtk.HBox(spacing=10) self.window.add(main_hbox) notebook = Gtk.Notebook() notebook.set_size_request(340, -1) main_hbox.pack_start(notebook, True, True, 0) self.build_tab_general(notebook) self.build_tab_image(notebook) self.build_tab_text(notebook) self.build_tab_anim(notebook) self.build_tab_effects(notebook) # Preview vbox_preview = Gtk.VBox(spacing=10) main_hbox.pack_start(vbox_preview, False, False, 0) # Zoom Control hb_zoom = Gtk.HBox(spacing=5) vbox_preview.pack_start(hb_zoom, False, False, 0) hb_zoom.pack_start(Gtk.Label(label="Zoom:"), False, False, 0) self.cmb_zoom = self.create_combo(["1x", "2x", "4x"]) self.cmb_zoom.set_active(0) self.cmb_zoom.connect("changed", self.on_zoom_changed) hb_zoom.pack_start(self.cmb_zoom, True, True, 0) frame_preview = Gtk.Frame(label="Preview") vbox_preview.pack_start(frame_preview, False, False, 0) self.preview_image = Gtk.Image() alignment = Gtk.Alignment(xalign=0.5, yalign=0.5, xscale=0, yscale=0) alignment.add(self.preview_image) alignment.set_padding(20, 20, 20, 20) frame_preview.add(alignment) btn_update = Gtk.Button(label="Update Preview") btn_update.connect("clicked", self.on_update_clicked) vbox_preview.pack_start(btn_update, False, False, 0) btn_save = Gtk.Button(label="Save Button...") btn_save.connect("clicked", self.save_image) vbox_preview.pack_start(btn_save, False, False, 0) # Presets Section frame_presets = Gtk.Frame(label="Presets") vbox_preview.pack_start(frame_presets, False, False, 5) hb_presets = Gtk.HBox(spacing=5) hb_presets.set_border_width(5) frame_presets.add(hb_presets) btn_save_p = Gtk.Button(label="Save...") btn_save_p.connect("clicked", self.on_save_preset) hb_presets.pack_start(btn_save_p, True, True, 0) btn_load_p = Gtk.Button(label="Load...") btn_load_p.connect("clicked", self.on_load_preset) hb_presets.pack_start(btn_load_p, True, True, 0) def build_tab_general(self, notebook): vbox = Gtk.VBox(spacing=5) vbox.set_border_width(10) # Background frame_bg = Gtk.Frame(label="Background") vbox.pack_start(frame_bg, False, False, 0) vb_bg = Gtk.VBox(spacing=5); vb_bg.set_border_width(5) frame_bg.add(vb_bg) # Type Selector hb_type = Gtk.HBox(spacing=5) vb_bg.pack_start(hb_type, False, False, 0) hb_type.pack_start(Gtk.Label(label="Type:"), False, False, 0) self.cmb_bg_type = self.create_combo(["Solid", "Gradient", "Pattern", "Template"]) self.cmb_bg_type.connect("changed", self.on_bg_type_changed) hb_type.pack_start(self.cmb_bg_type, True, True, 0) # Controls Container (hide/show these) self.vb_bg_controls = Gtk.VBox(spacing=5) vb_bg.pack_start(self.vb_bg_controls, False, False, 0) # -- Solid Controls -- self.hb_solid = Gtk.HBox(spacing=5) self.hb_solid.pack_start(Gtk.Label(label="Color:"), False, False, 0) self.bg_color_btn = Gtk.ColorButton() self.bg_color_btn.set_color(Gdk.Color.parse(self.engine.bg_color)[1]) self.hb_solid.pack_start(self.bg_color_btn, False, False, 0) # -- Gradient Controls -- self.vb_grad = Gtk.VBox(spacing=5) hb_g1 = Gtk.HBox(spacing=5) hb_g1.pack_start(Gtk.Label(label="Start Color:"), False, False, 0) self.grad_start_btn = Gtk.ColorButton() self.grad_start_btn.set_color(Gdk.Color.parse(self.engine.bg_color)[1]) hb_g1.pack_start(self.grad_start_btn, False, False, 0) self.vb_grad.pack_start(hb_g1, False, False, 0) hb_g2 = Gtk.HBox(spacing=5) hb_g2.pack_start(Gtk.Label(label="End Color:"), False, False, 0) self.grad_end_btn = Gtk.ColorButton() self.grad_end_btn.set_color(Gdk.Color.parse(self.engine.bg_color_end)[1]) hb_g2.pack_start(self.grad_end_btn, False, False, 0) self.vb_grad.pack_start(hb_g2, False, False, 0) hb_g3 = Gtk.HBox(spacing=5) hb_g3.pack_start(Gtk.Label(label="Direction:"), False, False, 0) self.cmb_grad_dir = self.create_combo(["Vertical", "Horizontal"]) hb_g3.pack_start(self.cmb_grad_dir, False, False, 0) self.vb_grad.pack_start(hb_g3, False, False, 0) # -- Pattern Controls -- self.hb_pat = Gtk.HBox(spacing=5) self.hb_pat.pack_start(Gtk.Label(label="Pattern:"), False, False, 0) self.cmb_pat = self.create_combo(["Checkerboard", "Hexagons", "Bricks", "Circles", "CrossHatch"]) self.hb_pat.pack_start(self.cmb_pat, True, True, 0) # -- Template Controls -- self.hb_tmpl = Gtk.HBox(spacing=5) btn_tmpl = Gtk.Button(label="Select...") btn_tmpl.connect("clicked", self.select_template) self.hb_tmpl.pack_start(btn_tmpl, True, True, 0) self.lbl_bg_tmpl = Gtk.Label(label="No template") self.hb_tmpl.pack_start(self.lbl_bg_tmpl, False, False, 0) # Add all to controls box self.vb_bg_controls.pack_start(self.hb_solid, False, False, 0) self.vb_bg_controls.pack_start(self.vb_grad, False, False, 0) self.vb_bg_controls.pack_start(self.hb_pat, False, False, 0) self.vb_bg_controls.pack_start(self.hb_tmpl, False, False, 0) # Border frame_brd = Gtk.Frame(label="Border") vbox.pack_start(frame_brd, False, False, 5) vb_brd = Gtk.VBox(spacing=5); vb_brd.set_border_width(5) frame_brd.add(vb_brd) self.chk_border = Gtk.CheckButton(label="Enable Border") self.chk_border.set_active(self.engine.border_enabled) vb_brd.pack_start(self.chk_border, False, False, 0) hb_bstyle = Gtk.HBox(spacing=5) vb_brd.pack_start(hb_bstyle, False, False, 0) hb_bstyle.pack_start(Gtk.Label(label="Style:"), False, False, 0) self.cmb_brd_style = self.create_combo(["Solid", "Raised", "Sunken", "Dashed", "Dotted"]) hb_bstyle.pack_start(self.cmb_brd_style, True, True, 0) hb_bcol = Gtk.HBox(spacing=5) vb_brd.pack_start(hb_bcol, False, False, 0) hb_bcol.pack_start(Gtk.Label(label="Color:"), False, False, 0) self.brd_color_btn = Gtk.ColorButton() self.brd_color_btn.set_color(Gdk.Color.parse(self.engine.border_color)[1]) hb_bcol.pack_start(self.brd_color_btn, False, False, 0) notebook.append_page(vbox, Gtk.Label(label="General")) def on_bg_type_changed(self, combo): # Determine which controls to show txt = self.get_combo_text(combo) if not txt: return # Hide all first self.hb_solid.hide() self.vb_grad.hide() self.hb_pat.hide() self.hb_tmpl.hide() if txt == "Solid": self.hb_solid.show() elif txt == "Gradient": self.vb_grad.show() elif txt == "Pattern": self.hb_pat.show() elif txt == "Template": self.hb_tmpl.show() def build_tab_image(self, notebook): vbox = Gtk.VBox(spacing=5) vbox.set_border_width(10) btn_sel = Gtk.Button(label="Select Image URL/File...") btn_sel.connect("clicked", self.select_img) vbox.pack_start(btn_sel, False, False, 0) self.lbl_img = Gtk.Label(label="No image") vbox.pack_start(self.lbl_img, False, False, 0) hb_dim = Gtk.HBox(spacing=5) vbox.pack_start(hb_dim, False, False, 5) hb_dim.pack_start(Gtk.Label(label="W:"), False, False, 0) self.spin_img_w = Gtk.SpinButton() self.spin_img_w.set_range(1, 88); self.spin_img_w.set_value(self.engine.img_w); self.spin_img_w.set_increments(1, 10) hb_dim.pack_start(self.spin_img_w, False, False, 0) hb_dim.pack_start(Gtk.Label(label="H:"), False, False, 0) self.spin_img_h = Gtk.SpinButton() self.spin_img_h.set_range(1, 31); self.spin_img_h.set_value(self.engine.img_h); self.spin_img_h.set_increments(1, 10) hb_dim.pack_start(self.spin_img_h, False, False, 0) hb_pos = Gtk.HBox(spacing=5) vbox.pack_start(hb_pos, False, False, 0) hb_pos.pack_start(Gtk.Label(label="X:"), False, False, 0) self.spin_img_x = Gtk.SpinButton() self.spin_img_x.set_range(-20, 88); self.spin_img_x.set_value(self.engine.img_x); self.spin_img_x.set_increments(1, 10) hb_pos.pack_start(self.spin_img_x, False, False, 0) hb_pos.pack_start(Gtk.Label(label="Y:"), False, False, 0) self.spin_img_y = Gtk.SpinButton() self.spin_img_y.set_range(-20, 31); self.spin_img_y.set_value(self.engine.img_y); self.spin_img_y.set_increments(1, 10) hb_pos.pack_start(self.spin_img_y, False, False, 0) notebook.append_page(vbox, Gtk.Label(label="Image")) def build_tab_text(self, notebook): scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) vbox_main = Gtk.VBox(spacing=10) vbox_main.set_border_width(10) scrolled.add_with_viewport(vbox_main) for index in [1, 2]: frame = Gtk.Frame(label=f"Text Layer {index}") vbox_main.pack_start(frame, False, False, 0) vbox = Gtk.VBox(spacing=5) vbox.set_border_width(5) frame.add(vbox) # Content vbox.pack_start(Gtk.Label(label=f"Content:"), False, False, 0) entry_txt = Gtk.Entry() entry_txt.set_text(getattr(self.engine, f"text{index}_content")) vbox.pack_start(entry_txt, False, False, 0) # Color & Font hb_style = Gtk.HBox(spacing=5) vbox.pack_start(hb_style, False, False, 0) btn_col = Gtk.ColorButton() btn_col.set_color(Gdk.Color.parse(getattr(self.engine, f"text{index}_color"))[1]) hb_style.pack_start(btn_col, False, False, 0) # Font Picker curr_font = getattr(self.engine, f"text{index}_font") curr_size = getattr(self.engine, f"text{index}_size") btn_font = Gtk.FontButton() btn_font.set_title(f"Font for Text {index}") # Try to set initial font name - might need accurate name btn_font.set_font_name(f"{curr_font} {curr_size}") btn_font.connect("font-set", self.on_font_set, index) hb_style.pack_start(btn_font, True, True, 0) # Size (Keep SpinButton for manual tweaks, but it syncs with FontButton) hb_size = Gtk.HBox(spacing=5) vbox.pack_start(hb_size, False, False, 0) hb_size.pack_start(Gtk.Label(label="Size:"), False, False, 0) spin_size = Gtk.SpinButton() spin_size.set_range(4, 72); spin_size.set_value(curr_size); spin_size.set_increments(1, 5) hb_size.pack_start(spin_size, False, False, 0) # Position hb_pos = Gtk.HBox(spacing=5) vbox.pack_start(hb_pos, False, False, 0) hb_pos.pack_start(Gtk.Label(label="X:"), False, False, 0) spin_x = Gtk.SpinButton() spin_x.set_range(-20, 100); spin_x.set_value(getattr(self.engine, f"text{index}_x")); spin_x.set_increments(1, 10) hb_pos.pack_start(spin_x, False, False, 0) hb_pos.pack_start(Gtk.Label(label="Y:"), False, False, 0) spin_y = Gtk.SpinButton() spin_y.set_range(-20, 50); spin_y.set_value(getattr(self.engine, f"text{index}_y")); spin_y.set_increments(1, 10) hb_pos.pack_start(spin_y, False, False, 0) # Outline vbox.pack_start(Gtk.HSeparator(), False, False, 5) chk_out = Gtk.CheckButton(label="Enable Outline") chk_out.set_active(getattr(self.engine, f"text{index}_outline_enabled")) vbox.pack_start(chk_out, False, False, 0) hb_ocol = Gtk.HBox(spacing=5) vbox.pack_start(hb_ocol, False, False, 0) hb_ocol.pack_start(Gtk.Label(label="Color:"), False, False, 0) btn_ocol = Gtk.ColorButton() btn_ocol.set_color(Gdk.Color.parse(getattr(self.engine, f"text{index}_outline_color"))[1]) hb_ocol.pack_start(btn_ocol, False, False, 0) setattr(self, f"entry_t{index}", entry_txt) setattr(self, f"btn_col_t{index}", btn_col) setattr(self, f"btn_font_t{index}", btn_font) # Changed from cmb_font_t{index} setattr(self, f"spin_sz_t{index}", spin_size) setattr(self, f"spin_x_t{index}", spin_x) setattr(self, f"spin_y_t{index}", spin_y) setattr(self, f"spin_x_t{index}", spin_x) setattr(self, f"spin_y_t{index}", spin_y) setattr(self, f"chk_out_t{index}", chk_out) setattr(self, f"btn_ocol_t{index}", btn_ocol) notebook.append_page(scrolled, Gtk.Label(label="Text")) def on_font_set(self, widget, index): font_name = widget.get_font_name() desc = Pango.FontDescription.from_string(font_name) # Resolve path family = desc.get_family() resolved = self.engine.resolve_font_path(family) setattr(self.engine, f"text{index}_font", resolved) size = int(desc.get_size() / Pango.SCALE) # Update spin button spin = getattr(self, f"spin_sz_t{index}") spin.set_value(size) self.update_preview() def build_tab_anim(self, notebook): vbox = Gtk.VBox(spacing=10) vbox.set_border_width(10) for i in [1, 2]: frame = Gtk.Frame(label=f"Text Layer {i} Animation") vbox.pack_start(frame, False, False, 0) vb = Gtk.VBox(spacing=5); vb.set_border_width(5); frame.add(vb) hb1 = Gtk.HBox(spacing=5); vb.pack_start(hb1, False, False, 0) hb1.pack_start(Gtk.Label(label="Type:"), False, False, 0) cmb = self.create_combo(["None", "Marquee", "Blink"]) hb1.pack_start(cmb, True, True, 0) setattr(self, f"cmb_anim_t{i}", cmb) hb2 = Gtk.HBox(spacing=5); vb.pack_start(hb2, False, False, 0) hb2.pack_start(Gtk.Label(label="Speed:"), False, False, 0) spin = Gtk.SpinButton() spin.set_range(1, 20); spin.set_increments(1, 5) hb2.pack_start(spin, False, False, 0) setattr(self, f"spin_anim_sz_t{i}", spin) cmb.connect("changed", lambda w: self.update_preview()) spin.connect("value-changed", lambda w: self.update_preview()) # Global frame_glob = Gtk.Frame(label="Global GIF") vbox.pack_start(frame_glob, False, False, 0) vb_g = Gtk.VBox(spacing=5); vb_g.set_border_width(5); frame_glob.add(vb_g) hb_d = Gtk.HBox(spacing=5); vb_g.pack_start(hb_d, False, False, 0) hb_d.pack_start(Gtk.Label(label="Delay (cs):"), False, False, 0) self.spin_gif_delay = Gtk.SpinButton() self.spin_gif_delay.set_range(1, 100); self.spin_gif_delay.set_value(10) self.spin_gif_delay.set_increments(1, 10) self.spin_gif_delay.connect("value-changed", lambda w: self.update_preview()) hb_d.pack_start(self.spin_gif_delay, False, False, 0) hb_f = Gtk.HBox(spacing=5); vb_g.pack_start(hb_f, False, False, 0) hb_f.pack_start(Gtk.Label(label="Frames:"), False, False, 0) self.spin_gif_frames = Gtk.SpinButton() self.spin_gif_frames.set_range(2, 100); self.spin_gif_frames.set_value(20) self.spin_gif_frames.set_increments(1, 10) self.spin_gif_frames.connect("value-changed", lambda w: self.update_preview()) hb_f.pack_start(self.spin_gif_frames, False, False, 0) notebook.append_page(vbox, Gtk.Label(label="Animation")) def build_tab_effects(self, notebook): vbox = Gtk.VBox(spacing=5) vbox.set_border_width(10) self.chk_aa = Gtk.CheckButton(label="Text Antialiasing") self.chk_aa.set_active(self.engine.text_antialias) vbox.pack_start(self.chk_aa, False, False, 0) self.chk_noise = Gtk.CheckButton(label="Gaussian Noise") vbox.pack_start(self.chk_noise, False, False, 0) self.chk_scanlines = Gtk.CheckButton(label="TV Scanlines") vbox.pack_start(self.chk_scanlines, False, False, 0) self.chk_dither = Gtk.CheckButton(label="Reduce Colors (Dither)") vbox.pack_start(self.chk_dither, False, False, 0) notebook.append_page(vbox, Gtk.Label(label="Effects")) def select_template(self, w): path = self.file_picker("Choose Background") if path: self.engine.bg_template_path = path self.lbl_bg_tmpl.set_text(os.path.basename(path)) self.update_preview() def clear_template(self, w): self.engine.bg_template_path = None self.lbl_bg_tmpl.set_text("No template") self.update_preview() def select_img(self, w): path = self.file_picker("Choose Image") if path: self.engine.img_path = path self.lbl_img.set_text(os.path.basename(path)) self.update_preview() def file_picker(self, title): dialog = Gtk.FileChooserDialog(title=title, action=Gtk.FileChooserAction.OPEN) dialog.set_transient_for(self.window) dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK) f = Gtk.FileFilter(); f.set_name("Images"); f.add_pattern("*.png"); f.add_pattern("*.jpg"); f.add_pattern("*.gif"); dialog.add_filter(f) path = None if dialog.run() == Gtk.ResponseType.OK: path = dialog.get_filename() dialog.destroy() return path def sync_ui(self): # General self.engine.bg_type = self.get_combo_text(self.cmb_bg_type) if self.engine.bg_type == "Gradient": self.engine.bg_color = self.gdk_color_to_hex(self.grad_start_btn.get_color()) else: self.engine.bg_color = self.gdk_color_to_hex(self.bg_color_btn.get_color()) self.engine.bg_color_end = self.gdk_color_to_hex(self.grad_end_btn.get_color()) # Gradient End self.engine.bg_gradient_dir = self.get_combo_text(self.cmb_grad_dir) self.engine.bg_pattern = self.get_combo_text(self.cmb_pat) self.engine.border_enabled = self.chk_border.get_active() self.engine.border_style = self.get_combo_text(self.cmb_brd_style) self.engine.border_color = self.gdk_color_to_hex(self.brd_color_btn.get_color()) # Image self.engine.img_w = int(self.spin_img_w.get_value()) self.engine.img_h = int(self.spin_img_h.get_value()) self.engine.img_x = int(self.spin_img_x.get_value()) self.engine.img_y = int(self.spin_img_y.get_value()) # Text 1 & 2 for i in [1, 2]: setattr(self.engine, f"text{i}_content", getattr(self, f"entry_t{i}").get_text()) setattr(self.engine, f"text{i}_color", self.gdk_color_to_hex(getattr(self, f"btn_col_t{i}").get_color())) setattr(self.engine, f"text{i}_size", int(getattr(self, f"spin_sz_t{i}").get_value())) setattr(self.engine, f"text{i}_x", int(getattr(self, f"spin_x_t{i}").get_value())) setattr(self.engine, f"text{i}_y", int(getattr(self, f"spin_y_t{i}").get_value())) setattr(self.engine, f"text{i}_outline_enabled", getattr(self, f"chk_out_t{i}").get_active()) setattr(self.engine, f"text{i}_outline_color", self.gdk_color_to_hex(getattr(self, f"btn_ocol_t{i}").get_color())) # Font btn = getattr(self, f"btn_font_t{i}") # Do NOT update engine font from button name directly here, # trust on_font_set or just keep what engine has. # Actually sync_ui should probably NOT overwrite font path with name from button # unless i resolve it again. But on_font_set handles it. # So skip font sync here to avoid resolving "Sans" back to "Sans" (unresolved). pass # Effects self.engine.text_antialias = self.chk_aa.get_active() self.engine.effect_noise = self.chk_noise.get_active() self.engine.effect_scanlines = self.chk_scanlines.get_active() self.engine.effect_dither = self.chk_dither.get_active() # Animations for i in [1, 2]: setattr(self.engine, f"text{i}_anim_type", self.get_combo_text(getattr(self, f"cmb_anim_t{i}"))) setattr(self.engine, f"text{i}_anim_speed", int(getattr(self, f"spin_anim_sz_t{i}").get_value())) self.engine.gif_delay = int(self.spin_gif_delay.get_value()) self.engine.gif_frames = int(self.spin_gif_frames.get_value()) def set_combo_by_text(self, combo, text): model = combo.get_model() it = model.get_iter_first() while it: if model[it][0] == text: combo.set_active_iter(it) return it = model.iter_next(it) def sync_ui_from_engine(self): # Background self.set_combo_by_text(self.cmb_bg_type, self.engine.bg_type) self.bg_color_btn.set_color(Gdk.Color.parse(self.engine.bg_color)[1]) self.grad_start_btn.set_color(Gdk.Color.parse(self.engine.bg_color)[1]) self.grad_end_btn.set_color(Gdk.Color.parse(self.engine.bg_color_end)[1]) self.set_combo_by_text(self.cmb_grad_dir, self.engine.bg_gradient_dir) self.set_combo_by_text(self.cmb_pat, self.engine.bg_pattern) self.lbl_bg_tmpl.set_text(os.path.basename(self.engine.bg_template_path) if self.engine.bg_template_path else "No template") # Border self.chk_border.set_active(self.engine.border_enabled) self.set_combo_by_text(self.cmb_brd_style, self.engine.border_style) self.brd_color_btn.set_color(Gdk.Color.parse(self.engine.border_color)[1]) # Image self.lbl_img.set_text(os.path.basename(self.engine.img_path) if self.engine.img_path else "No image") self.spin_img_w.set_value(self.engine.img_w) self.spin_img_h.set_value(self.engine.img_h) self.spin_img_x.set_value(self.engine.img_x) self.spin_img_y.set_value(self.engine.img_y) # Text 1 & 2 for i in [1, 2]: getattr(self, f"entry_t{i}").set_text(getattr(self.engine, f"text{i}_content")) getattr(self, f"btn_col_t{i}").set_color(Gdk.Color.parse(getattr(self.engine, f"text{i}_color"))[1]) getattr(self, f"spin_sz_t{i}").set_value(getattr(self.engine, f"text{i}_size")) getattr(self, f"spin_x_t{i}").set_value(getattr(self.engine, f"text{i}_x")) getattr(self, f"spin_y_t{i}").set_value(getattr(self.engine, f"text{i}_y")) getattr(self, f"chk_out_t{i}").set_active(getattr(self.engine, f"text{i}_outline_enabled")) getattr(self, f"btn_ocol_t{i}").set_color(Gdk.Color.parse(getattr(self.engine, f"text{i}_outline_color"))[1]) # Note: don't easily sync font button back unless have the Pango description string # but at least set the font button label if possible. # In Gtk2 FontButton, set_font_name works. name = getattr(self.engine, f"text{i}_font") size = getattr(self.engine, f"text{i}_size") getattr(self, f"btn_font_t{i}").set_font_name(f"{os.path.basename(name)} {size}") # Effects self.chk_aa.set_active(self.engine.text_antialias) self.chk_noise.set_active(self.engine.effect_noise) self.chk_scanlines.set_active(self.engine.effect_scanlines) self.chk_dither.set_active(self.engine.effect_dither) # Animation for i in [1, 2]: self.set_combo_by_text(getattr(self, f"cmb_anim_t{i}"), getattr(self.engine, f"text{i}_anim_type")) getattr(self, f"spin_anim_sz_t{i}").set_value(getattr(self.engine, f"text{i}_anim_speed")) self.spin_gif_delay.set_value(self.engine.gif_delay) self.spin_gif_frames.set_value(self.engine.gif_frames) # Show/Hide bg controls self.on_bg_type_changed(self.cmb_bg_type) def on_save_preset(self, w): self.sync_ui() dialog = Gtk.FileChooserDialog(title="Save Preset", action=Gtk.FileChooserAction.SAVE) dialog.set_transient_for(self.window) dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK) dialog.set_do_overwrite_confirmation(True) f = Gtk.FileFilter(); f.set_name("JSON Presets"); f.add_pattern("*.json"); dialog.add_filter(f) if dialog.run() == Gtk.ResponseType.OK: path = dialog.get_filename() if not path.endswith(".json"): path += ".json" state = self.engine.get_state() try: with open(path, 'w') as f: json.dump(state, f, indent=4) except Exception as e: print(f"Error saving preset: {e}") dialog.destroy() def on_load_preset(self, w): dialog = Gtk.FileChooserDialog(title="Load Preset", action=Gtk.FileChooserAction.OPEN) dialog.set_transient_for(self.window) dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK) f = Gtk.FileFilter(); f.set_name("JSON Presets"); f.add_pattern("*.json"); dialog.add_filter(f) if dialog.run() == Gtk.ResponseType.OK: path = dialog.get_filename() try: with open(path, 'r') as f: state = json.load(f) self.engine.set_state(state) self.sync_ui_from_engine() self.update_preview() except Exception as e: print(f"Error loading preset: {e}") dialog.destroy() def on_zoom_changed(self, combo): txt = self.get_combo_text(combo) if txt == "1x": self.zoom_level = 1 elif txt == "2x": self.zoom_level = 2 elif txt == "4x": self.zoom_level = 4 self.update_preview() def on_update_clicked(self, w): self.update_preview() def update_preview(self): self.sync_ui() # Preview filename extension depends on animation is_gif = (self.engine.text1_anim_type != "None" or self.engine.text2_anim_type != "None") out_ext = ".gif" if is_gif else ".png" preview_file = PREVIEW_FILENAME.replace(".png", out_ext) if self.engine.generate(preview_file): if is_gif: # Use PixbufAnimation for GIFs anim = GdkPixbuf.PixbufAnimation.new_from_file(preview_file) self.preview_image.set_from_animation(anim) else: pixbuf = GdkPixbuf.Pixbuf.new_from_file(preview_file) if self.zoom_level > 1: w = pixbuf.get_width() * self.zoom_level h = pixbuf.get_height() * self.zoom_level pixbuf = pixbuf.scale_simple(w, h, GdkPixbuf.InterpType.NEAREST) self.preview_image.set_from_pixbuf(pixbuf) def save_image(self, w): self.sync_ui() dialog = Gtk.FileChooserDialog(title="Save", action=Gtk.FileChooserAction.SAVE) dialog.set_transient_for(self.window) dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK) dialog.set_do_overwrite_confirmation(True) if dialog.run() == Gtk.ResponseType.OK: self.engine.generate(dialog.get_filename()) dialog.destroy() if __name__ == "__main__": app = UltimateButtonApp() Gtk.main()