summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTimo Wilken2018-07-17 22:40:28 +0200
committerTimo Wilken2023-12-01 22:49:28 +0100
commitb4778f5ce93812f6c72dc55fb884bba959f057f6 (patch)
tree6b9483428fcf8d251ee1a08e8c322db09139d7d8
Initial commitHEADmaster
-rw-r--r--.gitignore1
-rw-r--r--LICENSE21
-rwxr-xr-x__init__.py123
-rw-r--r--abitur.py114
-rw-r--r--error_label.py33
-rw-r--r--exam_chooser.py22
-rw-r--r--exam_grades.py72
-rw-r--r--grade_entry.py101
-rw-r--r--result_display.py36
-rw-r--r--subject.py125
-rw-r--r--subject_grade_table.py61
-rw-r--r--term_grades.py72
-rw-r--r--tooltip.py228
-rw-r--r--watchable.py25
14 files changed, 1034 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c18dd8d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..efffd0f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 Timo Wilken
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/__init__.py b/__init__.py
new file mode 100755
index 0000000..caa2c17
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+'''Abitur result calculator, with a Tk GUI.'''
+
+import locale
+import tkinter as tk
+import tkinter.ttk as ttk
+import tkinter.messagebox
+
+from abitur import Abitur
+from term_grades import TermGrades
+from exam_grades import ExamGrades
+from result_display import ResultDisplay
+
+
+__version__ = '2018-07-14'
+SUBJECTS = 'De', 'Ma'
+TERMS = '11.1', '11.2', '12.1', '12.2'
+
+
+class Application(ttk.Frame):
+ '''Top-level Frame of the application.'''
+
+ def __init__(self, master=None):
+ super().__init__(master)
+ self.grid(row=0, column=0, sticky=tk.NSEW)
+ master.columnconfigure(0, weight=1)
+ master.rowconfigure(0, weight=1)
+
+ root = self.winfo_toplevel()
+ root.title('Abiturrechner')
+
+ ttk.Sizegrip(self).grid(row=99, column=0, sticky=tk.SE)
+
+ menubar = tk.Menu(self)
+ root.config(menu=menubar)
+ filemenu = tk.Menu(menubar, tearoff=False)
+ menubar.add_cascade(label='Datei', menu=filemenu)
+ helpmenu = tk.Menu(menubar, tearoff=False)
+ menubar.add_cascade(label='Hilfe', menu=helpmenu)
+
+ def add_menu_item(menu, label, command, accel_key=None):
+ '''Add a menu item and bind its accelerator.'''
+ if accel_key:
+ shortcut = 'Strg+{}'.format(accel_key.upper())
+ tk_accel = '<Control-{}>'.format(accel_key.lower())
+ self.bind_all(tk_accel, command)
+ menu_kwargs = {'accelerator': shortcut}
+ else:
+ menu_kwargs = {}
+ menu.add_command(label=label, command=command, **menu_kwargs)
+
+ add_menu_item(filemenu, 'Neu', self.new_file, 'n')
+ add_menu_item(filemenu, 'Öffnen', self.open_file, 'o')
+ add_menu_item(filemenu, 'Speichern', self.save_file, 's')
+ filemenu.add_separator()
+ add_menu_item(filemenu, 'Schließen', lambda e=None: self.quit(), 'q')
+
+ add_menu_item(helpmenu, 'KMK Abiturordnung', self.spec_source)
+ add_menu_item(helpmenu, 'Über dieses Programm', self.about)
+
+ self.columnconfigure(0, weight=1)
+ self.rowconfigure(0, weight=0)
+ self.rowconfigure(1, weight=0)
+ self.rowconfigure(98, weight=1)
+
+ self.abitur = Abitur.create_default()
+
+ grades_nb = ttk.Notebook(self)
+ grades_nb.enable_traversal()
+ grades_nb.grid(row=0, column=0, sticky=tk.NSEW)
+ terms = TermGrades(self, self.abitur)
+ grades_nb.add(terms, text='Halbjahresnoten', underline=0)
+ exams = ExamGrades(self, self.abitur)
+ grades_nb.add(exams, text='Prüfungsnoten', underline=0)
+ ResultDisplay(self, self.abitur) \
+ .grid(row=1, column=0, pady=10, sticky=tk.NSEW)
+
+
+ def new_file(self, e=None):
+ pass
+
+ def open_file(self, e=None):
+ pass
+
+ def save_file(self, e=None):
+ pass
+
+ def about(self, e=None):
+ '''Show an about window.'''
+ msg = ('Abiturrechner nach §7 der Ordnung zur Erlangung der '
+ 'Allgemeinen Hochschulreife an Deutschen Schulen im Ausland: '
+ 'Beschluss der KMK vom 11.06.2015\n\nVersion {version}\n'
+ '© {year} Timo Wilken').format(version=__version__,
+ year=__version__.split('-')[0])
+ tkinter.messagebox.showinfo('Über dieses Programm', msg, parent=self)
+
+ def spec_source(self, e=None):
+ '''Open the Abitur specification in a browser.'''
+ url = ('https://www.kmk.org/fileadmin/Dateien/doc/Bildung/Auslandsschul'
+ 'wesen/Abitur/2015_06_11-PO-Deutsches-Intern-Abitur.pdf')
+ def show_url_msgbox():
+ '''Show the URL in a message box in case the browser failed.'''
+ title = 'Browser könnte nicht geöffnet werden.'
+ tkinter.messagebox.showwarning(title, url, parent=self)
+ try:
+ import webbrowser
+ try:
+ webbrowser.open(url)
+ except webbrowser.Error:
+ show_url_msgbox()
+ except ImportError:
+ show_url_msgbox()
+
+
+def main():
+ '''Main entry point and setup.'''
+ #locale.setlocale(locale.LC_ALL, 'de_DE')
+ Application(tk.Tk()).mainloop()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/abitur.py b/abitur.py
new file mode 100644
index 0000000..b5a682d
--- /dev/null
+++ b/abitur.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+
+'''Represents the overarching structure and logic of the Abitur.'''
+
+import math
+import itertools as it
+from collections import OrderedDict
+
+from subject import Subject, Grade, Term, TERM_GRADE, EXAM_GRADE
+from watchable import Watchable
+
+class Abitur(Watchable):
+ '''Encapsulates overarching logic.'''
+
+ _point_grades = tuple(
+ 'nicht bestanden' if p < 300 else
+ (1.0 if p > 822 else (math.floor((17/3 - p/180) * 10) / 10))
+ for p in range(901)
+ )
+
+ def __init__(self, subjects, terms, exams):
+ super().__init__()
+ self.exams, self.terms = exams, terms
+ self._subjects = []
+ for subj in subjects:
+ self.add_subject(subj)
+
+ @property
+ def subjects(self):
+ '''A shallow copy of the list of all subjects.'''
+ return self._subjects[:]
+
+ def add_subject(self, subject):
+ '''Register a new subject.'''
+ self._subjects.append(subject)
+ subject.subscribe(lambda subj, avg: self._update_subscribers())
+
+ @staticmethod
+ def is_valid_grade(grade):
+ '''Checks whether the given grade is in the range of valid grades.
+
+ Note that non-integer grades are allowed.
+ '''
+ return 0 <= grade <= 15
+
+ @property
+ def num_enabled_terms(self):
+ return sum(s.num_enabled_terms for s in self.subjects)
+
+ @property
+ def term_points(self):
+ enabled_terms = self.num_enabled_terms
+ if not enabled_terms:
+ return 0
+ result = 40 * sum(s.term_points for s in self.subjects) / enabled_terms
+ return int(result)
+
+ @property
+ def exam_points(self):
+ return sum(s.exam_points for s in self.subjects)
+
+ @property
+ def total_points(self):
+ return self.term_points + self.exam_points
+
+ @property
+ def total_grade(self):
+ return self._point_grades[self.total_points]
+
+ @property
+ def points_to_next_grade(self):
+ points = i = self.total_points
+ try:
+ while self._point_grades[i] == self._point_grades[points]:
+ i += 1
+ return i - points
+ except IndexError:
+ return 'keine höhere Note'
+
+ @classmethod
+ def create_default(cls):
+ '''Create an Abitur object.
+
+ It is filled in with the German School London's configuration of
+ subjects and terms.
+ '''
+ terms = tuple(map(Term, ('11.1', '11.2', '12.1', '12.2')))
+ exams = 'schriftlich', 'mündlich'
+ slk = 'Sprachlich-literarisch-künstlerisch'
+ gsw = 'Gesellschaftswissenschaftlich'
+ mnw = 'Mathematisch-naturwissenschaftlich'
+ etc = 'Andere'
+ subjects = [
+ Subject('Deutsch', slk),
+ Subject('Englisch', slk),
+ Subject('Französisch', slk),
+ Subject('Spanisch', slk),
+ Subject('Kunst', slk),
+ Subject('Musik', slk),
+ Subject('Geschichte', gsw),
+ Subject('Erdkunde', gsw),
+ Subject('Ethik', gsw),
+ Subject('Mathematik', mnw),
+ Subject('Physik', mnw),
+ Subject('Chemie', mnw),
+ Subject('Biologie', mnw),
+ Subject('Sport', etc),
+ ]
+ for subj in subjects:
+ for term in terms:
+ subj.set_grade(term, Grade(subj, term, None, False), TERM_GRADE)
+ for exam in exams:
+ subj.set_grade(exam, Grade(subj, exam, None, False), EXAM_GRADE)
+ return cls(subjects, terms, exams)
diff --git a/error_label.py b/error_label.py
new file mode 100644
index 0000000..600ab6d
--- /dev/null
+++ b/error_label.py
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+
+import tkinter as tk
+import tkinter.ttk as ttk
+
+from tooltip import ToolTip
+
+
+class ErrorLabel(ttk.Frame):
+ '''A Label that may be in two states -- indicating an error or not.'''
+
+ def __init__(self, master, error_state_variable, tooltip_text_variable,
+ error_indicator='!!', **label_options):
+ super().__init__(master)
+
+ spacing = {'sticky': tk.NSEW, 'padx': 5, 'pady': 0}
+
+ ttk.Label(self, **label_options).grid(row=0, column=0, **spacing)
+
+ self._error_indicator = ttk.Label(self, textvariable=None)
+ self._tooltip = ToolTip(self, textvariable=tooltip_text_variable)
+
+ self._update_error_state()
+ self._update_label_text()
+ error_state_variable.trace_add('write', self._update_error_state)
+ textvariable.trace_add('write', self._update_label_text)
+
+ def _update_error_state(self, *_):
+ self._tooltip.configure(state=(tk.NORMAL if self._error_state_var.get()
+ else tk.DISABLED))
+ if error_state_variable.get():
+ textvariable.set(' '.join((textvariable.get(), error_indicator))
+ if error_state_variable.get())
diff --git a/exam_chooser.py b/exam_chooser.py
new file mode 100644
index 0000000..ace8ed9
--- /dev/null
+++ b/exam_chooser.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+
+'''A widget for choosing the type of exam.'''
+
+import tkinter as tk
+import tkinter.ttk as ttk
+
+
+class ExamChooser(ttk.Frame):
+ '''A Frame for choosing a type of exam; a "none" option is added.'''
+
+ def __init__(self, master, exam_types):
+ super().__init__(master)
+ spacing = {'sticky': tk.NSEW, 'padx': 5, 'pady': 0}
+ self.selection_var = tk.StringVar(self)
+ ttk.Radiobutton(self, value='', text='keine',
+ variable=self.selection_var) \
+ .grid(row=0, column=0, **spacing)
+ for i, value in enumerate(exam_types):
+ ttk.Radiobutton(self, value=value, text=value,
+ variable=self.selection_var) \
+ .grid(row=0, column=i + 1, **spacing)
diff --git a/exam_grades.py b/exam_grades.py
new file mode 100644
index 0000000..23b8141
--- /dev/null
+++ b/exam_grades.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+
+'''A Tk Frame for displaying and editing term grades.'''
+
+import tkinter as tk
+import tkinter.ttk as ttk
+from tkinter.font import Font, BOLD
+
+from subject_grade_table import SubjectGradeTable
+from exam_chooser import ExamChooser
+
+
+class ExamGrades(SubjectGradeTable):
+ '''A large Tk Frame for entering subjects' exam grades.'''
+
+ def __init__(self, master, abitur):
+ spacing = {'sticky': tk.NS, 'padx': 5, 'pady': 5}
+ def add_row_total(row, col, subject):
+ exam_chooser = ExamChooser(self, abitur.exams)
+ exam_chooser.grid(row=row, column=col, **spacing)
+
+ def update_grades(*_):
+ sel_exam = exam_chooser.selection_var.get()
+ for exam, grade in subject.exam_grades.items():
+ grade.enabled = exam == sel_exam
+ exam_chooser.selection_var.trace_add('write', update_grades)
+
+ totalvar = tk.StringVar(self, 'N/A')
+ ttk.Label(self, textvariable=totalvar) \
+ .grid(row=row, column=col + 1, **spacing)
+ def update_total(subj, new_avg=None):
+ totalvar.set(subj.exam_points)
+ subject.subscribe(update_total)
+ update_total(subject)
+
+ grades = {s: s.exam_grades.values() for s in abitur.subjects}
+ grade_entry_opts = {'valid_grade': abitur.is_valid_grade,
+ 'with_enabled': False}
+ super().__init__(master, abitur.subjects, row_callback=add_row_total,
+ grade_entry_options=grade_entry_opts, grades=grades,
+ add_separators=True, header_rowspan=2)
+
+ # column headings
+ hdr_kwargs = {'font': Font(weight=BOLD)}
+ ttk.Label(self, text='Aufgabenfeld', **hdr_kwargs) \
+ .grid(row=0, column=0, rowspan=2, **spacing)
+ ttk.Label(self, text='Prüfungsfach', **hdr_kwargs) \
+ .grid(row=0, column=1, rowspan=2, **spacing)
+ ttk.Label(self, text='Prüfungsnoten', **hdr_kwargs) \
+ .grid(row=0, column=2, columnspan=len(abitur.exams), **spacing)
+ for i, text in enumerate(abitur.exams):
+ ttk.Label(self, text=text, **hdr_kwargs) \
+ .grid(row=1, column=2 + i, **spacing)
+ ttk.Label(self, text='Prüfung', **hdr_kwargs) \
+ .grid(row=0, column=2 + len(abitur.exams), rowspan=2, **spacing)
+ ttk.Label(self, text='Punkt-\nsumme', **hdr_kwargs) \
+ .grid(row=0, column=3 + len(abitur.exams), rowspan=2, **spacing)
+
+ # totals row
+ overall_total_var = tk.StringVar(self, abitur.exam_points)
+ abitur.subscribe(lambda: overall_total_var.set(abitur.exam_points))
+
+ ttk.Label(self, text='SUMME', **hdr_kwargs) \
+ .grid(row=99, column=0, columnspan=5, **spacing)
+ ttk.Label(self, textvariable=overall_total_var) \
+ .grid(row=99, column=5, **spacing)
+
+ self.rowconfigure(0, weight=0)
+ self.columnconfigure(0, weight=1)
+ self.columnconfigure(1, weight=1)
+ self.columnconfigure(4, weight=1)
+ self.columnconfigure(5, weight=1)
diff --git a/grade_entry.py b/grade_entry.py
new file mode 100644
index 0000000..033e0d0
--- /dev/null
+++ b/grade_entry.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+
+'''A common Tk Frame for entering grades.'''
+
+import locale
+import tkinter as tk
+import tkinter.ttk as ttk
+
+
+class GradeEntry(ttk.Frame):
+ '''A Tk Frame with a grade entry box.'''
+
+ def __init__(self, master, grade, valid_grade=lambda g: True,
+ with_enabled=True):
+ super().__init__(master)
+ self._grade = grade
+ self._is_valid_grade = valid_grade
+
+ self._checkvar = tk.BooleanVar(self, grade.enabled)
+ self._checkvar.trace_add('write', self._on_change_enabled)
+ checkbtn = ttk.Checkbutton(self, variable=self._checkvar)
+ checkbtn.grid(row=0, column=0, sticky=tk.NSEW)
+ if not with_enabled:
+ checkbtn.state((tk.DISABLED,))
+ self.columnconfigure(0, weight=0)
+
+ self._entryvar = tk.StringVar(self, grade.value)
+ self._entryvar.trace_add('write', self._on_change_grade)
+ self._indicatorvar = tk.StringVar(self, '')
+
+ ttk.Label(self, textvariable=self._indicatorvar) \
+ .grid(row=0, column=2, sticky=tk.NSEW)
+ self._entry = ttk.Entry(self, textvariable=self._entryvar, width=4,
+ justify=tk.RIGHT)
+ self._entry.grid(row=0, column=1, sticky=tk.NSEW)
+
+ self.columnconfigure(1, weight=1)
+ self.columnconfigure(2, weight=0)
+ self.rowconfigure(0, weight=1)
+
+ style = ttk.Style()
+ style.configure('Invalid.TEntry', foreground='#ff0000')
+ style.configure('Valid.TEntry', foreground='#00a000')
+ style.configure('Disabled.TEntry', foreground='#666666')
+ style.configure('Error.TLabel', foreground='#ff0000')
+
+ grade.subscribe(self._on_change_underlying_grade)
+ grade.subject.subscribe(self.update_average)
+ self._update_style()
+ self.update_average()
+
+ def _update_style(self):
+ if self.grade is None and self._entryvar.get():
+ self._entry['style'] = 'Invalid.TEntry'
+ self._indicatorvar.set('!!')
+ else:
+ self._entry['style'] = ('Valid.TEntry' if self.enabled
+ else 'Disabled.TEntry')
+ if self._entryvar.get():
+ self._indicatorvar.set('')
+
+ def update_average(self, subject=None, new_average=None):
+ '''Update the displayed average grade.'''
+ if new_average is None:
+ new_average = self._grade.subject.average_grade
+ if not self._entryvar.get():
+ self._indicatorvar.set('({:.1f})'.format(new_average))
+
+ def _on_change_grade(self, *_):
+ '''Called when the Entry's content changes. Sets the invalid flag.'''
+ if self._grade.value != self.grade:
+ self._grade.value = self.grade
+ self._update_style()
+
+ def _on_change_enabled(self, *_):
+ '''Called when the checkbox's status changes.'''
+ if self._grade.enabled != self.enabled:
+ self._grade.enabled = self.enabled
+ self._update_style()
+
+ def _on_change_underlying_grade(self, *_):
+ if self.grade != self._grade.value:
+ self._entryvar.set(self._grade.value)
+ if self.enabled != self._grade.enabled:
+ self._checkvar.set(self._grade.enabled)
+
+ @property
+ def grade(self):
+ '''Returns the currently entered grade, or None if it is invalid.'''
+ try:
+ in_grade = float(self._entryvar.get())
+ except ValueError:
+ return None
+ if self._is_valid_grade(in_grade):
+ return in_grade
+ return None
+
+ @property
+ def enabled(self):
+ '''Returns whether the grade was enabled by the user.'''
+ return self._checkvar.get()
diff --git a/result_display.py b/result_display.py
new file mode 100644
index 0000000..322f239
--- /dev/null
+++ b/result_display.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+
+'''Displays overall results based on the given grades.'''
+
+import tkinter as tk
+import tkinter.ttk as ttk
+from tkinter.font import Font, BOLD
+
+
+class ResultDisplay(ttk.LabelFrame):
+ '''A Frame that displays calculated results, by exposing StringVars.'''
+
+ def __init__(self, master, abitur):
+ bold_font = {'font': Font(weight=BOLD)}
+ title_label = ttk.Label(master, text='Ergebnis', **bold_font)
+ super().__init__(master, labelwidget=title_label)
+
+ total_points = tk.StringVar(self, 'N/A')
+ total_grade = tk.StringVar(self, 'N/A')
+ points_to_next_grade = tk.StringVar(self, 'N/A')
+ def update_totals(chg_subj=None, subj_avg=None):
+ total_points.set(abitur.total_points)
+ total_grade.set(abitur.total_grade)
+ points_to_next_grade.set(abitur.points_to_next_grade)
+ update_totals()
+ abitur.subscribe(update_totals)
+
+ spacing = {'padx': 5, 'pady': 5, 'sticky': tk.NSEW}
+ for i, (text, var) in enumerate([
+ ('Gesamtpunktzahl', total_points),
+ ('Gesamtnote', total_grade),
+ ('Punkte bis zur nächsthöheren Note', points_to_next_grade),
+ ]):
+ ttk.Label(self, text=text, **bold_font) \
+ .grid(row=i, column=0, **spacing)
+ ttk.Label(self, textvariable=var).grid(row=i, column=1, **spacing)
diff --git a/subject.py b/subject.py
new file mode 100644
index 0000000..de58f3d
--- /dev/null
+++ b/subject.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+
+'''Represents a subjects and its grades.'''
+
+from collections import OrderedDict
+
+from watchable import Watchable
+
+TERM_GRADE, EXAM_GRADE = 'term', 'exam'
+
+
+class Subject(Watchable):
+ '''Represents a subject, storing its grades.'''
+
+ def __init__(self, name, group):
+ super().__init__()
+ self.name, self.group = name, group
+ self._grades = {TERM_GRADE: OrderedDict(), EXAM_GRADE: OrderedDict()}
+ self._average_grade = self._exam_points = self._term_points = \
+ self._num_enabled_terms = 0
+
+ def _update(self):
+ def valid_grades(grade_type):
+ return [g.value for g in self._grades[grade_type].values()
+ if g.value is not None]
+ def enabled(grade_type):
+ return [g for g in self._grades[grade_type].values() if g.enabled]
+ def value_or_avg(grade):
+ return self.average_grade if grade.value is None else grade.value
+
+ valid = valid_grades(TERM_GRADE) + valid_grades(EXAM_GRADE)
+ self._average_grade = sum(valid) / len(valid) if valid else 0
+ enabled_term, enabled_exam = map(enabled, (TERM_GRADE, EXAM_GRADE))
+ self._exam_points = int(4 * sum(map(value_or_avg, enabled_exam)))
+ self._term_points = int(sum(map(value_or_avg, enabled_term)))
+ self._num_enabled_terms = len(enabled_term)
+ self._update_subscribers(self, self._average_grade)
+
+ @property
+ def term_grades(self):
+ '''A shallow copy of the OrderedDict of term grades.'''
+ return self._grades[TERM_GRADE].copy()
+ @term_grades.setter
+ def term_grades(self, value):
+ for term in self._grades[TERM_GRADE]:
+ self.remove_grade(term, TERM_GRADE)
+ for term, grade in value.items():
+ self.set_grade(term, grade, TERM_GRADE)
+
+ @property
+ def exam_grades(self):
+ '''A shallow copy of the OrderedDict of exam grades.'''
+ return self._grades[EXAM_GRADE].copy()
+ @exam_grades.setter
+ def exam_grades(self, value):
+ for exam in self._grades[EXAM_GRADE]:
+ self.remove_grade(exam, EXAM_GRADE)
+ for exam, grade in value.items():
+ self.set_grade(exam, grade, EXAM_GRADE)
+
+ def remove_grade(self, term, grade_type):
+ if term not in self._grades[grade_type]:
+ raise KeyError(term)
+ self._grades[grade_type][term].unsubscribe(self._update)
+ del self._grades[grade_type][term]
+
+ def set_grade(self, term, grade, grade_type):
+ if term in self._grades[grade_type]:
+ self.remove_grade(term, grade_type)
+ grade.subscribe(self._update)
+ self._grades[grade_type][term] = grade
+
+ @property
+ def average_grade(self):
+ '''Gives the average of all non-None term and exam grades.'''
+ return self._average_grade
+
+ @property
+ def exam_points(self):
+ '''Calculates the points towards the final result gained from exams.'''
+ return self._exam_points
+
+ @property
+ def term_points(self):
+ '''Calculates the points towards the final result from term grades.'''
+ return self._term_points
+
+ @property
+ def num_enabled_terms(self):
+ '''Calculates how many of this subject's term grades are enabled.'''
+ return self._num_enabled_terms
+
+
+class Term:
+ def __init__(self, name):
+ self.name = name
+
+
+class Grade(Watchable):
+ '''Represents a term or exam grade.'''
+
+ def __init__(self, subject, term, value, enabled):
+ super().__init__()
+ self.subject, self.term = subject, term
+ self._value = float(value) if value is not None else value
+ self._enabled = bool(enabled)
+
+ @property
+ def value(self):
+ return self._value
+ @value.setter
+ def value(self, value):
+ if value is not None:
+ self._value = float(value)
+ else:
+ self._value = value
+ self._update_subscribers()
+
+ @property
+ def enabled(self):
+ return self._enabled
+ @enabled.setter
+ def enabled(self, value):
+ self._enabled = bool(value)
+ self._update_subscribers()
diff --git a/subject_grade_table.py b/subject_grade_table.py
new file mode 100644
index 0000000..7372b2c
--- /dev/null
+++ b/subject_grade_table.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+
+'''A Tk Frame, pre-filled with gridded subjects and subject groups.'''
+
+import functools as ft
+import tkinter as tk
+import tkinter.ttk as ttk
+
+from grade_entry import GradeEntry
+
+class SubjectGradeTable(ttk.Frame):
+ '''A large Tk Frame pre-filled with subjects and their groups.'''
+
+ def __init__(self, master, subjects, *, grade_entry_options={},
+ grades=None, row_callback=None, header_rowspan=1,
+ pre_colspan=0, add_separators=False, separator_column=0,
+ separator_colspan=99):
+ super().__init__(master)
+ spacing = {'sticky': tk.NSEW, 'padx': 5, 'pady': 5}
+ sep_opts = {'orient': tk.HORIZONTAL}
+ sep_grid = {'column': separator_column, 'columnspan': separator_colspan,
+ **spacing}
+ row = header_rowspan
+ self.entries = {s: {} for s in subjects}
+
+ for group in ft.reduce(_dedupe, (s.group for s in subjects), []):
+ if add_separators:
+ ttk.Separator(self, **sep_opts).grid(row=row, **sep_grid)
+ self.rowconfigure(row, weight=0)
+ row += 1
+
+ subjects_in_gp = [s for s in subjects if s.group == group]
+ ttk.Label(self, text=group) \
+ .grid(row=row, column=pre_colspan, rowspan=len(subjects_in_gp),
+ **spacing)
+
+ for subj in subjects_in_gp:
+ self.rowconfigure(row, weight=0)
+ ttk.Label(self, text=subj.name) \
+ .grid(row=row, column=pre_colspan + 1, **spacing)
+
+ if grades and subj in grades:
+ for col, grade in enumerate(grades[subj]):
+ col += pre_colspan + 2
+ self.columnconfigure(col, weight=3)
+ GradeEntry(self, grade, **grade_entry_options) \
+ .grid(row=row, column=col, **spacing)
+
+ if row_callback:
+ numg = len(grades[subj]) if grades and subj in grades else 0
+ row_callback(row, 2 + pre_colspan + numg, subj)
+ row += 1
+
+ if add_separators:
+ ttk.Separator(self, **sep_opts).grid(row=row, **sep_grid)
+ self.rowconfigure(row, weight=0)
+
+
+def _dedupe(acc, element):
+ '''Update the accumulated list of unique items with the given element.'''
+ return acc if element in acc else acc + [element]
diff --git a/term_grades.py b/term_grades.py
new file mode 100644
index 0000000..c6cee1f
--- /dev/null
+++ b/term_grades.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+
+'''A Tk Frame for displaying and editing term grades.'''
+
+import tkinter as tk
+import tkinter.ttk as ttk
+from tkinter.font import Font, BOLD
+
+from subject_grade_table import SubjectGradeTable
+
+
+class TermGrades(SubjectGradeTable):
+ '''A large Tk Frame for entering subjects' term grades.'''
+
+ def __init__(self, master, abitur):
+ spacing = {'sticky': tk.NS, 'padx': 5, 'pady': 5}
+
+ def add_row_totals(row, col, subject):
+ total_terms_var = tk.StringVar(self, subject.num_enabled_terms)
+ total_points_var = tk.StringVar(self, subject.term_points)
+ for i, var in enumerate([total_terms_var, total_points_var]):
+ ttk.Label(self, textvariable=var) \
+ .grid(row=row, column=col + i, **spacing)
+ def update_total(subj, new_avg=None):
+ total_points_var.set(subj.term_points)
+ total_terms_var.set(subj.num_enabled_terms)
+ subject.subscribe(update_total)
+ update_total(subject)
+
+ grades = {s: s.term_grades.values() for s in abitur.subjects}
+ grade_entry = {'valid_grade': abitur.is_valid_grade}
+ super().__init__(master, abitur.subjects, header_rowspan=2,
+ add_separators=True, row_callback=add_row_totals,
+ grades=grades, grade_entry_options=grade_entry)
+
+ # column headings
+ hdr_kwargs = {'font': Font(weight=BOLD), 'justify': tk.CENTER}
+ ttk.Label(self, text='Aufgabenfeld', **hdr_kwargs) \
+ .grid(row=0, column=0, rowspan=2, **spacing)
+ ttk.Label(self, text='Fach', **hdr_kwargs) \
+ .grid(row=0, column=1, rowspan=2, **spacing)
+ ttk.Label(self, text='Halbjahresnoten', **hdr_kwargs) \
+ .grid(row=0, column=2, columnspan=len(abitur.terms), **spacing)
+ for i, term in enumerate(abitur.terms):
+ ttk.Label(self, text=term.name, **hdr_kwargs) \
+ .grid(row=1, column=i + 2, **spacing)
+ ttk.Label(self, text='eingebrachte\nHalbjahre', **hdr_kwargs) \
+ .grid(row=0, column=len(abitur.terms) + 2, rowspan=2, **spacing)
+ ttk.Label(self, text='Punkt-\nsumme', **hdr_kwargs) \
+ .grid(row=0, column=len(abitur.terms) + 3, rowspan=2, **spacing)
+
+ # totals row
+ overall_total_terms_var = tk.StringVar(self, abitur.num_enabled_terms)
+ overall_total_points_var = tk.StringVar(self, abitur.term_points)
+ def update_overall_total():
+ overall_total_points_var.set(abitur.term_points)
+ overall_total_terms_var.set(abitur.num_enabled_terms)
+ abitur.subscribe(update_overall_total)
+ ttk.Label(self, text='SUMME', **hdr_kwargs) \
+ .grid(row=99, column=0, columnspan=2 + len(abitur.terms), **spacing)
+ ttk.Label(self, textvariable=overall_total_terms_var) \
+ .grid(row=99, column=2 + len(abitur.terms), **spacing)
+ ttk.Label(self, textvariable=overall_total_points_var) \
+ .grid(row=99, column=3 + len(abitur.terms), **spacing)
+
+ self.rowconfigure(0, weight=0)
+ self.rowconfigure(1, weight=0)
+ self.rowconfigure(99, weight=0)
+ self.columnconfigure(0, weight=1)
+ self.columnconfigure(1, weight=1)
+ self.columnconfigure(len(abitur.terms) + 2, weight=1)
+ self.columnconfigure(len(abitur.terms) + 3, weight=1)
diff --git a/tooltip.py b/tooltip.py
new file mode 100644
index 0000000..a8518cf
--- /dev/null
+++ b/tooltip.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+
+'''
+The ToolTip class provides a flexible tooltip widget for tkinter;
+it is based on IDLE's ToolTip module.
+
+Michael Lange <klappnase@freakmail.de>
+http://tkinter.unpythonic.net/wiki/ToolTip
+https://github.com/bekar/tk_tooltip
+'''
+
+import tkinter as tk
+
+class ToolTip:
+ '''Provides a flexible tooltip widget for tkinter.'''
+
+ def __init__(self, master, **opts):
+ self.master = master
+ self._opts = {
+ 'anchor' : 'center',
+ 'bd' : 1,
+ 'bg' : 'lightyellow',
+ 'delay' : 1500,
+ 'fg' : 'black',
+ 'follow_mouse': False,
+ 'font' : None,
+ 'justify' : 'left',
+ 'padx' : 4,
+ 'pady' : 2,
+ 'relief' : 'solid',
+ 'state' : 'normal',
+ 'text' : None,
+ 'textvariable': None,
+ 'width' : 0,
+ 'wraplength' : 150
+ }
+ self.configure(**opts)
+ self._tipwindow = None
+ self._id = None
+ self._id1 = self.master.bind("<Enter>", self._enter, '+')
+ self._id2 = self.master.bind("<Leave>", self._leave, '+')
+ self._id3 = self.master.bind("<ButtonPress>", self._leave, '+')
+ self._follow_mouse = self._opts['follow_mouse']
+ if self._follow_mouse:
+ self._id4 = self.master.bind("<Motion>", self._motion, '+')
+
+ def configure(self, **opts):
+ '''Modifies one or more widget options.
+
+ If no options are given, the method returns a dictionary containing all
+ current option values. The changes will take effect the next time the
+ tooltip shows up.
+
+ **options
+ Widget options
+
+ anchor={'n', 's', 'e', 'screen_width', 'nw', ... }; Default is CENTER.
+ Where the text is placed inside the widget.
+
+ bd={integer}; Default is 1
+ The width of the widget border.
+ NOTE: don't use "borderwidth"
+
+ bg={string}; Default is "lightyellow"
+ background color to use for the widget
+ NOTE: don't use "background"
+
+ delay={integer}; Default is 1500 ms
+ delay for widget to appear when the mouse pointer hovers
+
+ fg={string}; Default is "black"
+ foreground (i.e. text) color to use;
+ NOTE: don't use "foreground"
+
+ follow_mouse={0, 1}
+ tooltip follows the mouse pointer; default = 0
+ NOTE: it cannot be changed after widget initialization
+
+ font={string, list}; Default is system specific
+ font to use for the widget
+
+ justify={"left", "right", "center"}; Default is "left"
+ multiple lines text alignment
+
+ padx={integer}; Default is 4
+ extra space added to the left and right within the widget
+
+ pady={integer}; Default is 2
+ extra space above and below the text
+
+ relief={"flat", "ridge", "groove", "raised", "sunken", "solid"}; Default is "solid"
+
+ state={"normal", "disabled"}; Default is "normal"
+ if set to "disabled" the tooltip will not appear
+
+ text={string}
+ the text that is displayed inside the widget
+
+ textvariable={StringVar() object}
+ if set to an instance of tkinter.StringVar() the variable's
+ value will be used as text for the widget
+
+ width={integer}; Default is 0, which means use "wraplength"
+ width of the widget
+
+ wraplength={integer}; Default is 150
+ limits the number of characters in each line
+ '''
+ for key in opts:
+ if key in self._opts:
+ self._opts[key] = opts[key]
+ else:
+ raise KeyError('KeyError: Unknown option: "%s"' % key)
+
+ def _enter(self, event=None): # handles <Enter> event
+ '''Callback when the mouse pointer enters the parent widget.'''
+ self._schedule()
+
+ def _leave(self, event=None): # handles <Leave> event
+ '''Called when the mouse pointer leaves the parent widget.'''
+ self._unschedule()
+ self._hide()
+
+ def _motion(self, event=None): # handles <Motion> event
+ '''Is called when the mouse pointer moves inside the parent.
+ '''
+ if self._tipwindow and self._follow_mouse:
+ self._tipwindow.wm_geometry("+%d+%d" % self._coords())
+
+ def _schedule(self):
+ self._unschedule()
+ if self._opts['state'] == 'disabled':
+ return
+ self._id = self.master.after(self._opts['delay'], self._show)
+
+ def _unschedule(self):
+ if self._id:
+ self.master.after_cancel(self._id)
+ self._id = None
+
+ def _show(self):
+ if self._opts['state'] == 'disabled':
+ self._unschedule()
+ return
+ if not self._tipwindow:
+ self._tipwindow = tw = tk.Toplevel(self.master)
+ # hide the window until we know the geometry
+ tw.withdraw()
+ tw.wm_overrideredirect(1)
+
+ if tw.tk.call("tk", "windowingsystem") == 'aqua':
+ tw.tk.call("::tk::unsupported::MacWindowStyle", "style", tw._w, "help", "none")
+
+ self._create_contents()
+ tw.update_idletasks()
+ tw.wm_geometry("+%d+%d" % self._coords())
+ tw.deiconify()
+
+ def _hide(self):
+ if self._tipwindow:
+ self._tipwindow.destroy()
+ self._tipwindow = None
+
+ def _coords(self):
+ '''Calculates the screen coordinates of the tooltip window widget.
+
+ It does this if follow_mouse is set to 1 and the tooltip has shown up
+ to continually update the coordinates of the tooltip window.
+ '''
+ # The tip window must be completely outside the master widget;
+ # otherwise when the mouse enters the tip window we get a leave event
+ # and it disappears, and then we get an enter event and it reappears,
+ # and so on forever :-( or we take care that the mouse pointer is
+ # always outside the tipwindow :-)
+ window_width = self._tipwindow.winfo_reqwidth()
+ window_height = self._tipwindow.winfo_reqheight()
+ screen_width = self._tipwindow.winfo_screenwidth()
+ screen_height = self._tipwindow.winfo_screenheight()
+ # calculate the y coordinate:
+ if self._follow_mouse:
+ y = self._tipwindow.winfo_pointery() + 20
+ # make sure the tipwindow is never outside the screen:
+ if y + window_height > screen_height:
+ y -= window_height + 30
+ else:
+ y = self.master.winfo_rooty() + self.master.winfo_height() + 3
+ if y + window_height > screen_height:
+ y = self.master.winfo_rooty() - window_height - 3
+ # we can use the same x coord in both cases:
+ x = self._tipwindow.winfo_pointerx() - window_width / 2
+ if x < 0:
+ x = 0
+ elif x + window_width > screen_width:
+ x = screen_width - window_width
+ return x, y
+
+ def _create_contents(self):
+ '''Creates the contents of the tooltip window.
+
+ By default, this is a tkinter.Label().
+ '''
+ opts = self._opts.copy()
+ for opt in ('delay', 'follow_mouse', 'state'):
+ del opts[opt]
+ label = tk.Label(self._tipwindow, **opts)
+ label.pack()
+
+
+def main():
+ '''Testing the module.'''
+ root = tk.Tk(className='ToolTip-demo')
+ root.bind('<Key-Escape>', lambda e: root.quit())
+
+ listbox = tk.Listbox(root)
+ listbox.pack(side='top')
+ listbox.insert('end', "I'm a listbox")
+ ToolTip(listbox, follow_mouse=1,
+ text="I'm a tooltip with follow_mouse set to 1, so I won't be "
+ "placed outside my parent")
+
+ button = tk.Button(root, text='Quit', command=root.quit)
+ button.pack(side='bottom')
+ ToolTip(button, text='Enough of this')
+
+ root.mainloop()
+
+if __name__ == '__main__':
+ main()
diff --git a/watchable.py b/watchable.py
new file mode 100644
index 0000000..bce2ae9
--- /dev/null
+++ b/watchable.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+
+'''Implements subscribing to changes to a class's data.'''
+
+class Watchable:
+ '''Allows others to register functions to be called when data changes.
+
+ Child classes must call _update_subscribers to trigger this.
+ '''
+
+ def __init__(self):
+ self._listeners = []
+
+ def subscribe(self, callback):
+ '''Register a callback to be called when the class's data changes.'''
+ self._listeners.append(callback)
+
+ def unsubscribe(self, callback):
+ '''Remove the registered callback.'''
+ self._listeners.remove(callback)
+
+ def _update_subscribers(self, *args, **kwargs):
+ '''Call each registered callback with the given arguments.'''
+ for callback in self._listeners:
+ callback(*args, **kwargs)