def __post_init__(self): names = [ 'percent_text', 'fail_text', 'pass_text', 'label', ] self._names, spot = names[:-1], 1 / len(names) self._last_settings = dict() pass_pct = self.constants['pct'] == 'pass' self._make_pct = self._pass_pct if pass_pct else self._fail_pct self._normal_colors = (COLORS.green, COLORS.blue, COLORS.green) self._checking_colors = tuple([COLORS.white] * 3) [[ self._load(Label( self, anchor='center', fg=RESOURCE.cfg('view').get('old_colors').get('metrics')[name], bg=COLORS.medium_grey), f'{name}_{row}', x=spot * column, y=.5 * row, height=.5, width=spot) for column, name in enumerate(names) ] for row in range(2)] [ getattr(self, f'label_{row}').text(f) for row, f in zip(range(2), ['h', 'd']) ] [self._set_row('text', ('-', '-', '-%'), i) for i in range(2)] self._numbers = -1, -1, -1, -1 self.publish(GetMetricsMessage())
def __init__(self, parent: tk.Tk) -> None: """ get bindings from ini, clear state, and perform initial event binding """ self.name = self.__class__.__name__ self.parent = parent self.check_parent() self.constants = RESOURCE.cfg('view')['hid'].get(self.name.lower()) self.bindings: Dict[str, Dict[str, str]] = self.constants['bindings'] self.__post_init__() self._active = True self.bind()
def __init__(self) -> None: """ lean init that only sets runtime constants and Tk init """ log.debug('instantiating Window...') self.name = self.__class__.__name__ self.constants = RESOURCE.cfg('view')['window'] self.w_px, self.h_px = APP.STATION.resolution self.widgets_by_position = dict() self.categories = self.categories or dict() self.poll_scheduled = None tk.Tk.__init__(self) log.debug('instantiated Window')
class History(Cell): """ show MN and SN of last tested units on this station """ # ? https://stackoverflow.com/a/31766128 # ? https://www.tutorialspoint.com/python/tk_listbox.htm _dt_ft: str = RESOURCE.cfg('view')['widget_constants']['Time']['DT_FORMAT'] _pad: int = 2 _last_results_fresh: bool _current_button_width: Optional[int] vbar_packed: bool def __post_init__(self): # initialize state variables self.configure(bg=COLORS.black) self._create_kws = dict(pady=self._pad) self._kws = dict( compound=tk.LEFT, relief='flat', overrelief='flat', disabledforeground=COLORS.white, state='disabled', fg=COLORS.white, bg=COLORS.medium_grey, bd=0, ) self._pass_ids = set() self._fail_ids = set() self._day_ids = set() self._day_sns = dict() self._hour_ids = set() self._model_ids = collections.defaultdict(set) self._lines = dict() self._displayed_ids = set() self.__last_results = set() self._elisions = dict() self._buttons = list() self._id_to_button_map = dict() self._p_f_dict = { HistoryPassFail.pass_string: self._pass_ids, HistoryPassFail.fail_string: self._fail_ids } self._len_f = HistoryLength.initial_setting self._pf_f = HistoryPassFail.initial_setting self._model_setting = HistoryPartNumber.initial_setting self._recency_f = HistoryRecency.initial_setting self._last_results_fresh = True self._current_button_width = None self.scrolled_width = None self._midnight = datetime.datetime.combine( datetime.date.today(), datetime.datetime.min.time()) self.history_frame = tk.Frame(self) self.vbar = Scrollbar(self) # make text field bg_ = COLORS.black self.field = tk.Text( self, state='disabled', wrap='char', relief='flat', cursor='arrow', fg=COLORS.white, bg=bg_, selectbackground=bg_, inactiveselectbackground=bg_, yscrollcommand=self.vbar.set, pady=0, padx=0, ) self.vbar['command'] = self.field.yview self.pack_history(True) self.history_frame.pack(fill=tk.BOTH, expand=1) # noinspection SpellCheckingInspection self._h = self.font.metrics('linespace') # make pass/fail glyphs _ts = [ (0, 0, 0) ] + [self.constants['glyph'][k]['color'] for k in ('pass', 'fail')] self._glyphs = { k: self._make_image(make_circle_glyph(120, .5, c), (self._h, self._h)) for k, c in zip([None, True, False], _ts) } self.enable() self.last_button_selected = None def _button_selected(self, record: _RECORD, button: tk.Button) -> None: """ called when an entry is double-clicked """ if self.last_button_selected: try: self.last_button_selected.configure(bg=COLORS.medium_grey) except tk.TclError: # last button can be gone before it's reverted pass button.configure(bg=COLORS.black) self.last_button_selected = button log.info(f'button {record["id"]} selected') def _make_entry(self, record: _RECORD) -> tk.Button: if self._current_button_width: self._kws['width'] = self._current_button_width button = tk.Button( self, **self._kws, image=self._glyphs[record["pf"]], text=f' {record["sn"]} - {record["dt"].strftime(self._dt_ft)}', ) button['command'] = partial(self._button_selected, record=record, button=button) self._buttons.append(button) self._id_to_button_map[record['id']] = button return button def add_record(self, id: int, pf: bool, dt: datetime, mn: str, sn: str) -> None: """ add one record to the appropriate categories """ id_ = id # happened today if dt > self._midnight: self._day_ids.add(id_) record = dict(id=id_, pf=pf, dt=dt, mn=mn, sn=sn) self._lines[id_] = record # happened in the last hour # _timestamp = dt + datetime.timedelta(hours=1) # TODO remove this vvv _timestamp = dt + datetime.timedelta(minutes=15) _now = datetime.datetime.now() if _timestamp > _now: self._hour_ids.add(id_) _timestamp -= _now _timestamp: datetime self.after(int(_timestamp.total_seconds() * 1000), self.remove_from_hour, id_) # passed or failed (self._pass_ids if pf else self._fail_ids).add(id_) # categorize by model number self._model_ids[mn].add(id_) # most recent result for this sn v = self._day_sns.setdefault(sn, (id_, dt)) if v[1] < dt: self._day_sns[sn] = v self._last_results_fresh = True # create and tag visible record representation self.field.window_create(1.0, window=self._make_entry(record), **self._create_kws) self.field.tag_add(str(id_), 1.0) def _last_results(self) -> _RECORD_ID_SET: """ extract the record ids from the most recent results dict """ if self._last_results_fresh: self.__last_results = set([v[0] for v in self._day_sns.values()]) self._last_results_fresh = False return self.__last_results def filter_predicate(self, cls) -> bool: """ determine whether to apply a filter from the button widget's class attributes """ return getattr(self, cls._history_attr) != cls.initial_setting def filter_for_display(self) -> _RECORD_ID_SET: """ use set intersection to find units that satisfy applicable filters """ filters = list() _filter_predicate = self.filter_predicate if _filter_predicate(HistoryLength): filters.append(self._hour_ids) if _filter_predicate(HistoryPassFail): filters.append(self._p_f_dict[self._pf_f]) if _filter_predicate(HistoryPartNumber): filters.append(self._model_ids[self._model_setting]) if _filter_predicate(HistoryRecency): filters.append(self._last_results()) return self._day_ids.intersection(*filters) def update_other_widgets(self) -> None: """ update the available selections for the four history select buttons update the numbers on the metrics widget in the top right """ self.parent_widget(HistoryPartNumber).set_options( list(self._model_ids.keys())) self.parent_widget(HistoryPassFail).set_options( [s for s, o in self._p_f_dict.items() if o]) self.parent_widget(HistoryLength).set_options(self._hour_ids) self.parent_widget(HistoryRecency).set_options( self._last_results() != self._day_ids) hr_ = self._hour_ids self.parent_widget(Metrics).set_numbers( fail_day=len(self._fail_ids), pass_day=len(self._pass_ids), fail_hour=len(self._fail_ids.intersection(hr_)), pass_hour=len(self._pass_ids.intersection(hr_)), ) def button_size(self) -> None: for button in self._buttons: try: button.configure(width=self._current_button_width) except Exception as e: log.warning(str(e)) def change_elision(self, _ids: _RECORD_ID_SET) -> None: for name in self._day_ids: do_elide = name not in _ids _last = self._elisions.setdefault(name, None) if _last is None or (do_elide ^ _last): self.field.tag_configure(str(name), elide=do_elide) self._elisions[name] = do_elide self.update() def pack_forget_widget(self, widget) -> None: _ = self try: widget.pack_forget() except Exception as e: log.warning(str(e)) def pack_history(self, scrollable: bool) -> None: """ forget appropriate widget(s) pack field or field and vbar """ self.pack_forget_widget(self.field) if scrollable: self.vbar.pack(in_=self.history_frame, side=tk.LEFT, fill=tk.Y) self.field.pack(in_=self.history_frame, side=tk.RIGHT, fill=tk.BOTH, expand=1) else: self.pack_forget_widget(self.vbar) self.field.pack(in_=self.history_frame, fill=tk.BOTH, expand=1) self._current_button_width = self.scrolled_width if scrollable else self.width self._kws['width'] = cast(int, self._current_button_width) self.vbar_packed = scrollable self.button_size() def _vbar_visibility(self) -> None: _conditions = [ self.field.yview() == (0., 1.), self.vbar.winfo_ismapped() ] if all(_conditions): self.pack_history(False) elif not any(_conditions): self.pack_history(True) def filter_and_update(self) -> None: """ filter with selections, sort by date, update widget with button(s), schedule update """ _new_ids = self.filter_for_display() self.update_other_widgets() self.change_elision(_new_ids) self._vbar_visibility() def _initialize_history(self, lines: List[Dict[str, Any]]) -> None: """ bulk process test history records """ # remove any existing records # delete until end-1 to avoid inserting a newline at the end of the field self.field.delete('1.0', 'end-1c') # tags aren't garbage collected over the whole life of the application # so they need to be explicitly destroyed [ self.field.tag_delete(name) for name in self.field.tag_names() if name != 'sel' ] # clear state self._pass_ids.clear() self._fail_ids.clear() self._day_ids.clear() self._hour_ids.clear() self._model_ids.clear() self._lines.clear() self._displayed_ids.clear() self._day_sns.clear() self._buttons.clear() self._elisions.clear() self._id_to_button_map.clear() self._midnight = datetime.datetime.combine( datetime.date.today(), datetime.datetime.min.time()) # make sub lists of record ids by category [self.add_record(**line) for line in lines] def change_contents(self, f: Callable, *arg) -> None: """ perform an operation one one fresh or stale record """ self.disable() _prev_state, self.field['state'] = self.field['state'], 'normal' try: f(*arg) finally: # filter and update regardless of success self.filter_and_update() self.field['state'] = _prev_state self.field.update() self.vbar.update() self.enable() @subscribe(HistoryAddEntryMessage) def add_one_to_history(self, id: int, pf: bool, dt: datetime, mn: str, sn: str) -> None: """ add a record to the top of the text widget """ self.change_contents(self.add_record, id, pf, dt, mn, sn) def remove_from_hour(self, record_id: int) -> None: """ this is the callback that cleans a unit from the hour list """ if record_id in self._hour_ids: self.change_contents(self._hour_ids.remove, record_id) @subscribe(HistorySetAllMessage) def initialize_history(self, records: List[HistoryAddEntryMessage]) -> None: """ check for new data perform init and then update display """ self.change_contents(self._initialize_history, records) def _on_show(self) -> None: """ make entry Button kwargs with winfo_width """ self.width = self.winfo_width() self.scrolled_width = self.width - self.vbar.winfo_width() self._kws.update( dict(height=self._h, width=self.scrolled_width, font=self.font)) @subscribe(HistorySelectEntryMessage) def select_button(self, id_: int) -> None: self._id_to_button_map[id_].invoke() @staticmethod def double_click(evt: tk.EventType) -> None: """ if double click lands on a button (history entry), invoke it """ _button = evt.widget # type: ignore invoke = getattr(_button, 'invoke', None) if callable(invoke): with_enabled(_button)(invoke)()
class Cell(tk.Frame, register.Mixin): """ wrapper around tk.Frame define _click(action) | _drag(action) to respond to mouse events in a widget """ __font: tk.font.Font = None parent: View is_enabled: bool = True content: tk.Label = None _view_config = RESOURCE.cfg('view') _enable_state_vars = {True: (COLORS.medium_grey, 'enabled'), False: (COLORS.dark_grey, 'disabled'), } _fresh_interval_ms: int = _view_config['window']['fresh_data_interval_ms'] _greyscale = _view_config['grey'] _fresh_sequence: List[str] = [ tuple_to_hex_color((v, v, v)) for v in range(_greyscale['light'], _greyscale['medium'], -1) ] category: Category subscribed_methods: Dict[Type, str] def parent_widget(self, cls: Type[_T], *, fail_silently: bool = False) -> _T: try: return getattr(self.parent, cls.__name__.lower()) except AttributeError: if not fail_silently: raise @register.after('__init__') def _add_subscribed_methods_to_parent(self): for _t, method_name in getattr(self, 'subscribed_methods', {}).items(): self.parent.subscribed_methods[_t].add((self.name, method_name)) @property def publish(self): return self.parent.publish @property def session_manager(self) -> SessionManager: return self.parent.session_manager @property def font(self) -> tk.font.Font: if not self.__font: f: int = self._view_config['FONTSIZE'][self.__class__.__name__.upper()] self.__font = self._make_font(f) if f else self.parent.font.tk_font return self.__font @font.setter def font(self, font: tk.font.Font) -> None: self.__font = font # SUPPRESS-LINTER <super().__init__ called immediately after first call of show()> # noinspection PyMissingConstructor def __init__(self, name: str, parent: View, x: float, y: float, w: float, h: float, ) -> None: """ calc relative dimensions using padding from ini calc actual frame dimensions in pixels for hid.Mouse make widget default font """ self.parent = parent self.name = name self.pos = x, y, w, h self._made = False self.labels = list() self.scheduled_reference = None self._normal_bg = None self.constants = self._view_config['widget_constants'].get(self.__class__.__name__, {}) self._fresh_index = None def __post_init__(self): """ perform subclass-specific setup here set to initial state, request controller update if necessary """ raise NotImplementedError @do_if_not_done('made', True) def _cell_make(self) -> None: """ compute dimensions and call overridden make() """ tk.Frame.__init__(self, self.parent) self.config(bg=COLORS.dark_grey) # compute relative dimensions x, y, w, h = self.pos _y_pad = self._view_config['window']['PADDING'] _x_pad = _y_pad / self.parent.screen.w_h_ratio _y_shift, _x_shift = _y_pad if y == 0 else 0, _x_pad if x == 0 else 0 _rel_x = x + _x_shift _rel_y = y + _y_shift _rel_width = (w - _x_pad) - _x_shift _rel_height = (h - _y_pad) - _y_shift # dimensions relative where (1,1) is the bottom right of the Window self._place_dimensions = dict(relx=_rel_x, rely=_rel_y, relwidth=_rel_width, relheight=_rel_height) # compute absolute dimensions self.x_co = int(_rel_x * self.parent.w_px) self.y_co = int(_rel_y * self.parent.h_px) self.w_co = int(_rel_width * self.parent.w_px) self.h_co = int(_rel_height * self.parent.h_px) # dimensions of this widget in pixels self.co_dimensions_raw = (self.x_co, self.x_co + self.w_co), (self.y_co, self.y_co + self.h_co) self.__post_init__() def _remove_previous(self): """ if a Cell exists in the same position, removes it """ previous_widget = self.parent.widgets_by_position.get(self.pos, None) if previous_widget: previous_widget.hide() def _before_show(self) -> None: """ called before the last widget is removed """ def _on_show(self): """ called just after place() start update scheduler etc """ @do_if_not_done('showed', True) def show(self): """ place frame on Window using relative values calculated in __init__ if a widget already exists in this position, hides it first """ self._cell_make() self._before_show() self._remove_previous() self.parent.widgets_by_position[self.pos] = self self.place(**self._place_dimensions) self.update() self._on_show() def _on_hide(self): """ executes just before hide() """ @do_if_not_done('showed', False) def hide(self): """ remove frame from Window """ self.cancel_scheduled() self._on_hide() self.place_forget() def _before_destroy(self): """ executes just before Tk.Frame.destroy() """ def destroy(self): """ overridden as a template hook """ try: self.cancel_scheduled() self._before_destroy() except Exception as e: print(self.name, e, 'in close') super().destroy() # # # appearance methods def _set_background(self, color: str) -> None: """ set the background color of self and label children """ _last_color = getattr(self, '__last_color', None) if _last_color is None or _last_color != color: self.config(bg=color) [label.color(bg=color) for label in self.labels] setattr(self, '__last_color', color) def _toggle_enabled(self, is_enabled: bool) -> None: """ change frame background to color from ini """ color, s = self._enable_state_vars[is_enabled] self.parent.after_idle(self._set_background, color) log.debug(f'window widget <{self.name}> {s}') self.is_enabled = is_enabled def enable(self, *args, **kwargs) -> None: """ expose partial _toggle_enabled to callers """ _, _ = args, kwargs self._toggle_enabled(True) def disable(self, *args, **kwargs) -> None: """ expose partial _toggle_enabled to callers """ _, _ = args, kwargs self._toggle_enabled(False) def _make_more_stale(self) -> None: """ darken from light to enabled on interval, then enable """ self._fresh_index += 1 try: self.parent.after_idle(self._set_background, self._fresh_sequence[self._fresh_index]) # self._set_background(self._fresh_sequence[self._fresh_index]) self.schedule(self._fresh_interval_ms, self._make_more_stale) except IndexError: self.schedule(self._fresh_interval_ms, self.enable) def fresh_data(self): """ indicate fresh data with temporary background highlight debounce fresh data re-fetch per ini """ self._fresh_index = -1 self._make_more_stale() # # # schedule methods def schedule(self, interval: int, callback: Callable, *args): """ calls after() and registers reference for future cancelling """ self.scheduled_reference = self.parent.after(interval, callback, *args) def cancel_scheduled(self): """ cancels scheduled callback if any """ if self.scheduled_reference: try: self.parent.after_cancel(self.scheduled_reference) except Exception as e: log.error(str(e)) # # # load methods def _load(self, tk_widget, name='content', **kwargs): """ remove existing content and pack new child widget to fill entire frame return child widget """ existing = getattr(self, name, None) if existing: for method in ['forget', 'pack_forget', 'destroy']: # SUPPRESS-LINTER <don't care if this fails> # noinspection PyBroadException try: getattr(existing, method)() except Exception as _: pass setattr(self, name, tk_widget) if kwargs: getattr(self, name).place(**kwargs) else: getattr(self, name).pack(fill=tk.BOTH, expand=1) return getattr(self, name) def _make_font(self, size: int, family: str = None) -> Font: """ make new Tk font instance for widget, font name can be overridden if different from parent's """ return Font(family=family or self.parent.font.name, size=size) @staticmethod def _make_image(fp: Union[Path, str], dimensions_in_pixels: Tuple[int, ...] = None) -> ImageTk.PhotoImage: """ take filepath from ini resize if necessary return TK-compatible object to be packed in frame """ img = PIL.Image.open(str(fp)) if dimensions_in_pixels: img = img.resize(dimensions_in_pixels) return ImageTk.PhotoImage(img)
class Mouse(Binding): """ encapsulates mouse event handling for the Window """ _release_methods = ['click', 'drag_h', 'drag_v'] press_event: Optional[tk.EventType] = None co_dimensions: Optional[Tuple[Tuple[int, ...], ...]] = None parent_methods = { 'update', 'winfo_rootx', 'winfo_rooty', } # calculate drag angle categories and drag length threshold from view ini __responsiveness = RESOURCE.cfg('view')['hid']['mouse']['responsiveness'] _drag = int(__responsiveness['CLICK_SWIPE_THRESHOLD_PX']) # square click threshold once, here, to speed up mouse action filter _drag_sq = _drag**2 __fudge = __responsiveness['DISTANCE_FROM_RIGHT_ANGLE_DEGREES_ALLOWED'] _directions: Dict[int, MouseAction] = dict() for deg in range(-180, 181): for _min, _max, direction in ( (-__fudge, __fudge, MouseAction.RIGHT), (-180, -(180 - __fudge), MouseAction.LEFT), ((180 - __fudge), 180, MouseAction.LEFT), (-90 - __fudge, -90 + __fudge, MouseAction.TOP), (90 - __fudge, 90 + __fudge, MouseAction.BOTTOM), ): if _min <= deg < _max: _directions[deg] = direction def clear(self) -> None: """ reset initial press state """ self.press_event, self.co_dimensions = None, None def validate(self, evt: tk.EventType): """ if mouse action is within the bounds of a widget, rather than in the padding, return the widget """ if self._active and evt.widget: cell = evt.widget.master if cell is not None: if getattr(cell, 'is_enabled', False): return cell # initial event handlers def press(self, evt: tk.EventType) -> str: """ handles every press event, including the first and second in a double click """ _cell = self.validate(evt) if _cell: # persist the event for other handlers self.press_event = evt # set widget root bounds from current window dimensions self.parent.update() x, y = self.parent.winfo_rootx(), self.parent.winfo_rooty() (x0, x1), (y0, y1) = _cell.co_dimensions_raw self.co_dimensions = (x + x0, x + x1), (y + y0, y + y1) # stop event handling chain return 'break' def double_click(self, evt: tk.EventType) -> None: """ pass double click event to cell if handler is defined disregards initial press state """ cell = self.validate(evt) if cell: call(cell, 'double_click', evt) self.clear() # gates: secondary handler if True @property def initial_target(self): """ secondary events should only proceed if initial state has been set returns target cell widget """ _cell = self.press_event if _cell: if self._active: return _cell.widget.master self.clear() def is_in_bounds(self, x_root: int, y_root: int) -> bool: """ determine if non-first event in sequence is still within first event's widget """ try: (x_min, x_max), (y_min, y_max) = self.co_dimensions except TypeError: return False else: return (x_min < x_root < x_max) and (y_min < y_root < y_max) # secondary event handlers def release(self, evt: tk.EventType) -> None: """ perform all calculations only on mouse release handle if all conditions are met by press - release pair clear state regardless """ cell = self.initial_target if cell: click, drag_h, drag_v = [ getattr(cell, k, None) for k in self._release_methods ] if click or drag_h or drag_v: # unpack release coordinates x, y = evt.x_root, evt.y_root if self.is_in_bounds(x, y): # if release is in pressed widget's bounds, calculate distances traveled in x and y dx, dy, _th = x - self.press_event.x_root, y - self.press_event.y_root, self._drag if (dx > _th) or (dy > _th) or ( (dx**2 + dy**2) > self._drag_sq): if drag_h or drag_v: # if mouse traveled far enough, determines drag angle _d = self._directions.get( int(math.degrees(math.atan2(dy, dx))), None) if _d: # if drag angle sufficiently vertical or horizontal, handles drag # by direction in cell _dir = _d.direction if drag_h and _dir == 'horizontal': drag_h(_d) elif drag_v and _dir == 'vertical': drag_v(_d) elif click: # handle as click in cell if it hasn't dragged past threshold click() call(cell, 'on_release') self.clear()