Spaces:
Sleeping
Sleeping
| 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 | |
| } | |
| 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 | |
| } | |
| 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("<Button-1>", self.on_press) | |
| self.canvas.bind("<B1-Motion>", self.on_drag) | |
| self.canvas.bind("<ButtonRelease-1>", 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("<Configure>", 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() | |