import tkinter as tk from tkinter import ttk, messagebox, filedialog import tkinter.font as tkfont import math import json import os from PIL import Image, ImageDraw, ImageFont # ========================================== # Geometry & Math Helpers # ========================================== def intersect_segments(p1, p2, q1, q2): """ Finds the intersection of segment p1-p2 and segment q1-q2. Returns (x, y) if they intersect, otherwise None. """ x1, y1 = p1 x2, y2 = p2 x3, y3 = q1 x4, y4 = q2 denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1) if denom == 0: return None # Parallel or collinear ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom if 0 <= ua <= 1 and 0 <= ub <= 1: return (x1 + ua * (x2 - x1), y1 + ua * (y2 - y1)) return None def get_boundary_intersection(node, target): """ Calculates where a ray from the center of `node` to `target` (x, y) intersects the node's boundary. """ cx, cy = node.x, node.y tx, ty = target dx = tx - cx dy = ty - cy dist = math.hypot(dx, dy) if dist == 0: return (cx, cy) # Circle if node.shape == "circle": r = min(node.w, node.h) / 2 return (cx + r * dx / dist, cy + r * dy / dist) # Oval (Ellipse) elif node.shape == "oval": a = node.w / 2 b = node.h / 2 t = 1.0 / math.sqrt((dx/a)**2 + (dy/b)**2 + 1e-9) return (cx + t * dx, cy + t * dy) # Polygon Shapes: square, diamond, inverted triangle else: if node.shape == "square": vertices = [ (cx - node.w/2, cy - node.h/2), (cx + node.w/2, cy - node.h/2), (cx + node.w/2, cy + node.h/2), (cx - node.w/2, cy + node.h/2) ] elif node.shape == "diamond": vertices = [ (cx, cy - node.h/2), (cx + node.w/2, cy), (cx, cy + node.h/2), (cx - node.w/2, cy) ] elif node.shape == "inverted triangle": vertices = [ (cx - node.w/2, cy - node.h/2), (cx + node.w/2, cy - node.h/2), (cx, cy + node.h/2) ] else: return (cx, cy) # Cast a long ray from center in the direction of target to ensure we hit the boundary ray_end = (cx + 10000 * dx / dist, cy + 10000 * dy / dist) for i in range(len(vertices)): v1 = vertices[i] v2 = vertices[(i + 1) % len(vertices)] pt = intersect_segments((cx, cy), ray_end, v1, v2) if pt: return pt return (cx, cy) def get_quadratic_bezier_points(p0, p1, p2, num_steps=20): """Generates points along a quadratic Bezier curve.""" points = [] for i in range(num_steps + 1): t = i / num_steps x = (1-t)**2 * p0[0] + 2*(1-t)*t * p1[0] + t**2 * p2[0] y = (1-t)**2 * p0[1] + 2*(1-t)*t * p1[1] + t**2 * p2[1] points.append((x, y)) return points # ========================================== # Word Wrapping Helper # ========================================== def wrap_text_by_width(text, max_width, measure_fn): """Wraps text on word boundaries based on a width measurement function.""" if not text: return "" words = text.split() lines = [] current_line = [] for word in words: test_line = " ".join(current_line + [word]) if measure_fn(test_line) <= max_width: current_line.append(word) else: if current_line: lines.append(" ".join(current_line)) current_line = [word] else: lines.append(word) current_line = [] if current_line: lines.append(" ".join(current_line)) return "\n".join(lines) # ========================================== # Pillow Font Loader # ========================================== def get_pil_font(font_name, size, bold=False): """Resilient font loader for Pillow on Windows.""" win_font_dir = "C:\\Windows\\Fonts" paths = [] if bold: paths.append(os.path.join(win_font_dir, f"{font_name}bd.ttf")) paths.append(os.path.join(win_font_dir, f"{font_name}b.ttf")) paths.append(os.path.join(win_font_dir, "arialbd.ttf")) else: paths.append(os.path.join(win_font_dir, f"{font_name}.ttf")) paths.append(os.path.join(win_font_dir, "arial.ttf")) for p in paths: if os.path.exists(p): try: return ImageFont.truetype(p, size) except Exception: pass return ImageFont.load_default() # ========================================== # Model Classes # ========================================== class Node: def __init__(self, designation, shape, description="", x=100.0, y=100.0): self.id = designation.strip() self.shape = shape.lower() # diamond, inverted triangle, square, circle, oval self.description = description.strip() self.x = float(x) self.y = float(y) self.w, self.h = self.default_sizes() def default_sizes(self): # Choose default dimensions based on shape to fit text neatly if self.shape == "circle": return 80.0, 80.0 elif self.shape == "oval": return 120.0, 60.0 elif self.shape == "square": return 110.0, 70.0 elif self.shape == "diamond": return 120.0, 90.0 elif self.shape == "inverted triangle": return 120.0, 90.0 return 100.0, 60.0 def get_vertices(self): cx, cy, w, h = self.x, self.y, self.w, self.h if self.shape == "square": return [ (cx - w/2, cy - h/2), (cx + w/2, cy - h/2), (cx + w/2, cy + h/2), (cx - w/2, cy + h/2) ] elif self.shape == "diamond": return [ (cx, cy - h/2), (cx + w/2, cy), (cx, cy + h/2), (cx - w/2, cy) ] elif self.shape == "inverted triangle": return [ (cx - w/2, cy - h/2), (cx + w/2, cy - h/2), (cx, cy + h/2) ] return [] def get_max_text_width(self): # Margins to prevent text touching borders if self.shape == "circle": return self.w * 0.70 elif self.shape == "oval": return self.w * 0.75 elif self.shape == "square": return self.w * 0.82 elif self.shape == "diamond": return self.w * 0.58 elif self.shape == "inverted triangle": return self.w * 0.62 return self.w * 0.8 def to_dict(self): return { "id": self.id, "shape": self.shape, "description": self.description, "x": self.x, "y": self.y } @classmethod def from_dict(cls, d): return cls(d["id"], d["shape"], d.get("description", ""), d["x"], d["y"]) class Edge: def __init__(self, u, v, style="straight"): self.u = u.strip() # From node ID self.v = v.strip() # To node ID self.style = style.lower() # straight, curved, dotted def to_dict(self): return { "u": self.u, "v": self.v, "style": self.style } @classmethod def from_dict(cls, d): return cls(d["u"], d["v"], d["style"]) class FlowchartModel: def __init__(self): self.nodes = {} # ID -> Node self.edges = [] # List of Edge objects def add_node(self, designation, shape, description="", x=100.0, y=100.0): designation = designation.strip() if not designation: return False, "Node designation cannot be empty." node = Node(designation, shape, description, x, y) self.nodes[designation] = node return True, node def delete_node(self, designation): designation = designation.strip() if designation in self.nodes: del self.nodes[designation] # Remove associated edges self.edges = [e for e in self.edges if e.u != designation and e.v != designation] return True return False def add_edge_path(self, path_str, style="straight"): """ Parses paths like 'A -> B -> C' or 'A, B' or 'A - B' and adds consecutive pairs as edges of the given style. """ # Determine delimiters and split if "->" in path_str or "-->" in path_str: s = path_str.replace("-->", "->") parts = [p.strip() for p in s.split("->") if p.strip()] elif "," in path_str: parts = [p.strip() for p in path_str.split(",") if p.strip()] else: parts = [p.strip() for p in path_str.split() if p.strip()] if len(parts) < 2: return False, "Invalid path. Enter at least two nodes (e.g. A -> B)." # Verify all nodes exist missing = [p for p in parts if p not in self.nodes] if missing: return False, f"Missing nodes: {', '.join(missing)}. Create them first." added_count = 0 for i in range(len(parts) - 1): u, v = parts[i], parts[i+1] # Avoid duplicate edges of the same direction exists = any(e.u == u and e.v == v for e in self.edges) if not exists: self.edges.append(Edge(u, v, style)) added_count += 1 return True, f"Added {added_count} edge(s)." def remove_edge(self, index): if 0 <= index < len(self.edges): self.edges.pop(index) return True return False def clear(self): self.nodes.clear() self.edges.clear() def auto_layout(self): """ Calculates a clean vertical hierarchical (layered) layout for the nodes. """ if not self.nodes: return # 1. Build adjacency list and compute in-degrees adj = {name: [] for name in self.nodes} in_degree = {name: 0 for name in self.nodes} for e in self.edges: if e.u in adj and e.v in adj: adj[e.u].append(e.v) in_degree[e.v] += 1 # 2. Layer nodes using simple BFS-like sorting levels = {} queue = [] # Start nodes (in-degree = 0) for name in self.nodes: if in_degree[name] == 0: levels[name] = 0 queue.append(name) # Handle cycles/disconnected loops (if no 0-in-degree nodes exist) if not queue: first_node = list(self.nodes.keys())[0] levels[first_node] = 0 queue.append(first_node) visited = set() while queue: curr = queue.pop(0) visited.add(curr) curr_level = levels.get(curr, 0) for neighbor in adj[curr]: old_lvl = levels.get(neighbor, -1) # Elevate level if current path is deeper if curr_level + 1 > old_lvl: levels[neighbor] = curr_level + 1 if neighbor not in visited and neighbor not in queue: queue.append(neighbor) # Catch any remaining nodes (e.g. disconnected nodes in cycles) for name in self.nodes: if name not in levels: levels[name] = 0 # 3. Group by level nodes_by_level = {} for name, lvl in levels.items(): nodes_by_level.setdefault(lvl, []).append(name) # 4. Assign Coordinates # Canvas defaults: center is ~400 canvas_width = 800 level_height = 140 horizontal_spacing = 150 for lvl, lvl_nodes in sorted(nodes_by_level.items()): lvl_nodes.sort() # Alphabetical for layout stability num_nodes = len(lvl_nodes) y = 100 + lvl * level_height for i, name in enumerate(lvl_nodes): x = (canvas_width / 2) + (i - (num_nodes - 1) / 2) * horizontal_spacing node = self.nodes[name] node.x = x node.y = y def save_to_file(self, filepath): data = { "nodes": [n.to_dict() for n in self.nodes.values()], "edges": [e.to_dict() for e in self.edges] } with open(filepath, "w") as f: json.dump(data, f, indent=4) def load_from_file(self, filepath): with open(filepath, "r") as f: data = json.load(f) self.clear() for n_dict in data.get("nodes", []): node = Node.from_dict(n_dict) self.nodes[node.id] = node for e_dict in data.get("edges", []): self.edges.append(Edge.from_dict(e_dict)) # ========================================== # Canvas GUI Widget # ========================================== class FlowchartCanvas(tk.Frame): def __init__(self, parent, model, select_callback=None): super().__init__(parent) self.model = model self.select_callback = select_callback self.selected_node_id = None self.dragged_node = None self.drag_offset_x = 0 self.drag_offset_y = 0 # Scrollbars and canvas self.canvas = tk.Canvas(self, bg="white", borderwidth=0, highlightthickness=0) self.hbar = tk.Scrollbar(self, orient=tk.HORIZONTAL, command=self.canvas.xview) self.vbar = tk.Scrollbar(self, orient=tk.VERTICAL, command=self.canvas.yview) self.canvas.config(xscrollcommand=self.hbar.set, yscrollcommand=self.vbar.set) self.grid(row=0, column=0, sticky="nsew") self.canvas.grid(row=0, column=0, sticky="nsew") self.vbar.grid(row=0, column=1, sticky="ns") self.hbar.grid(row=1, column=0, sticky="ew") self.rowconfigure(0, weight=1) self.columnconfigure(0, weight=1) # Grid settings self.canvas.config(scrollregion=(0, 0, 1500, 1200)) # Fonts self.font_bold = ("Segoe UI", 10, "bold") self.font_reg = ("Segoe UI", 9) # Event Bindings self.canvas.bind("", self.on_press) self.canvas.bind("", self.on_drag) self.canvas.bind("", self.on_release) # Initial draw self.redraw() def update_scroll_region(self): if not self.model.nodes: self.canvas.config(scrollregion=(0, 0, 1200, 900)) return xs = [n.x for n in self.model.nodes.values()] ys = [n.y for n in self.model.nodes.values()] min_x = min(xs) - 150 max_x = max(xs) + 150 min_y = min(ys) - 150 max_y = max(ys) + 150 min_x = min(0, min_x) min_y = min(0, min_y) max_x = max(1200, max_x) max_y = max(900, max_y) self.canvas.config(scrollregion=(min_x, min_y, max_x, max_y)) def on_press(self, event): # Convert window event coordinates to canvas coordinates (taking scrolling into account) cx = self.canvas.canvasx(event.x) cy = self.canvas.canvasy(event.y) # Check if clicked on a node clicked_node = None for node in self.model.nodes.values(): if (node.x - node.w/2 <= cx <= node.x + node.w/2 and node.y - node.h/2 <= cy <= node.y + node.h/2): clicked_node = node break if clicked_node: self.selected_node_id = clicked_node.id self.dragged_node = clicked_node self.drag_offset_x = cx - clicked_node.x self.drag_offset_y = cy - clicked_node.y if self.select_callback: self.select_callback(clicked_node.id) else: self.selected_node_id = None if self.select_callback: self.select_callback(None) self.redraw() def on_drag(self, event): if self.dragged_node: cx = self.canvas.canvasx(event.x) cy = self.canvas.canvasy(event.y) # Drag node center relative to cursor offset self.dragged_node.x = cx - self.drag_offset_x self.dragged_node.y = cy - self.drag_offset_y self.redraw() def on_release(self, event): self.dragged_node = None self.update_scroll_region() def draw_arrowhead(self, p_end, p_from, canvas_line_id=None): """Draws a clean, custom filled B&W arrowhead pointing at p_end.""" xe, ye = p_end xf, yf = p_from dx = xe - xf dy = ye - yf dist = math.hypot(dx, dy) if dist == 0: return ux = dx / dist uy = dy / dist arrow_length = 12 arrow_width = 8 bx = xe - ux * arrow_length by = ye - uy * arrow_length px = -uy * (arrow_width / 2) py = ux * (arrow_width / 2) pts = [xe, ye, bx + px, by + py, bx - px, by - py] self.canvas.create_polygon(pts, fill="#212529", outline="#212529") def redraw(self): self.canvas.delete("all") # 1. Draw Grid Lines region = self.canvas.cget("scrollregion") if region: _, _, r_w, r_h = map(float, region.split()) else: r_w, r_h = 1200, 900 for x in range(0, int(r_w), 45): self.canvas.create_line(x, 0, x, r_h, fill="#f1f3f5", dash=(2, 4)) for y in range(0, int(r_h), 45): self.canvas.create_line(0, y, r_w, y, fill="#f1f3f5", dash=(2, 4)) # Measure function using Tkinter's Font class tk_font_reg = tkfont.Font(family="Segoe UI", size=9) measure_fn = lambda txt: tk_font_reg.measure(txt) # 2. Draw Edges for edge in self.model.edges: u_node = self.model.nodes.get(edge.u) v_node = self.model.nodes.get(edge.v) if not u_node or not v_node: continue # Line style properties dash_pattern = (4, 4) if edge.style == "dotted" else None if edge.style == "curved": # Compute control point offset from midpoint dx = v_node.x - u_node.x dy = v_node.y - u_node.y dist = math.hypot(dx, dy) mx = (u_node.x + v_node.x) / 2 my = (u_node.y + v_node.y) / 2 # Curvature vector if dist > 0: nx = -dy / dist ny = dx / dist p_ctrl = (mx + nx * 45, my + ny * 45) else: p_ctrl = (mx, my + 45) # Calculate boundary intersections towards the control point p_start = get_boundary_intersection(u_node, p_ctrl) p_end = get_boundary_intersection(v_node, p_ctrl) # Generate points along Bezier curve bezier_pts = get_quadratic_bezier_points(p_start, p_ctrl, p_end, 25) flat_coords = [] for pt in bezier_pts: flat_coords.extend(pt) # Draw curved segments self.canvas.create_line(*flat_coords, fill="#212529", width=2, dash=dash_pattern) # Arrowhead points tangent to the curve end (direction: p_end - p_ctrl) self.draw_arrowhead(p_end, p_ctrl) else: # Straight / Dotted line p_start = get_boundary_intersection(u_node, (v_node.x, v_node.y)) p_end = get_boundary_intersection(v_node, (u_node.x, u_node.y)) self.canvas.create_line(p_start[0], p_start[1], p_end[0], p_end[1], fill="#212529", width=2, dash=dash_pattern) self.draw_arrowhead(p_end, p_start) # 3. Draw Nodes for node in self.model.nodes.values(): cx, cy, w, h = node.x, node.y, node.w, node.h # --- Draw Drop Shadow (Light gray, offset 4px) --- sh_offset = 4 scx, scy = cx + sh_offset, cy + sh_offset if node.shape in ("circle", "oval"): self.canvas.create_oval(scx - w/2, scy - h/2, scx + w/2, scy + h/2, fill="#e9ecef", outline="") elif node.shape == "square": self.canvas.create_rectangle(scx - w/2, scy - h/2, scx + w/2, scy + h/2, fill="#e9ecef", outline="") else: sh_vertices = [] for vx, vy in node.get_vertices(): sh_vertices.extend([vx + sh_offset, vy + sh_offset]) self.canvas.create_polygon(sh_vertices, fill="#e9ecef", outline="") # --- Draw Main Node Shape --- outline_color = "#212529" bg_color = "#ffffff" if node.shape in ("circle", "oval"): self.canvas.create_oval(cx - w/2, cy - h/2, cx + w/2, cy + h/2, fill=bg_color, outline=outline_color, width=2) elif node.shape == "square": self.canvas.create_rectangle(cx - w/2, cy - h/2, cx + w/2, cy + h/2, fill=bg_color, outline=outline_color, width=2) else: vertices = [] for vx, vy in node.get_vertices(): vertices.extend([vx, vy]) self.canvas.create_polygon(vertices, fill=bg_color, outline=outline_color, width=2) # --- Draw Selected Highlight (Dashed boundary box) --- if node.id == self.selected_node_id: self.canvas.create_rectangle(cx - w/2 - 4, cy - h/2 - 4, cx + w/2 + 4, cy + h/2 + 4, outline="#4dabf7", width=1.5, dash=(2, 2)) # --- Draw Text --- max_w = node.get_max_text_width() if node.description: # Text wrapping on description desc_wrapped = wrap_text_by_width(node.description, max_w, measure_fn) # Draw Designation (Bold, centered slightly higher) self.canvas.create_text(cx, cy - 12, text=node.id, font=self.font_bold, fill="#212529", anchor="center") # Draw Description (Regular, centered slightly lower) self.canvas.create_text(cx, cy + 10, text=desc_wrapped, font=self.font_reg, fill="#495057", anchor="center") else: # Designation only (Perfect center) self.canvas.create_text(cx, cy, text=node.id, font=self.font_bold, fill="#212529", anchor="center") # ========================================== # Main App Controller GUI # ========================================== class FlowchartApp(tk.Tk): def __init__(self): super().__init__() self.title("B&W Flowchart Sketcher") self.geometry("1150x720") self.configure(bg="#f8f9fa") # Initialize model self.model = FlowchartModel() # Styles config self.style = ttk.Style() self.style.theme_use("clam") # Set primary palette (Clean Slate) self.style.configure(".", background="#f8f9fa", foreground="#212529", font=("Segoe UI", 9)) self.style.configure("TLabel", foreground="#343a40", font=("Segoe UI", 9, "bold")) self.style.configure("TButton", background="#e9ecef", foreground="#212529", font=("Segoe UI", 9, "bold"), borderwidth=1) self.style.map("TButton", background=[("active", "#dfe2e6"), ("pressed", "#ced4da")], foreground=[("active", "#212529")]) self.style.configure("Primary.TButton", background="#212529", foreground="#ffffff", borderwidth=0) self.style.map("Primary.TButton", background=[("active", "#343a40"), ("pressed", "#495057")], foreground=[("active", "#ffffff")]) # Create Layout self.setup_ui() # Load sample flowchart self.load_samples() def setup_ui(self): # Master grid setup self.rowconfigure(0, weight=1) self.columnconfigure(1, weight=1) # 1. Left Sidebar Panel (Inputs and lists) self.sidebar = tk.Frame(self, bg="#ffffff", bd=1, relief=tk.SOLID, width=330) self.sidebar.grid(row=0, column=0, sticky="nsw", padx=10, pady=10) self.sidebar.pack_propagate(False) # Scrollable Sidebar Frame to handle smaller resolutions canvas_sidebar = tk.Canvas(self.sidebar, bg="#ffffff", borderwidth=0, highlightthickness=0) scrollbar_sidebar = ttk.Scrollbar(self.sidebar, orient=tk.VERTICAL, command=canvas_sidebar.yview) self.sidebar_content = tk.Frame(canvas_sidebar, bg="#ffffff") canvas_sidebar.create_window((0, 0), window=self.sidebar_content, anchor="nw") canvas_sidebar.configure(yscrollcommand=scrollbar_sidebar.set) scrollbar_sidebar.pack(side=tk.RIGHT, fill=tk.Y) canvas_sidebar.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # Bind scrolling size self.sidebar_content.bind("", lambda e: canvas_sidebar.configure(scrollregion=canvas_sidebar.bbox("all"))) # Padding inside content padx_c, pady_c = 15, 6 # --- Node Editor Section --- lbl_nodes_title = tk.Label(self.sidebar_content, text="NODE MANAGER", font=("Segoe UI", 11, "bold"), bg="#ffffff", fg="#212529") lbl_nodes_title.pack(anchor="w", padx=padx_c, pady=(15, 5)) # Node Designation (ID) lbl_node_id = ttk.Label(self.sidebar_content, text="Designation / Label (Short ID):", background="#ffffff") lbl_node_id.pack(anchor="w", padx=padx_c, pady=pady_c) self.entry_node_id = ttk.Entry(self.sidebar_content, width=28) self.entry_node_id.pack(anchor="w", padx=padx_c) # Node Shape lbl_shape = ttk.Label(self.sidebar_content, text="Icon Shape:", background="#ffffff") lbl_shape.pack(anchor="w", padx=padx_c, pady=pady_c) self.combo_shape = ttk.Combobox(self.sidebar_content, values=["Square", "Circle", "Oval", "Diamond", "Inverted Triangle"], state="readonly", width=26) self.combo_shape.set("Square") self.combo_shape.pack(anchor="w", padx=padx_c) # Node Description lbl_desc = ttk.Label(self.sidebar_content, text="Description (Inside Shape):", background="#ffffff") lbl_desc.pack(anchor="w", padx=padx_c, pady=pady_c) self.entry_desc = ttk.Entry(self.sidebar_content, width=28) self.entry_desc.pack(anchor="w", padx=padx_c) # Node Action Buttons btn_frame_node = tk.Frame(self.sidebar_content, bg="#ffffff") btn_frame_node.pack(anchor="w", padx=padx_c, pady=(10, 10)) self.btn_add_node = ttk.Button(btn_frame_node, text="Add/Save Node", style="Primary.TButton", command=self.add_or_update_node) self.btn_add_node.grid(row=0, column=0, padx=(0, 5)) self.btn_delete_node = ttk.Button(btn_frame_node, text="Delete Node", command=self.delete_node) self.btn_delete_node.grid(row=0, column=1) # Divider Line ttk.Separator(self.sidebar_content, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=padx_c, pady=10) # --- Edge Editor Section --- lbl_edges_title = tk.Label(self.sidebar_content, text="EDGE MANAGER", font=("Segoe UI", 11, "bold"), bg="#ffffff", fg="#212529") lbl_edges_title.pack(anchor="w", padx=padx_c, pady=(5, 5)) # Edge sequence description lbl_edge_path = ttk.Label(self.sidebar_content, text="Path (e.g. A -> B -> C):", background="#ffffff") lbl_edge_path.pack(anchor="w", padx=padx_c, pady=pady_c) self.entry_edge_path = ttk.Entry(self.sidebar_content, width=28) self.entry_edge_path.pack(anchor="w", padx=padx_c) # Edge style lbl_edge_style = ttk.Label(self.sidebar_content, text="Connector Line Style:", background="#ffffff") lbl_edge_style.pack(anchor="w", padx=padx_c, pady=pady_c) self.combo_edge_style = ttk.Combobox(self.sidebar_content, values=["Straight", "Curved", "Dotted"], state="readonly", width=26) self.combo_edge_style.set("Straight") self.combo_edge_style.pack(anchor="w", padx=padx_c) # Edge Action Buttons btn_frame_edge = tk.Frame(self.sidebar_content, bg="#ffffff") btn_frame_edge.pack(anchor="w", padx=padx_c, pady=(10, 10)) btn_add_edge = ttk.Button(btn_frame_edge, text="Add Edges", style="Primary.TButton", command=self.add_edges) btn_add_edge.grid(row=0, column=0, padx=(0, 5)) # Divider Line ttk.Separator(self.sidebar_content, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=padx_c, pady=10) # --- List Review & Clear --- lbl_list_title = tk.Label(self.sidebar_content, text="EDGE LIST", font=("Segoe UI", 10, "bold"), bg="#ffffff", fg="#495057") lbl_list_title.pack(anchor="w", padx=padx_c, pady=(5, 2)) # Listbox for edges self.edge_listbox = tk.Listbox(self.sidebar_content, width=30, height=5, font=("Segoe UI", 9), relief=tk.SOLID, borderwidth=1) self.edge_listbox.pack(anchor="w", padx=padx_c, pady=pady_c) btn_delete_edge = ttk.Button(self.sidebar_content, text="Delete Selected Edge", command=self.delete_edge) btn_delete_edge.pack(anchor="w", padx=padx_c, pady=(2, 10)) # --- Project Operations --- ttk.Separator(self.sidebar_content, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=padx_c, pady=10) lbl_ops_title = tk.Label(self.sidebar_content, text="ACTIONS", font=("Segoe UI", 11, "bold"), bg="#ffffff", fg="#212529") lbl_ops_title.pack(anchor="w", padx=padx_c, pady=(5, 5)) # Grid layout for bottom action buttons actions_frame = tk.Frame(self.sidebar_content, bg="#ffffff") actions_frame.pack(anchor="w", padx=padx_c, pady=(5, 20)) btn_auto_layout = ttk.Button(actions_frame, text="Auto-Layout", command=self.run_auto_layout) btn_auto_layout.grid(row=0, column=0, padx=2, pady=2, sticky="ew") btn_clear = ttk.Button(actions_frame, text="Clear All", command=self.clear_all) btn_clear.grid(row=0, column=1, padx=2, pady=2, sticky="ew") btn_save = ttk.Button(actions_frame, text="Save Project", command=self.save_project) btn_save.grid(row=1, column=0, padx=2, pady=2, sticky="ew") btn_load = ttk.Button(actions_frame, text="Load Project", command=self.load_project) btn_load.grid(row=1, column=1, padx=2, pady=2, sticky="ew") btn_export = ttk.Button(actions_frame, text="Export PNG", style="Primary.TButton", command=self.export_png) btn_export.grid(row=2, column=0, columnspan=2, padx=2, pady=5, sticky="ew") # 2. Right Canvas Panel self.canvas_panel = tk.Frame(self, bg="#ffffff", bd=1, relief=tk.SOLID) self.canvas_panel.grid(row=0, column=1, sticky="nsew", padx=(0, 10), pady=10) self.canvas_panel.rowconfigure(0, weight=1) self.canvas_panel.columnconfigure(0, weight=1) # Create Flowchart Canvas widget self.f_canvas = FlowchartCanvas(self.canvas_panel, self.model, select_callback=self.on_canvas_select) def on_canvas_select(self, node_id): """Callback when a node is clicked on the canvas.""" if node_id: node = self.model.nodes.get(node_id) if node: # Load fields self.entry_node_id.delete(0, tk.END) self.entry_node_id.insert(0, node.id) self.combo_shape.set(node.shape.title()) self.entry_desc.delete(0, tk.END) self.entry_desc.insert(0, node.description) self.btn_add_node.config(text="Save Node") else: # Clear fields self.entry_node_id.delete(0, tk.END) self.combo_shape.set("Square") self.entry_desc.delete(0, tk.END) self.btn_add_node.config(text="Add Node") def add_or_update_node(self): node_id = self.entry_node_id.get().strip() shape = self.combo_shape.get().strip() desc = self.entry_desc.get().strip() if not node_id: messagebox.showwarning("Input Error", "Node Designation (ID) is required.") return is_update = node_id in self.model.nodes if is_update: # Update existing node attributes node = self.model.nodes[node_id] node.shape = shape.lower() node.description = desc node.w, node.h = node.default_sizes() # update size if shape changed else: # Create a new node in center of current screen # Calculate approx canvas center coordinates cx = self.f_canvas.canvas.canvasx(self.f_canvas.canvas.winfo_width() / 2) cy = self.f_canvas.canvas.canvasy(self.f_canvas.canvas.winfo_height() / 2) # Avoid placing directly on top of each other if not cx or cx < 100: cx = 200 if not cy or cy < 100: cy = 200 success, node = self.model.add_node(node_id, shape, desc, x=cx, y=cy) if not success: messagebox.showerror("Error", node) return self.f_canvas.redraw() self.f_canvas.update_scroll_region() self.refresh_edge_listbox() # Reset input form self.on_canvas_select(None) def delete_node(self): node_id = self.entry_node_id.get().strip() if not node_id: messagebox.showwarning("Select Node", "Select or type a node designation to delete.") return if node_id in self.model.nodes: confirm = messagebox.askyesno("Delete Node", f"Are you sure you want to delete node '{node_id}' and all connecting edges?") if confirm: self.model.delete_node(node_id) self.f_canvas.selected_node_id = None self.f_canvas.redraw() self.f_canvas.update_scroll_region() self.refresh_edge_listbox() self.on_canvas_select(None) else: messagebox.showerror("Not Found", f"Node '{node_id}' does not exist.") def add_edges(self): path = self.entry_edge_path.get().strip() style = self.combo_edge_style.get().lower() if not path: messagebox.showwarning("Input Error", "Please specify an edge path (e.g. A -> B).") return success, msg = self.model.add_edge_path(path, style) if success: self.entry_edge_path.delete(0, tk.END) self.f_canvas.redraw() self.refresh_edge_listbox() else: messagebox.showerror("Error", msg) def delete_edge(self): selected_idx = self.edge_listbox.curselection() if not selected_idx: messagebox.showwarning("Select Edge", "Select an edge from the Edge List to delete.") return idx = selected_idx[0] if self.model.remove_edge(idx): self.f_canvas.redraw() self.refresh_edge_listbox() def refresh_edge_listbox(self): self.edge_listbox.delete(0, tk.END) for i, edge in enumerate(self.model.edges): self.edge_listbox.insert(tk.END, f"{edge.u} -> {edge.v} ({edge.style.title()})") def run_auto_layout(self): if not self.model.nodes: return self.model.auto_layout() self.f_canvas.redraw() self.f_canvas.update_scroll_region() def clear_all(self): if messagebox.askyesno("Clear All", "Delete all nodes and edges from the workspace?"): self.model.clear() self.f_canvas.selected_node_id = None self.f_canvas.redraw() self.f_canvas.update_scroll_region() self.refresh_edge_listbox() self.on_canvas_select(None) def save_project(self): filename = filedialog.asksaveasfilename( defaultextension=".json", filetypes=[("JSON files", "*.json"), ("All Files", "*.*")], title="Save Flowchart Project" ) if filename: try: self.model.save_to_file(filename) messagebox.showinfo("Saved", "Project saved successfully.") except Exception as e: messagebox.showerror("Error", f"Could not save project: {e}") def load_project(self): filename = filedialog.askopenfilename( filetypes=[("JSON files", "*.json"), ("All Files", "*.*")], title="Load Flowchart Project" ) if filename: try: self.model.load_from_file(filename) self.f_canvas.selected_node_id = None self.f_canvas.redraw() self.f_canvas.update_scroll_region() self.refresh_edge_listbox() self.on_canvas_select(None) messagebox.showinfo("Loaded", "Project loaded successfully.") except Exception as e: messagebox.showerror("Error", f"Could not load project: {e}") def load_samples(self): # Create a beautiful sample flowchart to show on startup self.model.add_node("Start", "Circle", "Start", 400.0, 80.0) self.model.add_node("Step 1", "Square", "Process Inputs", 400.0, 200.0) self.model.add_node("Check", "Diamond", "Valid?", 400.0, 330.0) self.model.add_node("Error", "Inverted Triangle", "Log Error", 220.0, 330.0) self.model.add_node("End", "Oval", "Success Finish", 400.0, 460.0) self.model.add_edge_path("Start -> Step 1", "straight") self.model.add_edge_path("Step 1 -> Check", "straight") self.model.add_edge_path("Check -> Error", "straight") self.model.add_edge_path("Check -> End", "straight") self.model.add_edge_path("Error -> Start", "curved") # curve looping back self.f_canvas.redraw() self.f_canvas.update_scroll_region() self.refresh_edge_listbox() # ========================================== # PIL Render & Export PNG # ========================================== def export_png(self): if not self.model.nodes: messagebox.showwarning("Empty Project", "No nodes to export.") return filename = filedialog.asksaveasfilename( defaultextension=".png", filetypes=[("PNG files", "*.png"), ("All Files", "*.*")], title="Export Flowchart to Image" ) if not filename: return try: # 1. Determine bounding box with padding xs = [n.x for n in self.model.nodes.values()] ys = [n.y for n in self.model.nodes.values()] # Find bounds using outer coordinates min_x = min(n.x - n.w/2 for n in self.model.nodes.values()) max_x = max(n.x + n.w/2 for n in self.model.nodes.values()) min_y = min(n.y - n.h/2 for n in self.model.nodes.values()) max_y = max(n.y + n.h/2 for n in self.model.nodes.values()) margin = 50.0 crop_x1 = min_x - margin crop_y1 = min_y - margin crop_x2 = max_x + margin crop_y2 = max_y + margin img_w = int(crop_x2 - crop_x1) img_h = int(crop_y2 - crop_y1) # Create white Pillow canvas img = Image.new("RGB", (img_w, img_h), "white") draw = ImageDraw.Draw(img) # Helper to map global coordinates to cropped coordinates def get_pt(cx, cy): return int(cx - crop_x1), int(cy - crop_y1) # Load clean fonts font_bold = get_pil_font("segoeui", 10, bold=True) font_reg = get_pil_font("segoeui", 9, bold=False) # Define measurement helper for PIL wrapping if hasattr(draw, 'textlength'): measure_fn = lambda txt: draw.textlength(txt, font=font_reg) else: measure_fn = lambda txt: font_reg.getsize(txt)[0] # Helper to draw PIL custom arrow def draw_pil_arrowhead(p_end, p_from): xe, ye = get_pt(p_end[0], p_end[1]) xf, yf = get_pt(p_from[0], p_from[1]) dx = xe - xf dy = ye - yf dist = math.hypot(dx, dy) if dist == 0: return ux = dx / dist uy = dy / dist arrow_l = 12 arrow_w = 8 bx = xe - ux * arrow_l by = ye - uy * arrow_l px = -uy * (arrow_w / 2) py = ux * (arrow_w / 2) pts = [(xe, ye), (bx + px, by + py), (bx - px, by - py)] draw.polygon(pts, fill="black", outline="black") # Helper to draw centered multiline text in PIL def draw_centered_text_pil(xy, text, font, fill="black"): lines = text.split("\n") # Get a constant line height for the font using a representative string try: bbox = font.getbbox("Abgqp") line_h = bbox[3] - bbox[1] except: line_h = font.getsize("Abgqp")[1] line_widths = [] for line in lines: if hasattr(draw, 'textlength'): w = draw.textlength(line, font=font) else: w = font.getsize(line)[0] line_widths.append(w) total_height = line_h * len(lines) + 4 * (len(lines) - 1) curr_y = xy[1] - total_height / 2 for i, line in enumerate(lines): w = line_widths[i] draw.text((xy[0] - w/2, curr_y), line, font=font, fill=fill) curr_y += line_h + 4 # 2. Draw Edges first (so they render behind nodes) for edge in self.model.edges: u_node = self.model.nodes.get(edge.u) v_node = self.model.nodes.get(edge.v) if not u_node or not v_node: continue # Curved Line if edge.style == "curved": dx = v_node.x - u_node.x dy = v_node.y - u_node.y dist = math.hypot(dx, dy) mx = (u_node.x + v_node.x) / 2 my = (u_node.y + v_node.y) / 2 if dist > 0: nx = -dy / dist ny = dx / dist p_ctrl = (mx + nx * 45, my + ny * 45) else: p_ctrl = (mx, my + 45) p_start = get_boundary_intersection(u_node, p_ctrl) p_end = get_boundary_intersection(v_node, p_ctrl) bezier_pts = get_quadratic_bezier_points(p_start, p_ctrl, p_end, 30) mapped_pts = [get_pt(px, py) for px, py in bezier_pts] if edge.style == "dotted": draw_dashed_line_pil(draw, mapped_pts, fill="black", width=2, dash_len=4, gap_len=4) else: draw.line(mapped_pts, fill="black", width=2) draw_pil_arrowhead(p_end, p_ctrl) # Straight or Dotted Line else: p_start = get_boundary_intersection(u_node, (v_node.x, v_node.y)) p_end = get_boundary_intersection(v_node, (u_node.x, u_node.y)) p_start_m = get_pt(p_start[0], p_start[1]) p_end_m = get_pt(p_end[0], p_end[1]) if edge.style == "dotted": # interpolate a few points to draw dashed line steps = int(math.hypot(p_end_m[0] - p_start_m[0], p_end_m[1] - p_start_m[1]) / 6) if steps < 2: steps = 2 pts = [] for s in range(steps + 1): t = s / steps pts.append((p_start_m[0] + t * (p_end_m[0] - p_start_m[0]), p_start_m[1] + t * (p_end_m[1] - p_start_m[1]))) draw_dashed_line_pil(draw, pts, fill="black", width=2, dash_len=4, gap_len=4) else: draw.line([p_start_m, p_end_m], fill="black", width=2) draw_pil_arrowhead(p_end, p_start) # 3. Draw Nodes for node in self.model.nodes.values(): cx, cy, w, h = node.x, node.y, node.w, node.h # Bounding coordinates in PIL layout px, py = get_pt(cx, cy) # --- Drop Shadow --- sh = 4 shadow_color = (233, 236, 239) # #e9ecef if node.shape in ("circle", "oval"): draw.ellipse([px - w/2 + sh, py - h/2 + sh, px + w/2 + sh, py + h/2 + sh], fill=shadow_color, outline=None) elif node.shape == "square": draw.rectangle([px - w/2 + sh, py - h/2 + sh, px + w/2 + sh, py + h/2 + sh], fill=shadow_color, outline=None) else: sh_vertices = [(vx - crop_x1 + sh, vy - crop_y1 + sh) for vx, vy in node.get_vertices()] draw.polygon(sh_vertices, fill=shadow_color, outline=None) # --- Main Shape --- if node.shape in ("circle", "oval"): draw.ellipse([px - w/2, py - h/2, px + w/2, py + h/2], fill="white", outline="black", width=2) elif node.shape == "square": draw.rectangle([px - w/2, py - h/2, px + w/2, py + h/2], fill="white", outline="black", width=2) else: sh_vertices = [(vx - crop_x1, vy - crop_y1) for vx, vy in node.get_vertices()] # Draw filled shape draw.polygon(sh_vertices, fill="white") # Draw thick border (polygon outlines don't support custom width in older Pillow) draw.line(sh_vertices + [sh_vertices[0]], fill="black", width=2, joint="curve") # --- Text Content --- max_w = node.get_max_text_width() if node.description: desc_wrapped = wrap_text_by_width(node.description, max_w, measure_fn) # Designation (Bold, centered slightly higher) draw_centered_text_pil((px, py - 12), node.id, font_bold, fill="black") # Description (Regular, centered slightly lower) draw_centered_text_pil((px, py + 10), desc_wrapped, font_reg, fill=(73, 80, 87)) else: # Designation only draw_centered_text_pil((px, py), node.id, font_bold, fill="black") # Save PIL Image img.save(filename, "PNG") messagebox.showinfo("Export Success", f"Flowchart exported successfully to:\n{filename}") except Exception as e: messagebox.showerror("Export Failed", f"Could not export flowchart: {e}") def draw_dashed_line_pil(draw, points, fill="black", width=2, dash_len=6, gap_len=6): """Walks along a list of connected points and draws dashes in PIL.""" if len(points) < 2: return current_dash_left = dash_len current_gap_left = 0 drawing = True for i in range(len(points) - 1): p1 = points[i] p2 = points[i+1] seg_dx = p2[0] - p1[0] seg_dy = p2[1] - p1[1] seg_len = math.hypot(seg_dx, seg_dy) if seg_len == 0: continue vx = seg_dx / seg_len vy = seg_dy / seg_len dist_moved = 0 curr_pt = p1 while dist_moved < seg_len: if drawing: step = min(seg_len - dist_moved, current_dash_left) next_pt = (curr_pt[0] + vx * step, curr_pt[1] + vy * step) draw.line([curr_pt, next_pt], fill=fill, width=width) dist_moved += step current_dash_left -= step curr_pt = next_pt if current_dash_left <= 0: drawing = False current_gap_left = gap_len else: step = min(seg_len - dist_moved, current_gap_left) next_pt = (curr_pt[0] + vx * step, curr_pt[1] + vy * step) dist_moved += step current_gap_left -= step curr_pt = next_pt if current_gap_left <= 0: drawing = True current_dash_left = dash_len if __name__ == "__main__": app = FlowchartApp() app.mainloop()