diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rwxr-xr-x | __init__.py | 123 | ||||
| -rw-r--r-- | abitur.py | 114 | ||||
| -rw-r--r-- | error_label.py | 33 | ||||
| -rw-r--r-- | exam_chooser.py | 22 | ||||
| -rw-r--r-- | exam_grades.py | 72 | ||||
| -rw-r--r-- | grade_entry.py | 101 | ||||
| -rw-r--r-- | result_display.py | 36 | ||||
| -rw-r--r-- | subject.py | 125 | ||||
| -rw-r--r-- | subject_grade_table.py | 61 | ||||
| -rw-r--r-- | term_grades.py | 72 | ||||
| -rw-r--r-- | tooltip.py | 228 | ||||
| -rw-r--r-- | watchable.py | 25 |
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__/ @@ -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) |
