I had been searching online mindmap apps for my study for a while and never got one that I am really happy with.
Then I asked myself what I really need for a mind map to do? My requirements are pretty simple:
It allows me to put in text and create child nodes. It can be saved and re-opened for later editing. I should be able to print it as a picture/pdf.
With some help from ChatGPT, I was actually able to create one that works very well so I am sharing it with all of you here.
Full source code in Python.
import tkinter as tk
from tkinter import filedialog
import json
class MindMapApp:
def __init__(self, root):
self.root = root
self.root.title("Mind Map Tool")
self.root.geometry("800x600")
# Main canvas
self.canvas = tk.Canvas(root, width=780, height=500, bg="lightgray")
self.canvas.pack(pady=10)
# Buttons
button_frame = tk.Frame(root)
button_frame.pack()
tk.Button(button_frame, text="Add Text Box", command=lambda: self.add_text_box()).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="Save", command=self.save_mind_map).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="Open", command=self.open_mind_map).pack(side=tk.LEFT, padx=5)
self.text_boxes = [] # List to track all text boxes
self.connections = [] # List to track parent-child relationships for drawing lines
def add_text_box(self, parent=None, x=None, y=None, text=""):
"""Add a new text box. If a parent is provided, connect the sub-note to it."""
if x is None or y is None:
# Add a top-level text box with default placement
x, y = 50 + len(self.text_boxes) * 30, 50 + len(self.text_boxes) * 30
# Create the text box and "+" button
new_text_box = tk.Text(self.root, width=20, height=2, wrap="word")
new_text_box.place(x=x, y=y)
new_text_box.insert("1.0", text)
new_text_box.bind("<KeyRelease>", lambda e: self.adjust_text_box_size(new_text_box))
new_text_box.focus_set() # Automatically set focus to the new text box
add_button = tk.Button(self.root, text="+", command=lambda: self.add_text_box(new_text_box))
add_button.place(x=x - 30, y=y)
# Link the "+" button to the text box
new_text_box._add_button = add_button
self.text_boxes.append(new_text_box)
# Add dragging functionality
self.make_draggable(new_text_box)
# Add right-click menu for deletion
self.add_context_menu(new_text_box)
# Draw a curved, dotted line connecting parent and child
if parent is not None:
# Determine non-overlapping position
x, y = self.find_non_overlapping_position(parent)
line_id = self.canvas.create_line(
parent.winfo_x() + 90, parent.winfo_y() + 10, # Parent's right center
x, y + 10, # Child's left center
fill="black",
dash=(4, 2),
smooth=True,
)
self.connections.append((parent, new_text_box, line_id))
def find_non_overlapping_position(self, parent):
"""Find a non-overlapping position for a new child text box."""
x = parent.winfo_x() + 150
y = parent.winfo_y()
while any(
abs(x - t.winfo_x()) < 100 and abs(y - t.winfo_y()) < 50
for t in self.text_boxes
):
y += 50 # Move down until a free space is found
return x, y
def adjust_text_box_size(self, text_box):
"""Adjust the size of the text box based on its content."""
content = text_box.get("1.0", "end-1c")
lines = content.split("\n")
width = max(len(line) for line in lines)
height = len(lines)
text_box.config(width=max(20, width), height=max(2, height))
def make_draggable(self, widget):
"""Make a widget draggable."""
def start_drag(event):
widget._drag_start_x = event.x
widget._drag_start_y = event.y
def drag(event):
# Calculate new position
new_x = widget.winfo_x() + event.x - widget._drag_start_x
new_y = widget.winfo_y() + event.y - widget._drag_start_y
widget.place(x=new_x, y=new_y)
# Update "+" button position
if hasattr(widget, "_add_button"):
widget._add_button.place(x=new_x - 30, y=new_y)
# Redraw all lines
self.redraw_lines()
widget.bind("<Button-1>", start_drag)
widget.bind("<B1-Motion>", drag)
def add_context_menu(self, widget):
"""Add a right-click context menu to delete the widget."""
menu = tk.Menu(self.root, tearoff=0)
menu.add_command(label="Delete", command=lambda: self.delete_text_box(widget))
def show_context_menu(event):
menu.tk_popup(event.x_root, event.y_root)
widget.bind("<Button-3>", show_context_menu)
def delete_text_box(self, widget):
"""Delete a text box, its "+" button, and associated lines."""
# Remove associated "+" button
if hasattr(widget, "_add_button"):
widget._add_button.destroy()
# Remove connections and lines related to the widget
for connection in self.connections[:]:
parent, child, line_id = connection
if parent == widget or child == widget:
self.canvas.delete(line_id)
self.connections.remove(connection)
# Destroy the widget
widget.destroy()
# Remove from text_boxes list
self.text_boxes.remove(widget)
def redraw_lines(self):
"""Redraw all lines to keep connections intact when widgets are moved."""
for parent, child, line_id in self.connections:
# Update line coordinates based on the current positions
self.canvas.coords(
line_id,
parent.winfo_x() + 90, parent.winfo_y() + 10, # Parent's right center
child.winfo_x(), child.winfo_y() + 10, # Child's left center
)
def save_mind_map(self):
"""Save the current mind map to a JSON file."""
mind_map = []
for text_box in self.text_boxes:
mind_map.append({
"x": text_box.winfo_x(),
"y": text_box.winfo_y(),
"text": text_box.get("1.0", "end-1c"),
})
connections = []
for parent, child, _ in self.connections:
connections.append({
"parent_index": self.text_boxes.index(parent),
"child_index": self.text_boxes.index(child),
})
data = {"text_boxes": mind_map, "connections": connections}
file_path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON files", "*.json")])
if file_path:
with open(file_path, "w") as file:
json.dump(data, file)
def open_mind_map(self):
"""Open a mind map from a JSON file."""
file_path = filedialog.askopenfilename(filetypes=[("JSON files", "*.json")])
if file_path:
with open(file_path, "r") as file:
data = json.load(file)
# Clear current mind map
for text_box in self.text_boxes[:]:
self.delete_text_box(text_box)
# Add text boxes
for item in data["text_boxes"]:
self.add_text_box(x=item["x"], y=item["y"], text=item["text"])
# Add connections
for conn in data["connections"]:
parent = self.text_boxes[conn["parent_index"]]
child = self.text_boxes[conn["child_index"]]
line_id = self.canvas.create_line(
parent.winfo_x() + 90, parent.winfo_y() + 10,
child.winfo_x(), child.winfo_y() + 10,
fill="black", dash=(4, 2), smooth=True
)
self.connections.append((parent, child, line_id))
# Run the application
root = tk.Tk()
app = MindMapApp(root)
# Periodically redraw lines to ensure they're updated
def periodic_redraw():
app.redraw_lines()
root.after(100, periodic_redraw)
periodic_redraw()
root.mainloop()
No comments:
Post a Comment