import tkinter import datetime import math import time COLOR_BACKGROUND = '#000' COLOR_FONT = '#666' COLOR_DROPDOWN = '#aaa' COLOR_DROPDOWN_ACTIVE = '#999' MODE_CLOCK = 'clock' MODE_COUNTDOWN = 'countdown' MODE_STOPWATCH = 'stopwatch' # Monospace fonts work best FONT = 'Consolas' # Used for resizing the clock font to fit the frame. # For consolas, 1.33333 is a good value. It may differ # for other fonts FONT_YX_RATIO = 1.33333 MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'] MONTHS_DICT = {'jan':1, 'feb':2, 'mar':3, 'apr':4, 'may':5, 'jun':6, 'jul':7, 'aug':8, 'sep':9, 'oct':10, 'nov':11, 'dec':12} class Clock: def __init__(self): self.t = tkinter.Tk() self.t.configure(bg=COLOR_BACKGROUND) self.mode_methods = { MODE_CLOCK: self.build_gui_clock, MODE_COUNTDOWN: self.build_gui_countdown, MODE_STOPWATCH: self.build_gui_stopwatch, } self.dropstring = tkinter.StringVar(self.t) self.dropstring.trace('w', self.trigger_choose_mode) modes = list(self.mode_methods.keys()) modes.sort(key=lambda x: x.lower()) self.drop = tkinter.OptionMenu(self.t, self.dropstring, *modes) self.drop.configure(relief='flat', bg=COLOR_DROPDOWN, activebackground=COLOR_DROPDOWN_ACTIVE, direction='below', highlightthickness=0, anchor='w') self.drop.pack(fill='x', anchor='n') #self.trigger_choose_mode() self.frame_applet = tkinter.Frame(self.t, width=400, height=95, bg=COLOR_BACKGROUND) self.frame_applet.pack(side='bottom', anchor='s', expand=True, fill='both') self.frame_applet.pack_propagate(0) self.frame_applet.grid_propagate(0) # tkinter elements belonging to any particular applet. # They will be destroyed when switching modes. # The dropdown and the frame_applet are not included here. self.elements = [] # Instance is used to keep track of how many times we have # swapped modes. # This is to make sure that any `self.t.after` loops are broken # when we leave that mode. self.instance = 0 self.dropstring.set(MODE_CLOCK) self.t.mainloop() @property def mode(self): return self.drop.cget('text') '''clock ###### ### ##### ###### ### ### ####### ### ####### ####### ### ### ### ### ### ### ### ##### ####### ####### ####### ####### ### ### ###### ####### ##### ###### ### ### ''' def build_gui_clock(self): ''' A clock using the system's local time in 24 hour format. ''' def tick_clock(): if this_instance != self.instance: # used to break the "after" loop return #now = datetime.datetime.now() #now = now.strftime('%H:%M:%S') now = time.strftime('%H:%M:%S') label_clock.configure(text=now) self.t.after(1000, tick_clock) this_instance = self.instance label_clock = tkinter.Label(self.frame_applet, text='x', bg=COLOR_BACKGROUND, fg=COLOR_FONT) label_clock.pack(anchor='center', expand=True) self.elements.append(label_clock) tick_clock() self.frame_applet.bind('', lambda event: self.resize_widget_font(self.frame_applet, label_clock)) self.resize_widget_font(self.frame_applet, label_clock) '''countdown ###### ##### ### ### ##### ####### ####### ####### ### ### ####### ####### ### ### ### ### ### ### ### ### ####### ####### ####### ### ### ### ###### ##### ##### ### ### ### ''' def build_gui_countdown(self): ''' A timer with two modes: 1. "in" - countdown a certain amount of time. Ex "5 hours" This mode can be paused and resumed and the timer will pick up where it left off 2. "until" - countdown until a certain moment. Ex "13 august 2015 15:00" In this mode, pausing only affects the display. Resuming the clock will skip to maintain the correct end time. ''' def toggle_mode(): reset_countdown() if label_countdown.mode is 'until': # "in" mode does not need the dd mmm yyyy boxes for item in elements_until: item.grid_forget() # "in" mode can have arbitrary hours spinbox_hour.configure(to=999) label_countdown.mode = 'in' else: spinbox_day.grid(row=0, column=1) spinbox_month.grid(row=0, column=2) spinbox_year.grid(row=0, column=3) spinbox_hour.configure(to=23) label_countdown.mode = 'until' button_countdownmode.configure(text=label_countdown.mode) def tick_countdown(): if this_instance != self.instance: return if not label_countdown.is_running: return now = time.time() if now > label_countdown.destination: reset_countdown() return until_dest = label_countdown.destination - now hours, minutes, seconds = self.hms_divmod(until_dest) display = '%02d:%02d:%04.1f' % (hours, minutes, seconds) display = display.replace('60.0', '00.0') previous_size = len(label_countdown.cget('text')) label_countdown.configure(text=display) if len(display) != previous_size: self.resize_widget_font(frame_display, label_countdown) self.t.after(100, tick_countdown) def start_countdown(): if label_countdown.mode is 'until': if label_countdown.destination is None: try: d = int(spinbox_day.get()) mo = MONTHS_DICT[spinbox_month.get().lower()] y = int(spinbox_year.get()) h = int(spinbox_hour.get()) m = int(spinbox_minute.get()) s = int(spinbox_second.get()) strp = '%d %s %d %d %d %d' % (d, mo, y, h, m, s) strp = datetime.datetime.strptime(strp, '%d %m %Y %H %M %S') label_countdown.destination = strp.timestamp() except ValueError: return else: now = time.time() if label_countdown.destination is None: try: h = int(spinbox_hour.get()) m = int(spinbox_minute.get()) s = int(spinbox_second.get()) label_countdown.destination = now + (3600*h) + (60*m) + (s) except ValueError: return else: # check how long we were paused, then increase the # destination by that much so the countdown doesn't skip. backlog = now - label_countdown.backlog label_countdown.destination += backlog label_countdown.is_running = True button_toggle.configure(text='stop') tick_countdown() def stop_countdown(): # Store the timestamp when the countdown was paused # so that when it resumes, we know how long we were asleep label_countdown.backlog = time.time() label_countdown.is_running = False button_toggle.configure(text='start') def reset_countdown(): stop_countdown() label_countdown.configure(text='00:00:00.0') label_countdown.destination = None label_countdown.backlog = 0 def toggle_countdown(): if label_countdown.is_running: stop_countdown() else: start_countdown() def reset_spinboxes(): for item in (spinbox_hour, spinbox_minute, spinbox_second, spinbox_day, spinbox_month, spinbox_year): item.configure(bg=COLOR_BACKGROUND, fg=COLOR_FONT, buttonbackground=COLOR_DROPDOWN, activebackground=COLOR_DROPDOWN_ACTIVE) item.delete(0, 'end') spinbox_hour.insert(0, 0) spinbox_minute.insert(0, 0) spinbox_second.insert(0, 0) spinbox_day.insert(0, time.strftime('%d')) spinbox_month.insert(0, time.strftime('%b').lower()) spinbox_year.insert(0, time.strftime('%Y')) this_instance = self.instance elements_until = [] frame_display = tkinter.Frame(self.frame_applet, bg=COLOR_BACKGROUND) frame_controls = tkinter.Frame(self.frame_applet, bg=COLOR_BACKGROUND) frame_spinboxes = tkinter.Frame(frame_controls, bg=COLOR_BACKGROUND) label_countdown = tkinter.Label(frame_display, text='00:00:00.0', bg=COLOR_BACKGROUND, fg=COLOR_FONT) # Although this says 'until', the applet will start in # 'in' mode because I use the toggle towards the end to # jog everything into place. label_countdown.mode = 'until' label_countdown.destination = None label_countdown.backlog = 0 label_countdown.is_running = False button_countdownmode = tkinter.Button(frame_controls, text='0', command=toggle_mode, bg=COLOR_DROPDOWN, activebackground=COLOR_DROPDOWN_ACTIVE) button_countdownmode.configure(width=5) button_toggle = tkinter.Button(frame_controls, text='start', command=toggle_countdown, bg=COLOR_DROPDOWN, activebackground=COLOR_DROPDOWN_ACTIVE) button_reset = tkinter.Button(frame_controls, text='reset', command=reset_countdown, bg=COLOR_DROPDOWN, activebackground=COLOR_DROPDOWN_ACTIVE) spinbox_hour = tkinter.Spinbox(frame_spinboxes, from_=0, to=999, width=3) spinbox_minute = tkinter.Spinbox(frame_spinboxes, from_=0, to=59, width=2) spinbox_second = tkinter.Spinbox(frame_spinboxes, from_=0, to=59, width=2) spinbox_day = tkinter.Spinbox(frame_spinboxes, from_=0, to=31, width=2) spinbox_month = tkinter.Spinbox(frame_spinboxes, values=MONTHS, width=4) spinbox_year = tkinter.Spinbox(frame_spinboxes, from_=2015, to=9999, width=4) reset_spinboxes() self.frame_applet.rowconfigure(0, weight=1) self.frame_applet.columnconfigure(0, weight=1) frame_controls.columnconfigure(1, weight=1) frame_display.grid(row=0, column=0, sticky='news') label_countdown.pack(anchor='center', expand=True) frame_controls.grid(row=1, column=0, sticky='ew') button_countdownmode.grid(row=0, column=0, sticky='ew') frame_spinboxes.grid(row=0, column=1, sticky='ew') spinbox_hour.grid(row=0, column=4) spinbox_minute.grid(row=0, column=5) spinbox_second.grid(row=0, column=6) # the day month year spinboxes are gridded # during the toggle_mode method. button_reset.grid(row=0, column=7) button_toggle.grid(row=0, column=8) self.elements.append(frame_display) self.elements.append(label_countdown) self.elements.append(frame_controls) self.elements.append(button_countdownmode) self.elements.append(button_toggle) self.elements.append(button_reset) self.elements.append(frame_spinboxes) self.elements.append(spinbox_hour) self.elements.append(spinbox_minute) self.elements.append(spinbox_second) self.elements.append(spinbox_day) self.elements.append(spinbox_month) self.elements.append(spinbox_year) elements_until.append(spinbox_day) elements_until.append(spinbox_month) elements_until.append(spinbox_year) toggle_mode() self.frame_applet.bind('', lambda event: self.resize_widget_font(frame_display, label_countdown)) self.t.update() self.resize_widget_font(frame_display, label_countdown) '''stopwatch ##### ####### ##### ###### ####### ####### ####### ### ### ### ### ### ### ###### ####### ### ####### ### ##### ### ##### ### ''' def build_gui_stopwatch(self): ''' A timer that counts upward from 0. ''' def tick_stopwatch(): if this_instance != self.instance: return if not label_stopwatch.is_running: return # started_at is reset on every press of the resume button # so we keep track of how much was on the clock last time # and add it. elapsed = time.time() - label_stopwatch.started_at elapsed += label_stopwatch.backlog hours, minutes, seconds = self.hms_divmod(elapsed) #seconds, centi = divmod(seconds, 100) display = '%02d:%02d:%06.3f' % (hours, minutes, seconds) display = display.replace('60.0', '00.0') previous_size = len(label_stopwatch.cget('text')) label_stopwatch.configure(text=display) if len(display) != previous_size: self.resize_widget_font(frame_display, label_stopwatch) self.t.after(10, tick_stopwatch) def toggle_stopwatch(): if label_stopwatch.is_running: stop_stopwatch() else: start_stopwatch() def stop_stopwatch(): if label_stopwatch.started_at is not None: # This check is important in case we press the "reset" # button without having started the clock yet. elapsed = time.time() - label_stopwatch.started_at # Keep track of how long the clock ran so we can # pick up from here when we resume. label_stopwatch.backlog += elapsed label_stopwatch.started_at = None label_stopwatch.is_running = False button_toggle.configure(text='start') def start_stopwatch(): label_stopwatch.started_at = time.time() label_stopwatch.is_running = True button_toggle.configure(text='stop') tick_stopwatch() def reset_stopwatch(): stop_stopwatch() label_stopwatch.backlog = 0 label_stopwatch.configure(text='00:00:00.000') this_instance = self.instance frame_display = tkinter.Frame(self.frame_applet, bg=COLOR_BACKGROUND) frame_controls = tkinter.Frame(self.frame_applet, bg=COLOR_BACKGROUND) label_stopwatch = tkinter.Label(frame_display, text='00:00:00.000', bg=COLOR_BACKGROUND, fg=COLOR_FONT) label_stopwatch.started_at = None # Backlog keeps track of how much time was on the watch when we stopped it # So when we start it again, the counter starts from 0 and we add the backlog # to get a total. label_stopwatch.backlog = 0 label_stopwatch.is_running = False button_toggle = tkinter.Button(frame_controls, text='start', command=toggle_stopwatch, bg=COLOR_DROPDOWN, activebackground=COLOR_DROPDOWN_ACTIVE) button_reset = tkinter.Button(frame_controls, text='reset', command=reset_stopwatch, bg=COLOR_DROPDOWN, activebackground=COLOR_DROPDOWN_ACTIVE) self.frame_applet.rowconfigure(0, weight=1) self.frame_applet.columnconfigure(0, weight=1) frame_display.grid(row=0, column=0, sticky='news') label_stopwatch.pack(anchor='center', expand=True) frame_controls.grid(row=1, column=0, sticky='ew') frame_controls.columnconfigure(0, weight=1) frame_controls.columnconfigure(1, weight=1) button_toggle.grid(row=0, column=1, sticky='ew') button_reset.grid(row=0, column=0, sticky='ew') self.elements.append(frame_display) self.elements.append(frame_controls) self.elements.append(label_stopwatch) self.elements.append(button_toggle) self.elements.append(button_reset) self.frame_applet.bind('', lambda event: self.resize_widget_font(frame_display, label_stopwatch)) # Update so that the window width and height are correct # when we call resize_widget_font self.t.update() self.resize_widget_font(frame_display, label_stopwatch) '''other ##### ####### ### ### ####### ###### ####### ####### ### ### ##### ### ### ### ### ### ####### ### ###### ####### ### ### ### ##### ### ### ##### ### ### ### ####### ### ### ''' def delete_applet_elements(self): self.frame_applet.unbind('') for x in range(len(self.elements)): self.elements.pop().destroy() self.frame_applet.rowconfigure(0, weight=0) self.frame_applet.columnconfigure(0, weight=0) def resize_widget_font(self, parent, subordinate): ''' Given a frame and a subordinate widget, resize the font of the widget to best fit the bounds of the frame. ''' # When initializing the widgets, the width and height is usually 1, 1. # Must use t.update to fix everything. self.t.update() frame_w = parent.winfo_width() frame_h = parent.winfo_height() #print(frame_w, frame_h) text = subordinate.cget('text') font = self.font_by_pixels(frame_w, frame_h, text) subordinate.configure(font=font) def font_by_pixels(self, frame_w, frame_h, text): ''' Given the size of a bounding box, find the best font size to fit text in the bounds. ''' lines = text.split('\n') label_w = max(len(line) for line in lines) label_h = len(lines) # Padding to not look dumb frame_h -= 20 # At 72 ppi, 1 point = 1 pixel height point_heightbased = int(frame_h / label_h) # but width requires involving the ratio point_widthbased = int(frame_w * FONT_YX_RATIO / label_w) point_smaller = min(point_widthbased, point_heightbased) point_smaller = max(point_smaller, 1) font = (FONT, point_smaller) return font def hms_divmod(self, amount): hours, minutes = divmod(amount, 3600) minutes, seconds = divmod(minutes, 60) return (hours, minutes, seconds) def trigger_choose_mode(self, *args): ''' This method is fired when the drop-down menu item is selected. It will choose which build_gui method to use based on the value of the optionmenu text. ''' mode = self.mode self.t.title(mode) method = self.mode_methods[mode] self.instance += 1 self.delete_applet_elements() method() c = Clock()