| 1 | # gui/layout_dialog.py
|
|---|
| 2 | import tkinter as tk
|
|---|
| 3 | from tkinter import ttk, messagebox, simpledialog
|
|---|
| 4 |
|
|---|
| 5 |
|
|---|
| 6 | class ExamLayoutDialog(tk.Toplevel):
|
|---|
| 7 | """Dialog for arranging Exam layout: pages on the right, question pool on the left."""
|
|---|
| 8 |
|
|---|
| 9 | def __init__(self, parent, exam):
|
|---|
| 10 | super().__init__(parent)
|
|---|
| 11 | self.parent = parent
|
|---|
| 12 | self.exam = exam
|
|---|
| 13 |
|
|---|
| 14 | # Ensure we have a layout object
|
|---|
| 15 | self.layout = getattr(exam, "layout", None)
|
|---|
| 16 | if self.layout is None:
|
|---|
| 17 | from builder.exam import ExamLayout
|
|---|
| 18 | self.layout = ExamLayout()
|
|---|
| 19 |
|
|---|
| 20 | self.title(f"Exam Layout — {exam.title}")
|
|---|
| 21 | self.geometry("900x600")
|
|---|
| 22 | self.configure(padx=10, pady=10)
|
|---|
| 23 |
|
|---|
| 24 | self.create_widgets()
|
|---|
| 25 | self.refresh_all()
|
|---|
| 26 |
|
|---|
| 27 | # Make modal
|
|---|
| 28 | self.transient(parent)
|
|---|
| 29 | self.grab_set()
|
|---|
| 30 | self.wait_visibility()
|
|---|
| 31 | self.focus_set()
|
|---|
| 32 |
|
|---|
| 33 | # ─────────────────────────────────────────────────────────────
|
|---|
| 34 | # GUI construction (pure grid layout)
|
|---|
| 35 | # ─────────────────────────────────────────────────────────────
|
|---|
| 36 | def create_widgets(self):
|
|---|
| 37 | # 3 main vertical rows: top section, page section, footer
|
|---|
| 38 | self.rowconfigure(0, weight=1)
|
|---|
| 39 | self.rowconfigure(1, weight=0)
|
|---|
| 40 | self.rowconfigure(2, weight=0)
|
|---|
| 41 | self.columnconfigure(0, weight=1)
|
|---|
| 42 |
|
|---|
| 43 | # ─────────────────────────────
|
|---|
| 44 | # TOP SECTION (Questions | Mid | Pages)
|
|---|
| 45 | # ─────────────────────────────
|
|---|
| 46 | top = ttk.Frame(self)
|
|---|
| 47 | top.grid(row=0, column=0, sticky="nsew", pady=(0, 8))
|
|---|
| 48 | top.columnconfigure(0, weight=1) # left
|
|---|
| 49 | top.columnconfigure(1, weight=0) # mid
|
|---|
| 50 | top.columnconfigure(2, weight=1) # right
|
|---|
| 51 | top.rowconfigure(1, weight=1)
|
|---|
| 52 |
|
|---|
| 53 | # LEFT — Question pool
|
|---|
| 54 | ttk.Label(top, text="Exam Questions").grid(row=0, column=0, sticky="w")
|
|---|
| 55 | self.questions_list = tk.Listbox(top, selectmode="extended", exportselection=False)
|
|---|
| 56 | self.questions_list.grid(row=1, column=0, sticky="nsew", padx=(0, 6))
|
|---|
| 57 | qbtns = ttk.Frame(top)
|
|---|
| 58 | qbtns.grid(row=2, column=0, sticky="ew", pady=(5, 0))
|
|---|
| 59 | ttk.Button(qbtns, text="Add Question", command=self.add_question).pack(side="left")
|
|---|
| 60 | ttk.Button(qbtns, text="Delete", command=self.delete_question).pack(side="left")
|
|---|
| 61 |
|
|---|
| 62 | # MIDDLE — Assignment controls
|
|---|
| 63 | mid = ttk.Frame(top)
|
|---|
| 64 | mid.grid(row=1, column=1, sticky="ns", padx=4)
|
|---|
| 65 | ttk.Button(mid, text="→ Add to Page", command=self.add_selected_to_page).pack(pady=10)
|
|---|
| 66 | ttk.Button(mid, text="← Remove", command=self.remove_selected_from_page).pack(pady=10)
|
|---|
| 67 |
|
|---|
| 68 | # RIGHT — Pages
|
|---|
| 69 | ttk.Label(top, text="Exam Pages").grid(row=0, column=2, sticky="w")
|
|---|
| 70 | self.pages_list = tk.Listbox(top, exportselection=False)
|
|---|
| 71 | self.pages_list.grid(row=1, column=2, sticky="nsew", padx=(6, 0))
|
|---|
| 72 | self.pages_list.bind("<<ListboxSelect>>", self.on_page_selected)
|
|---|
| 73 |
|
|---|
| 74 | pagebtns = ttk.Frame(top)
|
|---|
| 75 | pagebtns.grid(row=2, column=2, sticky="ew", pady=(5, 0))
|
|---|
| 76 | ttk.Button(pagebtns, text="Add Page", command=self.add_page).pack(side="left")
|
|---|
| 77 | ttk.Button(pagebtns, text="Rename", command=self.rename_page).pack(side="left")
|
|---|
| 78 | ttk.Button(pagebtns, text="Delete", command=self.delete_page).pack(side="left")
|
|---|
| 79 | ttk.Button(pagebtns, text="↑", command=self.move_page_up).pack(side="right")
|
|---|
| 80 | ttk.Button(pagebtns, text="↓", command=self.move_page_down).pack(side="right")
|
|---|
| 81 |
|
|---|
| 82 | # ─────────────────────────────
|
|---|
| 83 | # SECOND SECTION — Page contents
|
|---|
| 84 | # ─────────────────────────────
|
|---|
| 85 | page_frame = ttk.LabelFrame(self, text="Questions on Selected Page")
|
|---|
| 86 | page_frame.grid(row=1, column=0, sticky="nsew")
|
|---|
| 87 | page_frame.columnconfigure(0, weight=1)
|
|---|
| 88 | page_frame.rowconfigure(0, weight=1)
|
|---|
| 89 |
|
|---|
| 90 | self.page_questions = tk.Listbox(page_frame, height=8, exportselection=False)
|
|---|
| 91 | self.page_questions.grid(row=0, column=0, sticky="nsew", padx=2, pady=2)
|
|---|
| 92 |
|
|---|
| 93 | pqbtns = ttk.Frame(page_frame)
|
|---|
| 94 | pqbtns.grid(row=1, column=0, sticky="ew", pady=(4, 0))
|
|---|
| 95 | ttk.Button(pqbtns, text="Move Up", command=self.move_q_up).pack(side="left")
|
|---|
| 96 | ttk.Button(pqbtns, text="Move Down", command=self.move_q_down).pack(side="left")
|
|---|
| 97 |
|
|---|
| 98 | # ─────────────────────────────
|
|---|
| 99 | # FOOTER — OK / Cancel
|
|---|
| 100 | # ─────────────────────────────
|
|---|
| 101 | footer = ttk.Frame(self)
|
|---|
| 102 | footer.grid(row=2, column=0, sticky="ew", pady=(10, 0))
|
|---|
| 103 | ttk.Button(footer, text="OK", command=self.on_ok).pack(side="right", padx=5)
|
|---|
| 104 | ttk.Button(footer, text="Cancel", command=self.destroy).pack(side="right", padx=5)
|
|---|
| 105 |
|
|---|
| 106 | # ─────────────────────────────────────────────────────────────
|
|---|
| 107 | # Refresh
|
|---|
| 108 | # ─────────────────────────────────────────────────────────────
|
|---|
| 109 | def refresh_all(self):
|
|---|
| 110 | self.pages_list.delete(0, tk.END)
|
|---|
| 111 | for p in self.layout.pages:
|
|---|
| 112 | self.pages_list.insert(tk.END, p.title)
|
|---|
| 113 |
|
|---|
| 114 | self.questions_list.delete(0, tk.END)
|
|---|
| 115 | for q in self.exam.questions:
|
|---|
| 116 | self.questions_list.insert(tk.END, q.text[:50])
|
|---|
| 117 |
|
|---|
| 118 | self.page_questions.delete(0, tk.END)
|
|---|
| 119 |
|
|---|
| 120 | def refresh_page_questions(self, page):
|
|---|
| 121 | self.page_questions.delete(0, tk.END)
|
|---|
| 122 | for qid in page.question_ids:
|
|---|
| 123 | q = self.exam.get_question_by_id(qid)
|
|---|
| 124 | if q:
|
|---|
| 125 | self.page_questions.insert(tk.END, f"{qid} — {q.text[:50]}")
|
|---|
| 126 |
|
|---|
| 127 | # ─────────────────────────────────────────────────────────────
|
|---|
| 128 | # Page operations
|
|---|
| 129 | # ─────────────────────────────────────────────────────────────
|
|---|
| 130 | def add_page(self):
|
|---|
| 131 | name = simpledialog.askstring("New Page", "Enter page title:", parent=self)
|
|---|
| 132 | if not name:
|
|---|
| 133 | return
|
|---|
| 134 | self.layout.add_page(name)
|
|---|
| 135 | self.refresh_all()
|
|---|
| 136 |
|
|---|
| 137 | def rename_page(self):
|
|---|
| 138 | idx = self.pages_list.curselection()
|
|---|
| 139 | if not idx:
|
|---|
| 140 | return
|
|---|
| 141 | page = self.layout.pages[idx[0]]
|
|---|
| 142 | new_name = simpledialog.askstring("Rename Page", "New title:", initialvalue=page.title, parent=self)
|
|---|
| 143 | if new_name:
|
|---|
| 144 | page.title = new_name
|
|---|
| 145 | self.refresh_all()
|
|---|
| 146 | self.pages_list.selection_set(idx[0])
|
|---|
| 147 |
|
|---|
| 148 | def delete_page(self):
|
|---|
| 149 | idx = self.pages_list.curselection()
|
|---|
| 150 | if not idx:
|
|---|
| 151 | return
|
|---|
| 152 | del self.layout.pages[idx[0]]
|
|---|
| 153 | self.refresh_all()
|
|---|
| 154 |
|
|---|
| 155 | def move_page_up(self):
|
|---|
| 156 | idx = self.pages_list.curselection()
|
|---|
| 157 | if not idx or idx[0] == 0:
|
|---|
| 158 | return
|
|---|
| 159 | i = idx[0]
|
|---|
| 160 | pages = self.layout.pages
|
|---|
| 161 | pages[i - 1], pages[i] = pages[i], pages[i - 1]
|
|---|
| 162 | self.refresh_all()
|
|---|
| 163 | self.pages_list.selection_set(i - 1)
|
|---|
| 164 |
|
|---|
| 165 | def move_page_down(self):
|
|---|
| 166 | idx = self.pages_list.curselection()
|
|---|
| 167 | if not idx or idx[0] == len(self.layout.pages) - 1:
|
|---|
| 168 | return
|
|---|
| 169 | i = idx[0]
|
|---|
| 170 | pages = self.layout.pages
|
|---|
| 171 | pages[i + 1], pages[i] = pages[i], pages[i + 1]
|
|---|
| 172 | self.refresh_all()
|
|---|
| 173 | self.pages_list.selection_set(i + 1)
|
|---|
| 174 |
|
|---|
| 175 | # ─────────────────────────────────────────────────────────────
|
|---|
| 176 | # Question assignment
|
|---|
| 177 | # ─────────────────────────────────────────────────────────────
|
|---|
| 178 | def add_selected_to_page(self):
|
|---|
| 179 | idx_page = self.pages_list.curselection()
|
|---|
| 180 | if not idx_page:
|
|---|
| 181 | messagebox.showinfo("Select Page", "Please select a page first.")
|
|---|
| 182 | return
|
|---|
| 183 | page = self.layout.pages[idx_page[0]]
|
|---|
| 184 | for i in self.questions_list.curselection():
|
|---|
| 185 | q = self.exam.questions[i]
|
|---|
| 186 | if q.flexo_id not in page.question_ids:
|
|---|
| 187 | page.question_ids.append(q.flexo_id)
|
|---|
| 188 | self.refresh_page_questions(page)
|
|---|
| 189 |
|
|---|
| 190 | def remove_selected_from_page(self):
|
|---|
| 191 | idx_page = self.pages_list.curselection()
|
|---|
| 192 | if not idx_page:
|
|---|
| 193 | return
|
|---|
| 194 | page = self.layout.pages[idx_page[0]]
|
|---|
| 195 | for i in reversed(self.page_questions.curselection()):
|
|---|
| 196 | del page.question_ids[i]
|
|---|
| 197 | self.refresh_page_questions(page)
|
|---|
| 198 |
|
|---|
| 199 | def on_page_selected(self, event=None):
|
|---|
| 200 | idx = self.pages_list.curselection()
|
|---|
| 201 | if not idx:
|
|---|
| 202 | self.page_questions.delete(0, tk.END)
|
|---|
| 203 | return
|
|---|
| 204 | page = self.layout.pages[idx[0]]
|
|---|
| 205 | self.refresh_page_questions(page)
|
|---|
| 206 |
|
|---|
| 207 | # ─────────────────────────────────────────────────────────────
|
|---|
| 208 | # Question management
|
|---|
| 209 | # ─────────────────────────────────────────────────────────────
|
|---|
| 210 | def add_question(self):
|
|---|
| 211 | messagebox.showinfo("Info", "Use the main builder to add questions to the exam.")
|
|---|
| 212 |
|
|---|
| 213 | def delete_question(self):
|
|---|
| 214 | idxs = list(self.questions_list.curselection())
|
|---|
| 215 | if not idxs:
|
|---|
| 216 | return
|
|---|
| 217 | if not messagebox.askyesno("Confirm", "Remove selected questions from exam?"):
|
|---|
| 218 | return
|
|---|
| 219 | qids = [self.exam.questions[i].flexo_id for i in idxs]
|
|---|
| 220 | for qid in qids:
|
|---|
| 221 | self.exam.remove_question(qid)
|
|---|
| 222 | for p in self.layout.pages:
|
|---|
| 223 | if qid in p.question_ids:
|
|---|
| 224 | p.question_ids.remove(qid)
|
|---|
| 225 | self.refresh_all()
|
|---|
| 226 |
|
|---|
| 227 | # ─────────────────────────────────────────────────────────────
|
|---|
| 228 | # Move question within page
|
|---|
| 229 | # ─────────────────────────────────────────────────────────────
|
|---|
| 230 | def move_q_up(self):
|
|---|
| 231 | """Move selected question one position up within the current page."""
|
|---|
| 232 | idx = self.page_questions.curselection()
|
|---|
| 233 | if not idx:
|
|---|
| 234 | return
|
|---|
| 235 | i = idx[0]
|
|---|
| 236 | page_idx = self.pages_list.curselection()
|
|---|
| 237 | if not page_idx:
|
|---|
| 238 | return
|
|---|
| 239 | page = self.layout.pages[page_idx[0]]
|
|---|
| 240 | if i == 0:
|
|---|
| 241 | return
|
|---|
| 242 | page.question_ids[i - 1], page.question_ids[i] = page.question_ids[i], page.question_ids[i - 1]
|
|---|
| 243 | self.refresh_page_questions(page)
|
|---|
| 244 | self.page_questions.selection_clear(0, tk.END)
|
|---|
| 245 | self.page_questions.selection_set(i - 1)
|
|---|
| 246 |
|
|---|
| 247 | def move_q_down(self):
|
|---|
| 248 | """Move selected question one position down within the current page."""
|
|---|
| 249 | idx = self.page_questions.curselection()
|
|---|
| 250 | if not idx:
|
|---|
| 251 | return
|
|---|
| 252 | i = idx[0]
|
|---|
| 253 | page_idx = self.pages_list.curselection()
|
|---|
| 254 | if not page_idx:
|
|---|
| 255 | return
|
|---|
| 256 | page = self.layout.pages[page_idx[0]]
|
|---|
| 257 | if i == len(page.question_ids) - 1:
|
|---|
| 258 | return
|
|---|
| 259 | page.question_ids[i + 1], page.question_ids[i] = page.question_ids[i], page.question_ids[i + 1]
|
|---|
| 260 | self.refresh_page_questions(page)
|
|---|
| 261 | self.page_questions.selection_clear(0, tk.END)
|
|---|
| 262 | self.page_questions.selection_set(i + 1)
|
|---|
| 263 |
|
|---|
| 264 | # ─────────────────────────────────────────────────────────────
|
|---|
| 265 | # Finalize
|
|---|
| 266 | # ─────────────────────────────────────────────────────────────
|
|---|
| 267 | def on_ok(self):
|
|---|
| 268 | self.exam.layout = self.layout
|
|---|
| 269 | self.destroy()
|
|---|