class Configurator(tk.Tk): """ The main Tk window representing the main app. Attributes ---------- treeview : :py:class:`~tkinter.Treeview` The treeview widget. treeview_popup_target_id : `int` The pop target id relating to the id of the selected element. treeview_popup : :py:class:`~tkinter.Widget` The treeview popup widget. cfg_file_name : `str` The file name of the current configuration. element_dict : `dict` The dictionary of elements. Keys are the element ids. root_element : :py:class:`~enrich2.base.storemanager.StoreManager` An instance inheriting from storemanager that acts as a root object. force_recalculate :py:class:`tkinter.BooleanVar` The tkinter boolean variable for this option. component_outliers :py:class:`tkinter.BooleanVar` The tkinter boolean variable for this option. tsv_requested : :py:class:`tkinter.BooleanVar` The tkinter boolean variable for this option. treeview_buttons : `list` The ``new``, ``edit`` and ``delete`` buttons. go_button : :py:class`~tkinter.ttk.Button` The button that begins the analysis scorer_widget : :py:class:`~enrich2.gui.options_frame.ScorerScriptsDropDown` The ScorerScriptsDropDown instance associated with this app. scorer : :py:class:`~enrich2.plugins.scoring.BaseScorerPlugin` The scorer class loaded from a plugin scorer_attrs : `dict` The scoring attributes for the plugin. scorer_path : `str` The path to the currently selected scoring plugin. analysis_thread : :py:class:`~threading.Thread` The thread object that runs the computation method to prevent GUI blocking. Methods ------- create_main_frame create_menubar create_treeview_context_menu create_new_element menu_open menu_save menu_saveas menu_selectall refresh_treeview treeview_context_menu set_treeview_properties populate_tree go_button_press new_button_press edit_button_press delete_button_press delete_element apply_seqlib_fastq get_element get_focused_element get_selected_elements get_selected_scorer_class get_selected_scorer_attrs get_selected_scorer_path run_analysis set_gui_state configure_analysis refresh_plugins show_plugin_source_window See Also -------- :py:class:`~tkinter.Tk` """ def __init__(self): tk.Tk.__init__(self) self.title("Enrich 2") # Main app variables self.cfg_file_name = tk.StringVar() self.element_dict = dict() self.root_element = None self.analysis_thread = None self.plugin_source_window = None self.queue = queue.Queue() # Treeview variables self.treeview = None self.treeview_popup_target_id = None self.treeview_popup = None # analysis options self.force_recalculate = tk.BooleanVar() self.component_outliers = tk.BooleanVar() self.tsv_requested = tk.BooleanVar() # allow resizing self.rowconfigure(0, weight=1) self.columnconfigure(0, weight=1) # create UI elements self.treeview_buttons = [] self.go_button = None self.scorer_widget = None self.scorer = None self.scorer_attrs = None self.scorer_path = None self.create_main_frame() self.create_menubar() self.create_treeview_context_menu() self.after(10, self.poll_logging_queue) self.plugin_source_window = SourceWindow(master=self) self.plugin_source_window.hide() self.refresh_plugins() # ---------------------------------------------------------------------- # # Creation Methods # ---------------------------------------------------------------------- # def create_treeview_context_menu(self): """ This creates the tree-like view rendering the experiment heirachy. """ self.treeview_popup = tk.Menu(self, tearoff=0) self.treeview_popup.add_command(label="Apply FASTQ...", command=self.apply_seqlib_fastq) def create_main_frame(self): """ Large function creating all the basic elements of the main app frame. Creates the treeview and associated buttons, the scoring plugin frame and the go button. """ # Frame for the Treeview and New/Edit/Delete buttons main = Frame(self, padding=(3, 3, 12, 12)) main.rowconfigure(0, weight=1) main.columnconfigure(0, weight=1) main.columnconfigure(1, weight=0) main.grid(row=0, column=0, sticky="nsew") # ------------------------------------------------------- # # Frame for the Treeview and its scrollbars tree_frame = Frame(main, padding=(3, 3, 12, 12)) tree_frame.rowconfigure(0, weight=1) tree_frame.rowconfigure(1, weight=0) tree_frame.columnconfigure(0, weight=1) tree_frame.columnconfigure(1, weight=0) tree_frame.grid(row=0, column=0, sticky="nsew") # ------------------------------------------------------- # # Treeview with column headings self.treeview = Treeview(tree_frame) self.treeview["columns"] = ("class", "barcodes", "variants") self.treeview.column("class", width=120) self.treeview.heading("class", text="Type") self.treeview.column("barcodes", width=25, stretch=tk.NO, anchor=tk.CENTER) self.treeview.heading("barcodes", text="BC") self.treeview.column("variants", width=25, stretch=tk.NO, anchor=tk.CENTER) self.treeview.heading("variants", text="V") self.treeview.grid(row=0, column=0, sticky="nsew") # Treeview context menu bindings self.treeview.bind("<Button-2>", self.treeview_context_menu) # Treeview scrollbars tree_ysb = tk.Scrollbar(tree_frame, orient="vertical", command=self.treeview.yview) tree_xsb = tk.Scrollbar(tree_frame, orient="horizontal", command=self.treeview.xview) tree_ysb.grid(row=0, column=1, sticky="nsw") tree_xsb.grid(row=1, column=0, sticky="ewn") self.treeview.config(yscroll=tree_ysb.set, xscroll=tree_xsb.set) # ------------------------------------------------------- # # Frame for New/Edit/Delete buttons button_frame = Frame(main, padding=(3, 3, 12, 12)) button_frame.grid(row=1, column=0) new_button = Button(button_frame, text="New...", command=self.new_button_press) new_button.grid(row=0, column=0) edit_button = Button(button_frame, text="Edit...", command=self.edit_button_press) edit_button.grid(row=0, column=1) delete_button = Button(button_frame, text="Delete", command=self.delete_button_press) delete_button.grid(row=0, column=2) self.treeview_buttons = [new_button, delete_button, edit_button] # ------------------------------------------------------- # # Frame for Plugin and Analysis Options right_frame = Frame(main, padding=(3, 3, 12, 12)) right_frame.rowconfigure(0, weight=1) right_frame.rowconfigure(1, weight=0) right_frame.columnconfigure(0, weight=1) right_frame.columnconfigure(1, weight=0) right_frame.grid(row=0, column=1, sticky="new") # ------------------------------------------------------- # # LabelFrame for plugin and options scoring_plugin = ScorerScriptsDropDown(right_frame, text="Scoring Options", padding=(3, 3, 12, 12)) scoring_plugin.grid(row=0, column=0, sticky="new") self.scorer_widget = scoring_plugin # ------------------------------------------------------- # # LabelFrame for Analysis Options row = 0 options_frame = LabelFrame(right_frame, text="Analysis Options", padding=(3, 3, 12, 12)) options_frame.grid(row=1, column=0, sticky="new", pady=4) # force recalculate force_recalculate = Checkbutton(options_frame, text="Force Recalculation", variable=self.force_recalculate) force_recalculate.grid(column=0, row=row, sticky="w") row += 1 # component outliers component_outliers = Checkbutton( options_frame, text="Component Outlier Statistics", variable=self.component_outliers, ) component_outliers.grid(column=0, row=row, sticky="w") row += 1 # write tsv tsv_requested = Checkbutton(options_frame, text="Write TSV Files", variable=self.tsv_requested) tsv_requested.grid(column=0, row=row, sticky="w") tsv_requested.invoke() row += 1 # ------------------------------------------------------- # # Run Analysis button frame go_button_frame = Frame(main, padding=(3, 3, 12, 12)) go_button_frame.grid(row=1, column=1) go_button = Button(go_button_frame, text="Run Analysis", command=self.go_button_press) go_button.grid(column=0, row=0) self.go_button = go_button def create_new_element(self): """ Create and return a new element based on the current selection. This element is not added to the treeview. """ element = None parent_element = self.get_focused_element() if isinstance(parent_element, Experiment): element = Condition() element.parent = parent_element elif isinstance(parent_element, Condition): element = Selection() element.parent = parent_element elif isinstance(parent_element, Selection): element = CreateSeqLibDialog(self).element_type() element.parent = parent_element elif isinstance(parent_element, SeqLib): # special case: creates a copy of the selected SeqLib as a sibling element = type(parent_element)() element.configure(parent_element.serialize()) element.parent = parent_element.parent # clear out the seqlib-specific values element.name = None element.timepoint = None element.counts_file = None element.reads = None else: raise ValueError("Unrecognized parent object " "type '{}'".format(type(parent_element))) return element def create_menubar(self): """ Creates the menubar for the main app, with associated drop down menus. """ # make platform-specific keybinds if platform.system() == "Darwin": accel_string = "Command+" accel_bind = "Command-" else: accel_string = "Ctrl+" accel_bind = "Control-" # create the menubar menubar = tk.Menu(self) # file menu filemenu = tk.Menu(menubar, tearoff=0) filemenu.add_command( label="Open...", accelerator="{}O".format(accel_string), command=self.menu_open, ) filemenu.add_command(label="Save", accelerator="{}S".format(accel_string), command=self.menu_save) filemenu.add_command( label="Save As...", accelerator="{}Shift+S".format(accel_string), command=self.menu_saveas, ) menubar.add_cascade(label="File", menu=filemenu) # edit menu filemenu = tk.Menu(menubar, tearoff=0) filemenu.add_command( label="Select All", accelerator="{}A".format(accel_string), command=self.menu_selectall, ) menubar.add_cascade(label="Edit", menu=filemenu) # tools menu filemenu = tk.Menu(menubar, tearoff=0) filemenu.add_command( label="Show Log", accelerator="{}L".format(accel_string), command=show_log_window, ) filemenu.add_command( label="Plugin Sources", accelerator="{}P".format(accel_string), command=self.show_plugin_source_window, ) filemenu.add_command( label="Refresh Plugins", accelerator="{}R".format(accel_string), command=self.refresh_plugins, ) menubar.add_cascade(label="Tools", menu=filemenu) # add the menubar self.config(menu=menubar) # add file menu keybinds self.bind("<{}o>".format(accel_bind), lambda event: self.menu_open()) self.bind("<{}s>".format(accel_bind), lambda event: self.menu_save()) self.bind("<{}Shift-s>".format(accel_bind), lambda event: self.menu_saveas()) # add edit menu keybinds self.bind("<{}a>".format(accel_bind), lambda event: self.menu_selectall()) # add show log menu keybinds # add edit menu keybinds self.bind("<{}l>".format(accel_bind), lambda event: show_log_window()) self.bind("<{}p>".format(accel_bind), lambda event: self.show_plugin_source_window()) self.bind("<{}r>".format(accel_bind), lambda event: self.refresh_plugins()) # ---------------------------------------------------------------------- # # Treeview Methods # ---------------------------------------------------------------------- # def treeview_context_menu(self, click): """ Sets the currently selected treeview object id in the variable ``treeview_popup_target_id``. Parameters ---------- click : tkinter click event """ target = self.treeview.identify_row(click.y) if target != "": self.treeview_popup_target_id = target self.treeview_popup.post(click.x_root, click.y_root) self.treeview_popup_target_id = None def apply_seqlib_fastq(self): """ Applies settings to the seqlib object by running the configuration method. """ SeqLibApplyDialog(self, self, self.treeview_popup_target_id) def new_button_press(self): """ Spawns a dialog box depending on the currently selected treeview item to create a new element. """ if self.treeview.focus() == "" and self.root_element is not None: tkinter.messagebox.showwarning(None, "No parent element selected.") else: if self.treeview.focus() == "" and self.root_element is None: element = CreateRootDialog(self).element if isinstance(element, SeqLib): EditDialog(self, self, element) self.root_element = element else: element = self.create_new_element() EditDialog(self, self, element) # refresh the treeview and re-assign treeview id's self.refresh_treeview() # select the newly added element if it was successfully added if element.treeview_id in list(self.element_dict.keys()): self.treeview.focus(element.treeview_id) self.treeview.selection_set(element.treeview_id) else: if element.parent is not None: self.treeview.focus(element.parent.treeview_id) self.treeview.selection_set(element.parent.treeview_id) del element def edit_button_press(self): """ Spawns a dialog box depending on the currently selected treeview item to edit the selected element. """ if self.treeview.focus() == "": tkinter.messagebox.showwarning(None, "No element selected.") else: EditDialog(self, self, self.get_focused_element()) def delete_button_press(self): """ Deletes the selected treeview element and it's children. """ if self.treeview.focus() == "": tkinter.messagebox.showwarning(None, "No element selected.") else: DeleteDialog(self, self) def delete_element(self, tree_id): """ Delete element with Treeview id *tree_id* from the tree, from the element dictionary, and from the associated data structure. Recursively deletes all children of *tree_id*. The tree should be refreshed using :py:meth:`refresh_tree` after each deletion. This is the responsibility of the caller. Parameters ---------- tree_id : `int` The id of the currently selected treeview element. """ if tree_id in self.element_dict: # recursively delete children if self.element_dict[tree_id].children is not None: for child in self.element_dict[tree_id].children: self.delete_element(child.treeview_id) # check if deleting the root element if self.root_element.treeview_id == tree_id: # clear the root element print("None {}".format(tree_id)) self.root_element = None else: try: # remove the element from its parent's list of children self.element_dict[tree_id].parent.remove_child_id(tree_id) except AttributeError: raise AttributeError( "Non-root element lacks proper parent") # delete the element from the dictionary del self.element_dict[tree_id] def refresh_treeview(self): """ Clears the Treeview and repopulates it with the current contents of the tree. """ # clear the entries in the Treeview for x in self.treeview.get_children(): self.treeview.delete(x) # clear the id-element dictionary # elements may be given new id's after repopulation self.element_dict.clear() # repopulate if self.root_element is not None: self.populate_tree(self.root_element) def set_treeview_properties(self, element): """ Set the information text for the Treeview *element*. Parameters ---------- element : :py:class:`~enrich2.base.storemanager.StoreManager` The storemanager object to configure. """ # set class property self.treeview.set(element.treeview_id, "class", element.treeview_class_name) # add the check marks for barcodes/variants if "variants" in element.labels: self.treeview.set(element.treeview_id, "variants", u"\u2713") else: self.treeview.set(element.treeview_id, "variants", "") if "barcodes" in element.labels: self.treeview.set(element.treeview_id, "barcodes", u"\u2713") else: self.treeview.set(element.treeview_id, "barcodes", "") self.treeview.set(element.treeview_id, "class", element.treeview_class_name) def populate_tree(self, element, parent_id=""): """ Recursively populate the Treeview. Also populates the *id_cfgstrings*. Parameters ---------- element : :py:class:`~enrich2.base.storemanager.StoreManager` The storemanager object to configure. parent_id : `int` ``treeview_id`` of element's parent. """ # insert into the Treeview element.treeview_id = self.treeview.insert(parent_id, "end", text=element.name, open=True) # add id-element pair to dictionary self.element_dict[element.treeview_id] = element # set information fields self.set_treeview_properties(element) # populate for children if element.children is not None: for child in element.children: self.populate_tree(child, parent_id=element.treeview_id) # ---------------------------------------------------------------------- # # Getter Methods # ---------------------------------------------------------------------- # def get_selected_scorer_class(self): """ Returns the currently selected scoring class object. """ return self.scorer def get_selected_scorer_attrs(self): """ Returns the currently selected scoring class attribute `dict`. """ return self.scorer_attrs def get_selected_scorer_path(self): """ Returns the currently selected scoring path. """ return self.scorer_path def get_element(self, treeview_id): """ Returns the element with *treeview_id*. Parameters ---------- treeview_id : `int` ``treeview_id`` attribute of element to get. Returns ------- :py:class:`~enrich2.base.storemanager.StoreManager` The instance with matching ``treeview_id`` """ return self.element_dict[treeview_id] def get_focused_element(self): """ Gets the focused element in the treeview. Returns ------- :py:class:`~enrich2.base.storemanager.StoreManager` Returns the element that is currently being focused in the Treeview. ``None`` if nothing is focused. """ if self.treeview.focus() != "": return self.get_element(self.treeview.focus()) else: return None def get_selected_elements(self): """ Returns a list of currently selected elements in the treeview. Returns ------- `list` Returns a list of elements that are currently selected in the Treeview. If no elements are selected, it returns an empty list. """ return [self.get_element(x) for x in self.treeview.selection()] # ---------------------------------------------------------------------- # # Menubar Methods # ---------------------------------------------------------------------- # def menu_open(self): """ Spawns an `askopenfilename` dialog to open a configuration file. """ message_title = "Open Configuration" fname = tkinter.filedialog.askopenfilename() if len(fname) > 0: # file was selected try: with open(fname, "rU") as handle: cfg = json.load(handle) except ValueError: tkinter.messagebox.showerror(message_title, "Failed to parse config file.") except IOError: tkinter.messagebox.showerror(message_title, "Could not read config file.") else: if is_experiment(cfg): obj = Experiment() elif is_selection(cfg): obj = Selection() elif is_seqlib(cfg): sltype = seqlib_type(cfg) obj = globals()[sltype]() else: tkinter.messagebox.showerror( message_title, "Unrecognized config format.") return obj.output_dir_override = False try: if isinstance(obj, Experiment) or isinstance( obj, Selection): obj.configure(cfg, init_from_gui=True) else: obj.configure(cfg) # Try load the scorer into the GUI scorer_path = cfg.get(SCORER, {}).get(SCORER_PATH, "") scorer_attrs = cfg.get(SCORER, {}).get(SCORER_OPTIONS, {}) if scorer_path: self.scorer_widget.load_from_cfg_file( scorer_path, scorer_attrs) else: log_message( logging_callback=logging.warning, msg="No plugin could be loaded from configuration.", extra={"oname": self.__class__.__name__}, ) except Exception as e: tkinter.messagebox.showerror( message_title, "Failed to load config file:\n\n{}".format(e)) else: self.root_element = obj self.cfg_file_name.set(fname) self.refresh_treeview() def menu_save(self): """ Asks the user where to save the current configuration. """ if len(self.cfg_file_name.get()) == 0: self.menu_saveas() elif self.root_element is None: tkinter.messagebox.showwarning("Save Configuration", "Cannot save empty configuration.") else: save = askyesno("Save Configuration", "Overwrite existing configuration?") if not save: return try: with open(self.cfg_file_name.get(), "w") as handle: cfg = self.root_element.serialize() # Get the currently selected scorer if not isinstance(self.root_element, SeqLib) and not isinstance( self.root_element, Condition): ( _, attrs, scorer_path, ) = self.scorer_widget.get_scorer_class_attrs_path() cfg[SCORER] = { SCORER_PATH: scorer_path, SCORER_OPTIONS: attrs } write_json(cfg, handle) except IOError: tkinter.messagebox.showerror("Save Configuration", "Failed to save config file.") else: tkinter.messagebox.showinfo( "Save Configuration", "Saved file at location:\n\n{}".format( self.cfg_file_name.get()), ) def menu_saveas(self): """ Asks the user where to save the current configuration. """ if self.root_element is None: tkinter.messagebox.showwarning("Save Configuration", "Cannot save empty configuration.") else: fname = tkinter.filedialog.asksaveasfilename() if len(fname) > 0: # file was selected try: with open(fname, "w") as handle: cfg = self.root_element.serialize() # Get the currently selected scorer if not isinstance(self.root_element, SeqLib) and not isinstance( self.root_element, Condition): ( _, attrs, scorer_path, ) = self.scorer_widget.get_scorer_class_attrs_path( ) cfg[SCORER] = { SCORER_PATH: scorer_path, SCORER_OPTIONS: attrs, } write_json(cfg, handle) except IOError: tkinter.messagebox.showerror( "Save Configuration", "Failed to save config file.") else: self.cfg_file_name.set(fname) tkinter.messagebox.showinfo( "Save Configuration", "Saved file at location:\n\n{}".format( self.cfg_file_name.get()), ) def menu_selectall(self): """ Add all elements in the Treeview to the selection. """ for k in self.element_dict.keys(): self.treeview.selection_add(k) def show_plugin_source_window(self): """ Show the pop-up window to modify plugin sources """ if not self.plugin_source_window: self.plugin_source_window = SourceWindow(master=self) else: self.plugin_source_window.toggle_show() # ---------------------------------------------------------------------- # # Run Analysis Methods # ---------------------------------------------------------------------- # def go_button_press(self): """ Starts the analysis if all elements have been properly configured. This will run the analysis in a new thread and block out GUI editing to prevent the analysis breaking. """ ( self.scorer, self.scorer_attrs, self.scorer_path, ) = self.scorer_widget.get_scorer_class_attrs_path() if self.scorer is None or self.scorer_attrs is None: tkinter.messagebox.showwarning("Incomplete Configuration", "No scoring plugin selected.") elif self.root_element is None: tkinter.messagebox.showwarning( "Incomplete Configuration", "No experimental design specified.") else: plugin, *_ = self.scorer_widget.get_selected_plugin() if plugin.md5_has_changed(): proceed = askokcancel( "Selected plugin has been modified.", "The selected plugin has been modified on disk. Do you " "want to proceed with the current version? To see changes " "click 'Cancel' and refresh plugins before proceeding.", ) if not proceed: return if askyesno( "Save Configuration?", "Would you like to save the confiugration " "file before proceeding?", ): self.menu_save() run = askyesno( "Begin Analysis?", "Click Yes when you are ready to start.\n\nThis could " "take some time so grab a cup of tea, or a beer if that's " "your thing, and enjoy the show.", ) if run: self.configure_analysis() self.set_gui_state(tk.DISABLED) thread = threading.Thread(target=self.run_analysis) thread.setDaemon(True) self.analysis_thread = thread self.analysis_thread.start() self.after(100, self.poll_analysis_thread) def poll_logging_queue(self): """ Polls the logging queue for messages to log. """ try: log = get_logging_queue(init=True).get(0) log[CALLBACK](log[MESSAGE], **log[KWARGS]) self.after(10, self.poll_logging_queue) except queue.Empty: self.after(10, self.poll_logging_queue) def poll_analysis_thread(self): """ Polls the thread to check it's state. When it is finished, all stores are closed. """ try: analysis_result = self.queue.get(0) self.handle_analysis_result(analysis_result) except queue.Empty: self.after(100, self.poll_analysis_thread) def handle_analysis_result(self, success): """ Shows the appropriate messagebox and logs exceptions upon analysis completing. Parameters ---------- success : `bool` Exception object if an error occured during analysis, otherwise None to indicate successful computation. """ log_message( logging_callback=logging.info, msg="Closing stores...", extra={"oname": self.root_element.name}, ) self.root_element.store_close(children=True) log_message( logging_callback=logging.info, msg="Stores closed.", extra={"oname": self.root_element.name}, ) if success: showinfo("Analysis completed.", "Analysis has completed successfully!") log_message( logging_callback=logging.info, msg="Completed successfully!", extra={"oname": self.root_element.name}, ) else: showwarning( "Error during analysis.", "An error occurred during the analysis. See log for details", ) log_message( logging_callback=logging.info, msg="Completed, but with errors!", extra={"oname": self.root_element.name}, ) self.set_gui_state(tk.NORMAL) def run_analysis(self): """ Runs the storemanager compute method. """ try: self.root_element.validate() self.root_element.store_open(children=True) self.root_element.calculate() if self.root_element.tsv_requested: self.root_element.write_tsv() self.queue.put(True, block=False) except Exception as exception: log_message( logging_callback=logging.exception, msg=exception, extra={"oname": self.root_element.name}, ) self.queue.put(False, block=False) finally: return def configure_analysis(self): """ Configures the attributes of the root_element by querying the GUI options. """ try: self.root_element.force_recalculate = self.force_recalculate.get() self.root_element.component_outliers = self.component_outliers.get( ) self.root_element.tsv_requested = self.tsv_requested.get() scorer_class = self.get_selected_scorer_class() scorer_class_attrs = self.get_selected_scorer_attrs() scorer_path = self.get_selected_scorer_path() self.root_element.scorer_class = scorer_class self.root_element.scorer_class_attrs = scorer_class_attrs self.root_element.scorer_path = scorer_path except Exception as e: log_message( logging_callback=logging.info, msg="An error occurred when trying to configure the " "root element.", extra={"oname": self.root_element.name}, ) log_message( logging_callback=logging.exception, msg=e, extra={"oname": self.root_element.name}, ) # ---------------------------------------------------------------------- # # GUI Modifications # ---------------------------------------------------------------------- # def set_gui_state(self, state): """ Sets the state of the `go_button`, `treeview` and `treeview_buttons`. Parameters ---------- state : `str` State to set, usually ``'normal'`` or ``'disabled'`` """ for btn in self.treeview_buttons: btn.config(state=state) self.go_button.config(state=state) if state == "normal": self.treeview.bind("<Button-2>", self.treeview_context_menu) else: self.treeview.bind("<Button-2>", lambda event: event) def refresh_plugins(self): """ Refresh the plugins by re-checking the sources file. """ if self.plugin_source_window: sources = self.plugin_source_window.sources self.scorer_widget.refresh_sources(sources)
class NameView(object): """Shows a treeview of unique names.""" def __init__(self, master, names): self.widget = Frame(master) self._tree = Treeview(self.widget, columns='name') self._tree.grid(row=0, column=0, sticky=(N, S, W, E)) self._tree.view = self self.widget.columnconfigure(0, weight=1) self.widget.rowconfigure(0, weight=1) self._tree.column('name', width=50) self._tree['show'] = 'tree' actions = { 'edit': lambda e: self.edit(), 'search': lambda e: self.search(), 'focus_next': lambda e: self.focus_next(), 'focus_prev': lambda e: self.focus_prev(), 'select': lambda e: self._tree.selection_toggle(self._tree.focus()), 'clear_selection': lambda e: self._tree.selection_set([]) } kb.make_bindings(kb.tagview, actions, self._tree.bind) self._iids = dict() self._names = dict() logger.debug('Names: %s', names) self.widget.focus_set = self._tree.focus_set for name in sorted(names): iid = self._tree.insert('', 'end', text=name) self._names[iid] = name self._iids[name] = iid self._scroll = Scrollbar(self.widget, command=self._tree.yview) self._tree['yscrollcommand'] = self._scroll.set self._scroll.grid(row=0, column=1, sticky=(N, S)) self.widget.columnconfigure(1, weight=0) def selection(self): logger.debug('Selection: %s', self._tree.selection()) return [self._names[iid] for iid in self._tree.selection()] def edit(self): self._tree.event_generate('<<NameViewEdit>>') def search(self): if len(self._tree.selection()) == 0: self._tree.selection_add(self._tree.focus()) self._tree.event_generate('<<NameViewSearch>>') def append(self, names): logger.debug('Append names: %s', names) for name in names: if name not in self._names.values(): iid = self._tree.insert('', 'end', text=name) self._names[iid] = name self._iids[name] = iid def delete(self, name): self._tree.delete(self._iids[name]) del self._names[self._iids[name]] del self._iids[name] def _focus(self, iid): self._tree.focus(iid) self._tree.see(iid) def focus_next(self): cur_iid = self._tree.focus() next_iid = self._tree.next(cur_iid) if next_iid == '': iids = self._tree.get_children() next_iid = iids[0] self._focus(next_iid) def focus_prev(self): cur_iid = self._tree.focus() prev_iid = self._tree.prev(cur_iid) if prev_iid == '': iids = self._tree.get_children() prev_iid = iids[-1] self._focus(prev_iid) def jump_to(self, name): try: iid = self._iids[name] self._focus(iid) except KeyError: pass def get_names(self): return tuple(self._names.values()) def set(self, names): self._tree.delete(*self._iids.values()) self._iids.clear() self._names.clear() for name in sorted(names): iid = self._tree.insert('', 'end', text=name) self._names[iid] = name self._iids[name] = iid
class Manager(Toplevel): def __init__(self, master): Toplevel.__init__(self, master, class_=APP_NAME) self.title(_("Manage Feeds")) self.grab_set() self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) self.im_moins = PhotoImage(master=self, file=IM_MOINS) self.im_moins_sel = PhotoImage(master=self, file=IM_MOINS_SEL) self.im_moins_clicked = PhotoImage(master=self, file=IM_MOINS_CLICKED) self.im_plus = PhotoImage(master=self, file=IM_PLUS) self._no_edit_entry = self.register(lambda: False) self.change_made = False self.categories = set(LATESTS.sections()) self.categories.remove('All') self.categories.add('') # --- treeview self.tree = Treeview(self, columns=('Title', 'URL', 'Category', 'Remove'), style='manager.Treeview', selectmode='none') self.tree.heading('Title', text=_('Title'), command=lambda: self._sort_column('Title', False)) self.tree.heading('URL', text=_('URL'), command=lambda: self._sort_column('URL', False)) self.tree.heading('Category', text=_('Category'), command=lambda: self._sort_column('Category', False)) self.tree.column('#0', width=6) self.tree.column('Title', width=250) self.tree.column('URL', width=350) self.tree.column('Category', width=150) self.tree.column('Remove', width=20, minwidth=20, stretch=False) y_scroll = AutoScrollbar(self, orient='vertical', command=self.tree.yview) x_scroll = AutoScrollbar(self, orient='horizontal', command=self.tree.xview) self.tree.configure(xscrollcommand=x_scroll.set, yscrollcommand=y_scroll.set) self.tree.bind('<Motion>', self._highlight_active) self.tree.bind('<Leave>', self._leave) self._last_active_item = None # --- populate treeview for title in sorted(FEEDS.sections(), key=lambda x: x.lower()): item = self.tree.insert('', 'end', values=(title, FEEDS.get(title, 'url'), FEEDS.get(title, 'category', fallback=''), '')) if FEEDS.getboolean(title, 'active', fallback=True): self.tree.selection_add(item) self.tree.item(item, tags=item) self.tree.tag_configure(item, image=self.im_moins) self.tree.tag_bind( item, '<ButtonRelease-1>', lambda event, i=item: self._click_release(event, i)) self.tree.tag_bind(item, '<ButtonPress-1>', lambda event, i=item: self._press(event, i)) self.tree.tag_bind(item, '<Double-1>', lambda event, i=item: self._edit(event, i)) self.tree.grid(row=0, column=0, sticky='ewsn') x_scroll.grid(row=1, column=0, sticky='ew') y_scroll.grid(row=0, column=1, sticky='ns') Button(self, image=self.im_plus, command=self.feed_add, style='manager.TButton').grid(row=2, column=0, columnspan=2, sticky='e', padx=4, pady=4) self._check_add_id = '' def destroy(self): try: self.after_cancel(self._check_add_id) except ValueError: pass Toplevel.destroy(self) def _edit(self, event, item): """Edit feed title.""" column = self.tree.identify_column(event.x) if column in ['#1', '#2']: bbox = self.tree.bbox(item, column) entry = Entry(self.tree) entry.place(x=bbox[0], y=bbox[1], width=bbox[2], height=bbox[3], anchor='nw') entry.bind('<Escape>', lambda e: entry.destroy()) entry.bind('<FocusOut>', lambda e: entry.destroy()) if column == '#1': entry.insert(0, self.tree.item(item, 'values')[0]) entry.configure(style='manager.TEntry') def ok(event): name = entry.get() if name: name = self.master.feed_rename( self.tree.set(item, 'Title'), name) self.tree.set(item, 'Title', name) entry.destroy() entry.bind('<Return>', ok) else: entry.insert(0, self.tree.item(item, 'values')[1]) entry.configure(style='no_edit.TEntry', validate='key', validatecommand=self._no_edit_entry) entry.selection_range(0, 'end') entry.focus_set() elif column == '#3': def focus_out(event): x, y = self.tree.winfo_pointerxy() x0 = combo.winfo_rootx() x1 = x0 + combo.winfo_width() y0 = combo.winfo_rooty() y1 = y0 + combo.winfo_height() if not (x0 <= x <= x1 and y0 <= y <= y1): combo.destroy() def ok(event): category = combo.get().strip() self.categories.add(category) self.master.feed_change_cat(self.tree.set(item, 'Title'), self.tree.set(item, 'Category'), category) self.tree.set(item, 'Category', category) combo.destroy() bbox = self.tree.bbox(item, column) cat = list(self.categories) combo = AutoCompleteCombobox(self.tree, values=cat, allow_other_values=True) combo.place(x=bbox[0], y=bbox[1], width=bbox[2], height=bbox[3], anchor='nw') combo.bind('<Escape>', lambda e: combo.destroy()) combo.bind('<FocusOut>', focus_out) combo.bind('<Return>', ok) combo.bind('<<ComboboxSelected>>', ok) combo.current(cat.index(self.tree.set(item, '#3'))) def _press(self, event, item): if self.tree.identify_column(event.x) == '#4': self.tree.tag_configure(item, image=self.im_moins_clicked) def _click_release(self, event, item): """Handle click on items.""" if self.tree.identify_row(event.y) == item: if self.tree.identify_column(event.x) == '#4': title = self.tree.item(item, 'values')[0] rep = True if CONFIG.getboolean('General', 'confirm_remove', fallback=True): rep = askokcancel( _('Confirmation'), _('Do you want to remove the feed {feed}?').format( feed=title)) if rep: self.master.feed_remove(title) self.tree.delete(item) self.change_made = True elif self.tree.identify_element( event.x, event.y) == 'Checkbutton.indicator': sel = self.tree.selection() if item in sel: self.tree.selection_remove(item) self.master.feed_set_active(self.tree.set(item, '#1'), False) else: self.tree.selection_add(item) self.master.feed_set_active(self.tree.set(item, '#1'), True) self.change_made = True else: self.tree.tag_configure(item, image=self.im_moins) def _leave(self, event): """Remove highlight when mouse leave the treeview.""" if self._last_active_item is not None: self.tree.tag_configure(self._last_active_item, image=self.im_moins) def _highlight_active(self, event): """Highlight minus icon under the mouse.""" if self._last_active_item is not None: self.tree.tag_configure(self._last_active_item, image=self.im_moins) if self.tree.identify_column(event.x) == '#4': item = self.tree.identify_row(event.y) if item: self.tree.tag_configure(item, image=self.im_moins_sel) self._last_active_item = item else: self._last_active_item = None else: self._last_active_item = None def _sort_column(self, column, reverse): """Sort column by (reversed) alphabetical order.""" l = [(self.tree.set(c, column), c) for c in self.tree.get_children('')] l.sort(reverse=reverse, key=lambda x: x[0].lower()) for index, (val, c) in enumerate(l): self.tree.move(c, "", index) self.tree.heading( column, command=lambda: self._sort_column(column, not reverse)) def feed_add(self): dialog = Add(self) self.wait_window(dialog) url = dialog.url if url: self.configure(cursor='watch') queue = self.master.feed_add(url, manager=True) self._check_add_id = self.after(1000, self._check_add_finished, url, queue) def _check_add_finished(self, url, queue): if queue.empty(): self._check_add_id = self.after(1000, self._check_add_finished, url, queue) else: title = queue.get(False) if title: item = self.tree.insert('', 'end', values=(title, url, '')) self.tree.item(item, tags=item) self.tree.tag_configure(item, image=self.im_moins) self.tree.tag_bind( item, '<ButtonRelease-1>', lambda event: self._click_release(event, item)) self.tree.tag_bind(item, '<ButtonPress-1>', lambda event: self._press(event, item)) self.tree.tag_bind(item, '<Double-1>', lambda event: self._edit(event, item)) self.tree.selection_add(item) self.change_made = True self.configure(cursor='arrow') self.focus_set() self.grab_set()
class Multicolumn_Listbox(Frame): _style_index = 0 class List_Of_Rows(object): def __init__(self, multicolumn_listbox): self._multicolumn_listbox = multicolumn_listbox def data(self, index): return self._multicolumn_listbox.row_data(index) def get(self, index): return Row(self._multicolumn_listbox, index) def insert(self, data, index=None): self._multicolumn_listbox.insert_row(data, index) def delete(self, index): self._multicolumn_listbox.delete_row(index) def update(self, index, data): self._multicolumn_listbox.update_row(index, data) def select(self, index): self._multicolumn_listbox.select_row(index) def deselect(self, index): self._multicolumn_listbox.deselect_row(index) def set_selection(self, indices): self._multicolumn_listbox.set_selection(indices) def __getitem__(self, index): return self.get(index) def __setitem__(self, index, value): return self._multicolumn_listbox.update_row(index, value) def __delitem__(self, index): self._multicolumn_listbox.delete_row(index) def __len__(self): return self._multicolumn_listbox.number_of_rows class List_Of_Columns(object): def __init__(self, multicolumn_listbox): self._multicolumn_listbox = multicolumn_listbox def data(self, index): return self._multicolumn_listbox.get_column(index) def get(self, index): return Column(self._multicolumn_listbox, index) def delete(self, index): self._multicolumn_listbox.delete_column(index) def update(self, index, data): self._multicolumn_listbox.update_column(index, data) def __getitem__(self, index): return self.get(index) def __setitem__(self, index, value): return self._multicolumn_listbox.update_column(index, value) def __delitem__(self, index): self._multicolumn_listbox.delete_column(index) def __len__(self): return self._multicolumn_listbox.number_of_columns def __init__(self, master, columns, data=None, command=None, sort=True, select_mode=None, heading_anchor=CENTER, cell_anchor=W, style=None, height=None, padding=None, adjust_heading_to_content=False, stripped_rows=None, selection_background=None, selection_foreground=None, field_background=None, heading_font=None, heading_background=None, heading_foreground=None, cell_pady=2, cell_background=None, cell_foreground=None, cell_font=None, headers=True): self._stripped_rows = stripped_rows self._columns = columns self._number_of_rows = 0 self._number_of_columns = len(columns) self.row = self.List_Of_Rows(self) self.column = self.List_Of_Columns(self) s = Style() if style is None: style_name = "Multicolumn_Listbox%s.Treeview" % self._style_index self._style_index += 1 else: style_name = style style_map = {} if selection_background is not None: style_map["background"] = [('selected', selection_background)] if selection_foreground is not None: style_map["foeground"] = [('selected', selection_foreground)] if style_map: s.map(style_name, **style_map) style_config = {} if cell_background is not None: style_config["background"] = cell_background if cell_foreground is not None: style_config["foreground"] = cell_foreground if cell_font is None: font_name = s.lookup(style_name, "font") cell_font = nametofont(font_name) else: if not isinstance(cell_font, Font): if isinstance(cell_font, basestring): cell_font = nametofont(cell_font) else: if len(Font) == 1: cell_font = Font(family=cell_font[0]) elif len(Font) == 2: cell_font = Font(family=cell_font[0], size=cell_font[1]) elif len(Font) == 3: cell_font = Font(family=cell_font[0], size=cell_font[1], weight=cell_font[2]) else: raise ValueError( "Not possible more than 3 values for font") style_config["font"] = cell_font self._cell_font = cell_font self._rowheight = cell_font.metrics("linespace") + cell_pady style_config["rowheight"] = self._rowheight if field_background is not None: style_config["fieldbackground"] = field_background s.configure(style_name, **style_config) heading_style_config = {} if heading_font is not None: heading_style_config["font"] = heading_font if heading_background is not None: heading_style_config["background"] = heading_background if heading_foreground is not None: heading_style_config["foreground"] = heading_foreground heading_style_name = style_name + ".Heading" s.configure(heading_style_name, **heading_style_config) treeview_kwargs = {"style": style_name} if height is not None: treeview_kwargs["height"] = height if padding is not None: treeview_kwargs["padding"] = padding if headers: treeview_kwargs["show"] = "headings" else: treeview_kwargs["show"] = "" if select_mode is not None: treeview_kwargs["selectmode"] = select_mode self.interior = Treeview(master, columns=columns, **treeview_kwargs) if command is not None: self._command = command self.interior.bind("<<TreeviewSelect>>", self._on_select) for i in range(0, self._number_of_columns): if sort: self.interior.heading( i, text=columns[i], anchor=heading_anchor, command=lambda col=i: self.sort_by(col, descending=False)) else: self.interior.heading(i, text=columns[i], anchor=heading_anchor) if adjust_heading_to_content: self.interior.column(i, width=Font().measure(columns[i])) self.interior.column(i, anchor=cell_anchor) if data is not None: for row in data: self.insert_row(row) @property def row_height(self): return self._rowheight @property def font(self): return self._cell_font def configure_column(self, index, width=None, minwidth=None, anchor=None, stretch=None): kwargs = {} for config_name in ("width", "anchor", "stretch", "minwidth"): config_value = locals()[config_name] if config_value is not None: kwargs[config_name] = config_value self.interior.column('#%s' % (index + 1), **kwargs) def row_data(self, index): try: item_ID = self.interior.get_children()[index] except IndexError: raise ValueError("Row index out of range: %d" % index) return self.item_ID_to_row_data(item_ID) def update_row(self, index, data): try: item_ID = self.interior.get_children()[index] except IndexError: raise ValueError("Row index out of range: %d" % index) if len(data) == len(self._columns): self.interior.item(item_ID, values=data) else: raise ValueError("The multicolumn listbox has only %d columns" % self._number_of_columns) def delete_row(self, index): list_of_items = self.interior.get_children() try: item_ID = list_of_items[index] except IndexError: raise ValueError("Row index out of range: %d" % index) self.interior.delete(item_ID) self._number_of_rows -= 1 if self._stripped_rows: for i in range(index, self._number_of_rows): self.interior.tag_configure(list_of_items[i + 1], background=self._stripped_rows[i % 2]) def insert_row(self, data, index=None): if len(data) != self._number_of_columns: raise ValueError("The multicolumn listbox has only %d columns" % self._number_of_columns) if index is None: index = self._number_of_rows - 1 item_ID = self.interior.insert('', index, values=data) self.interior.item(item_ID, tags=item_ID) self._number_of_rows += 1 if self._stripped_rows: list_of_items = self.interior.get_children() self.interior.tag_configure(item_ID, background=self._stripped_rows[index % 2]) for i in range(index + 1, self._number_of_rows): self.interior.tag_configure(list_of_items[i], background=self._stripped_rows[i % 2]) def column_data(self, index): return [ self.interior.set(child_ID, index) for child_ID in self.interior.get_children('') ] def update_column(self, index, data): for i, item_ID in enumerate(self.interior.get_children()): data_row = self.item_ID_to_row_data(item_ID) data_row[index] = data[i] self.interior.item(item_ID, values=data_row) return data def clear(self): # Another possibility: # self.interior.delete(*self.interior.get_children()) for row in self.interior.get_children(): self.interior.delete(row) self._number_of_rows = 0 def update(self, data): self.clear() for row in data: self.insert_row(row) def focus(self, index=None): if index is None: return self.interior.item(self.interior.focus()) else: item = self.interior.get_children()[index] self.interior.focus(item) def state(self, state=None): if state is None: return self.interior.state() else: self.interior.state(state) @property def number_of_rows(self): return self._number_of_rows @property def number_of_columns(self): return self._number_of_columns def toogle_selection(self, index): list_of_items = self.interior.get_children() try: item_ID = list_of_items[index] except IndexError: raise ValueError("Row index out of range: %d" % index) self.interior.selection_toggle(item_ID) def select_row(self, index): list_of_items = self.interior.get_children() try: item_ID = list_of_items[index] except IndexError: raise ValueError("Row index out of range: %d" % index) self.interior.selection_add(item_ID) def deselect_row(self, index): list_of_items = self.interior.get_children() try: item_ID = list_of_items[index] except IndexError: raise ValueError("Row index out of range: %d" % index) self.interior.selection_remove(item_ID) def deselect_all(self): self.interior.selection_remove(self.interior.selection()) def set_selection(self, indices): list_of_items = self.interior.get_children() self.interior.selection_set(" ".join(list_of_items[row_index] for row_index in indices)) @property def selected_rows(self): data = [] for item_ID in self.interior.selection(): data_row = self.item_ID_to_row_data(item_ID) data.append(data_row) return data @property def indices_of_selected_rows(self): list_of_indices = [] for index, item_ID in enumerate(self.interior.get_children()): if item_ID in self.interior.selection(): list_of_indices.append(index) return list_of_indices def delete_all_selected_rows(self): selected_items = self.interior.selection() for item_ID in selected_items: self.interior.delete(item_ID) number_of_deleted_rows = len(selected_items) self._number_of_rows -= number_of_deleted_rows return number_of_deleted_rows def _on_select(self, event): for item_ID in event.widget.selection(): data_row = self.item_ID_to_row_data(item_ID) self._command(data_row) def item_ID_to_row_data(self, item_ID): item = self.interior.item(item_ID) return item["values"] @property def table_data(self): data = [] for item_ID in self.interior.get_children(): data_row = self.item_ID_to_row_data(item_ID) data.append(data_row) return data @table_data.setter def table_data(self, data): self.update(data) def cell_data(self, row, column): """Get the value of a table cell""" try: item = self.interior.get_children()[row] except IndexError: raise ValueError("Row index out of range: %d" % row) return self.interior.set(item, column) def update_cell(self, row, column, value): """Set the value of a table cell""" item_ID = self.interior.get_children()[row] data = self.item_ID_to_row_data(item_ID) data[column] = value self.interior.item(item_ID, values=data) def __getitem__(self, index): if isinstance(index, tuple): row, column = index return self.cell_data(row, column) else: raise Exception("Row and column indices are required") def __setitem__(self, index, value): if isinstance(index, tuple): row, column = index self.update_cell(row, column, value) else: raise Exception("Row and column indices are required") def bind(self, event, handler): self.interior.bind(event, handler) def sort_by(self, col, descending): """ sort tree contents when a column header is clicked """ # grab values to sort data = [(self.interior.set(child_ID, col), child_ID) for child_ID in self.interior.get_children('')] # if the data to be sorted is numeric change to float try: data = [(float(number), child_ID) for number, child_ID in data] except ValueError: pass # now sort the data in place data.sort(reverse=descending) for idx, item in enumerate(data): self.interior.move(item[1], '', idx) # switch the heading so that it will sort in the opposite direction self.interior.heading( col, command=lambda col=col: self.sort_by(col, not descending)) if self._stripped_rows: list_of_items = self.interior.get_children('') for i in range(len(list_of_items)): self.interior.tag_configure(list_of_items[i], background=self._stripped_rows[i % 2]) def destroy(self): self.interior.destroy() def item_ID(self, index): return self.interior.get_children()[index]
class NameView(object): """Shows a treeview of unique names.""" def __init__(self, master, names): self.widget = Frame(master) self._tree = Treeview(self.widget, columns='name') self._tree.grid(row=0,column=0, sticky=(N,S,W,E)) self._tree.view = self self.widget.columnconfigure(0, weight=1) self.widget.rowconfigure(0,weight=1) self._tree.column('name', width=50) self._tree['show'] = 'tree' actions = {'edit': lambda e: self.edit(), 'search': lambda e: self.search(), 'focus_next': lambda e: self.focus_next(), 'focus_prev': lambda e: self.focus_prev(), 'select': lambda e: self._tree.selection_toggle(self._tree.focus()), 'clear_selection': lambda e: self._tree.selection_set([]) } kb.make_bindings(kb.tagview, actions, self._tree.bind) self._iids = dict() self._names = dict() logger.debug('Names: %s', names) self.widget.focus_set = self._tree.focus_set for name in sorted(names): iid = self._tree.insert('', 'end', text=name) self._names[iid] = name self._iids[name] = iid self._scroll = Scrollbar(self.widget, command=self._tree.yview) self._tree['yscrollcommand'] = self._scroll.set self._scroll.grid(row=0, column=1, sticky=(N, S)) self.widget.columnconfigure(1, weight=0) def selection(self): logger.debug('Selection: %s', self._tree.selection()) return [self._names[iid] for iid in self._tree.selection()] def edit(self): self._tree.event_generate('<<NameViewEdit>>') def search(self): if len(self._tree.selection()) == 0: self._tree.selection_add(self._tree.focus()) self._tree.event_generate('<<NameViewSearch>>') def append(self, names): logger.debug('Append names: %s', names) for name in names: if name not in self._names.values(): iid = self._tree.insert('', 'end', text=name) self._names[iid] = name self._iids[name] = iid def delete(self, name): self._tree.delete(self._iids[name]) del self._names[self._iids[name]] del self._iids[name] def _focus(self, iid): self._tree.focus(iid) self._tree.see(iid) def focus_next(self): cur_iid = self._tree.focus() next_iid = self._tree.next(cur_iid) if next_iid == '': iids = self._tree.get_children() next_iid = iids[0] self._focus(next_iid) def focus_prev(self): cur_iid = self._tree.focus() prev_iid = self._tree.prev(cur_iid) if prev_iid == '': iids = self._tree.get_children() prev_iid = iids[-1] self._focus(prev_iid) def jump_to(self, name): try: iid = self._iids[name] self._focus(iid) except KeyError: pass def get_names(self): return tuple(self._names.values()) def set(self, names): self._tree.delete(*self._iids.values()) self._iids.clear() self._names.clear() for name in sorted(names): iid = self._tree.insert('', 'end', text=name) self._names[iid] = name self._iids[name] = iid
class EventScheduler(Tk): def __init__(self): Tk.__init__(self, className='Scheduler') logging.info('Start') self.protocol("WM_DELETE_WINDOW", self.hide) self._visible = BooleanVar(self, False) self.withdraw() self.icon_img = PhotoImage(master=self, file=ICON48) self.iconphoto(True, self.icon_img) # --- systray icon self.icon = TrayIcon(ICON, fallback_icon_path=ICON_FALLBACK) # --- menu self.menu_widgets = SubMenu(parent=self.icon.menu) self.menu_eyes = Eyes(self.icon.menu, self) self.icon.menu.add_checkbutton(label=_('Manager'), command=self.display_hide) self.icon.menu.add_cascade(label=_('Widgets'), menu=self.menu_widgets) self.icon.menu.add_cascade(label=_("Eyes' rest"), menu=self.menu_eyes) self.icon.menu.add_command(label=_('Settings'), command=self.settings) self.icon.menu.add_separator() self.icon.menu.add_command(label=_('About'), command=lambda: About(self)) self.icon.menu.add_command(label=_('Quit'), command=self.exit) self.icon.bind_left_click(lambda: self.display_hide(toggle=True)) add_trace(self._visible, 'write', self._visibility_trace) self.menu = Menu(self, tearoff=False) self.menu.add_command(label=_('Edit'), command=self._edit_menu) self.menu.add_command(label=_('Delete'), command=self._delete_menu) self.right_click_iid = None self.menu_task = Menu(self.menu, tearoff=False) self._task_var = StringVar(self) menu_in_progress = Menu(self.menu_task, tearoff=False) for i in range(0, 110, 10): prog = '{}%'.format(i) menu_in_progress.add_radiobutton(label=prog, value=prog, variable=self._task_var, command=self._set_progress) for state in ['Pending', 'Completed', 'Cancelled']: self.menu_task.add_radiobutton(label=_(state), value=state, variable=self._task_var, command=self._set_progress) self._img_dot = tkPhotoImage(master=self) self.menu_task.insert_cascade(1, menu=menu_in_progress, compound='left', label=_('In Progress'), image=self._img_dot) self.title('Scheduler') self.rowconfigure(1, weight=1) self.columnconfigure(0, weight=1) self.scheduler = BackgroundScheduler(coalesce=False, misfire_grace_time=86400) self.scheduler.add_jobstore('sqlalchemy', url='sqlite:///%s' % JOBSTORE) self.scheduler.add_jobstore('memory', alias='memo') # --- style self.style = Style(self) self.style.theme_use("clam") self.style.configure('title.TLabel', font='TkdefaultFont 10 bold') self.style.configure('title.TCheckbutton', font='TkdefaultFont 10 bold') self.style.configure('subtitle.TLabel', font='TkdefaultFont 9 bold') self.style.configure('white.TLabel', background='white') self.style.configure('border.TFrame', background='white', border=1, relief='sunken') self.style.configure("Treeview.Heading", font="TkDefaultFont") bgc = self.style.lookup("TButton", "background") fgc = self.style.lookup("TButton", "foreground") bga = self.style.lookup("TButton", "background", ("active", )) self.style.map('TCombobox', fieldbackground=[('readonly', 'white'), ('readonly', 'focus', 'white')], background=[("disabled", "active", "readonly", bgc), ("!disabled", "active", "readonly", bga)], foreground=[('readonly', '!disabled', fgc), ('readonly', '!disabled', 'focus', fgc), ('readonly', 'disabled', 'gray40'), ('readonly', 'disabled', 'focus', 'gray40') ], arrowcolor=[("disabled", "gray40")]) self.style.configure('menu.TCombobox', foreground=fgc, background=bgc, fieldbackground=bgc) self.style.map('menu.TCombobox', fieldbackground=[('readonly', bgc), ('readonly', 'focus', bgc)], background=[("disabled", "active", "readonly", bgc), ("!disabled", "active", "readonly", bga)], foreground=[('readonly', '!disabled', fgc), ('readonly', '!disabled', 'focus', fgc), ('readonly', 'disabled', 'gray40'), ('readonly', 'disabled', 'focus', 'gray40') ], arrowcolor=[("disabled", "gray40")]) self.style.map('DateEntry', arrowcolor=[("disabled", "gray40")]) self.style.configure('cal.TFrame', background='#424242') self.style.configure('month.TLabel', background='#424242', foreground='white') self.style.configure('R.TButton', background='#424242', arrowcolor='white', bordercolor='#424242', lightcolor='#424242', darkcolor='#424242') self.style.configure('L.TButton', background='#424242', arrowcolor='white', bordercolor='#424242', lightcolor='#424242', darkcolor='#424242') active_bg = self.style.lookup('TEntry', 'selectbackground', ('focus', )) self.style.map('R.TButton', background=[('active', active_bg)], bordercolor=[('active', active_bg)], darkcolor=[('active', active_bg)], lightcolor=[('active', active_bg)]) self.style.map('L.TButton', background=[('active', active_bg)], bordercolor=[('active', active_bg)], darkcolor=[('active', active_bg)], lightcolor=[('active', active_bg)]) self.style.configure('txt.TFrame', background='white') self.style.layout('down.TButton', [('down.TButton.downarrow', { 'side': 'right', 'sticky': 'ns' })]) self.style.map('TRadiobutton', indicatorforeground=[('disabled', 'gray40')]) self.style.map('TCheckbutton', indicatorforeground=[('disabled', 'gray40')], indicatorbackground=[ ('pressed', '#dcdad5'), ('!disabled', 'alternate', 'white'), ('disabled', 'alternate', '#a0a0a0'), ('disabled', '#dcdad5') ]) self.style.map('down.TButton', arrowcolor=[("disabled", "gray40")]) self.style.map('TMenubutton', arrowcolor=[('disabled', self.style.lookup('TMenubutton', 'foreground', ['disabled']))]) bg = self.style.lookup('TFrame', 'background', default='#ececec') self.configure(bg=bg) self.option_add('*Toplevel.background', bg) self.option_add('*Menu.background', bg) self.option_add('*Menu.tearOff', False) # toggle text self._open_image = PhotoImage(name='img_opened', file=IM_OPENED, master=self) self._closed_image = PhotoImage(name='img_closed', file=IM_CLOSED, master=self) self._open_image_sel = PhotoImage(name='img_opened_sel', file=IM_OPENED_SEL, master=self) self._closed_image_sel = PhotoImage(name='img_closed_sel', file=IM_CLOSED_SEL, master=self) self.style.element_create( "toggle", "image", "img_closed", ("selected", "!disabled", "img_opened"), ("active", "!selected", "!disabled", "img_closed_sel"), ("active", "selected", "!disabled", "img_opened_sel"), border=2, sticky='') self.style.map('Toggle', background=[]) self.style.layout('Toggle', [('Toggle.border', { 'children': [('Toggle.padding', { 'children': [('Toggle.toggle', { 'sticky': 'nswe' })], 'sticky': 'nswe' })], 'sticky': 'nswe' })]) # toggle sound self._im_sound = PhotoImage(master=self, file=IM_SOUND) self._im_mute = PhotoImage(master=self, file=IM_MUTE) self._im_sound_dis = PhotoImage(master=self, file=IM_SOUND_DIS) self._im_mute_dis = PhotoImage(master=self, file=IM_MUTE_DIS) self.style.element_create( 'mute', 'image', self._im_sound, ('selected', '!disabled', self._im_mute), ('selected', 'disabled', self._im_mute_dis), ('!selected', 'disabled', self._im_sound_dis), border=2, sticky='') self.style.layout('Mute', [('Mute.border', { 'children': [('Mute.padding', { 'children': [('Mute.mute', { 'sticky': 'nswe' })], 'sticky': 'nswe' })], 'sticky': 'nswe' })]) self.style.configure('Mute', relief='raised') # widget scrollbar self._im_trough = {} self._im_slider_vert = {} self._im_slider_vert_prelight = {} self._im_slider_vert_active = {} self._slider_alpha = Image.open(IM_SCROLL_ALPHA) for widget in ['Events', 'Tasks']: bg = CONFIG.get(widget, 'background', fallback='gray10') fg = CONFIG.get(widget, 'foreground') widget_bg = self.winfo_rgb(bg) widget_fg = tuple( round(c * 255 / 65535) for c in self.winfo_rgb(fg)) active_bg = active_color(*widget_bg) active_bg2 = active_color(*active_color(*widget_bg, 'RGB')) slider_vert = Image.new('RGBA', (13, 28), active_bg) slider_vert_active = Image.new('RGBA', (13, 28), widget_fg) slider_vert_prelight = Image.new('RGBA', (13, 28), active_bg2) self._im_trough[widget] = tkPhotoImage(width=15, height=15, master=self) self._im_trough[widget].put(" ".join( ["{" + " ".join([bg] * 15) + "}"] * 15)) self._im_slider_vert_active[widget] = PhotoImage( slider_vert_active, master=self) self._im_slider_vert[widget] = PhotoImage(slider_vert, master=self) self._im_slider_vert_prelight[widget] = PhotoImage( slider_vert_prelight, master=self) self.style.element_create('%s.Vertical.Scrollbar.trough' % widget, 'image', self._im_trough[widget]) self.style.element_create( '%s.Vertical.Scrollbar.thumb' % widget, 'image', self._im_slider_vert[widget], ('pressed', '!disabled', self._im_slider_vert_active[widget]), ('active', '!disabled', self._im_slider_vert_prelight[widget]), border=6, sticky='ns') self.style.layout( '%s.Vertical.TScrollbar' % widget, [('%s.Vertical.Scrollbar.trough' % widget, { 'children': [('%s.Vertical.Scrollbar.thumb' % widget, { 'expand': '1' })], 'sticky': 'ns' })]) # --- tree columns = { _('Summary'): ({ 'stretch': True, 'width': 300 }, lambda: self._sort_by_desc(_('Summary'), False)), _('Place'): ({ 'stretch': True, 'width': 200 }, lambda: self._sort_by_desc(_('Place'), False)), _('Start'): ({ 'stretch': False, 'width': 150 }, lambda: self._sort_by_date(_('Start'), False)), _('End'): ({ 'stretch': False, 'width': 150 }, lambda: self._sort_by_date(_("End"), False)), _('Category'): ({ 'stretch': False, 'width': 100 }, lambda: self._sort_by_desc(_('Category'), False)) } self.tree = Treeview(self, show="headings", columns=list(columns)) for label, (col_prop, cmd) in columns.items(): self.tree.column(label, **col_prop) self.tree.heading(label, text=label, anchor="w", command=cmd) self.tree.tag_configure('0', background='#ececec') self.tree.tag_configure('1', background='white') self.tree.tag_configure('outdated', foreground='red') scroll = AutoScrollbar(self, orient='vertical', command=self.tree.yview) self.tree.configure(yscrollcommand=scroll.set) # --- toolbar toolbar = Frame(self) self.img_plus = PhotoImage(master=self, file=IM_ADD) Button(toolbar, image=self.img_plus, padding=1, command=self.add).pack(side="left", padx=4) Label(toolbar, text=_("Filter by")).pack(side="left", padx=4) # --- TODO: add filter by start date (after date) self.filter_col = Combobox( toolbar, state="readonly", # values=("",) + self.tree.cget('columns')[1:], values=("", _("Category")), exportselection=False) self.filter_col.pack(side="left", padx=4) self.filter_val = Combobox(toolbar, state="readonly", exportselection=False) self.filter_val.pack(side="left", padx=4) Button(toolbar, text=_('Delete All Outdated'), padding=1, command=self.delete_outdated_events).pack(side="right", padx=4) # --- grid toolbar.grid(row=0, columnspan=2, sticky='we', pady=4) self.tree.grid(row=1, column=0, sticky='eswn') scroll.grid(row=1, column=1, sticky='ns') # --- restore data data = {} self.events = {} self.nb = 0 try: with open(DATA_PATH, 'rb') as file: dp = Unpickler(file) data = dp.load() except Exception: l = [ f for f in os.listdir(os.path.dirname(BACKUP_PATH)) if f.startswith('data.backup') ] if l: l.sort(key=lambda x: int(x[11:])) shutil.copy(os.path.join(os.path.dirname(BACKUP_PATH), l[-1]), DATA_PATH) with open(DATA_PATH, 'rb') as file: dp = Unpickler(file) data = dp.load() self.nb = len(data) backup() now = datetime.now() for i, prop in enumerate(data): iid = str(i) self.events[iid] = Event(self.scheduler, iid=iid, **prop) self.tree.insert('', 'end', iid, values=self.events[str(i)].values()) tags = [str(self.tree.index(iid) % 2)] self.tree.item(iid, tags=tags) if not prop['Repeat']: for rid, d in list(prop['Reminders'].items()): if d < now: del self.events[iid]['Reminders'][rid] self.after_id = self.after(15 * 60 * 1000, self.check_outdated) # --- bindings self.bind_class("TCombobox", "<<ComboboxSelected>>", self.clear_selection, add=True) self.bind_class("TCombobox", "<Control-a>", self.select_all) self.bind_class("TEntry", "<Control-a>", self.select_all) self.tree.bind('<3>', self._post_menu) self.tree.bind('<1>', self._select) self.tree.bind('<Double-1>', self._edit_on_click) self.menu.bind('<FocusOut>', lambda e: self.menu.unpost()) self.filter_col.bind("<<ComboboxSelected>>", self.update_filter_val) self.filter_val.bind("<<ComboboxSelected>>", self.apply_filter) # --- widgets self.widgets = {} prop = { op: CONFIG.get('Calendar', op) for op in CONFIG.options('Calendar') } self.widgets['Calendar'] = CalendarWidget(self, locale=CONFIG.get( 'General', 'locale'), **prop) self.widgets['Events'] = EventWidget(self) self.widgets['Tasks'] = TaskWidget(self) self.widgets['Timer'] = Timer(self) self.widgets['Pomodoro'] = Pomodoro(self) self._setup_style() for item, widget in self.widgets.items(): self.menu_widgets.add_checkbutton( label=_(item), command=lambda i=item: self.display_hide_widget(i)) self.menu_widgets.set_item_value(_(item), widget.variable.get()) add_trace(widget.variable, 'write', lambda *args, i=item: self._menu_widgets_trace(i)) self.icon.loop(self) self.tk.eval(""" apply {name { set newmap {} foreach {opt lst} [ttk::style map $name] { if {($opt eq "-foreground") || ($opt eq "-background")} { set newlst {} foreach {st val} $lst { if {($st eq "disabled") || ($st eq "selected")} { lappend newlst $st $val } } if {$newlst ne {}} { lappend newmap $opt $newlst } } else { lappend newmap $opt $lst } } ttk::style map $name {*}$newmap }} Treeview """) # react to scheduler --update-date in command line signal.signal(signal.SIGUSR1, self.update_date) # update selected date in calendar and event list every day self.scheduler.add_job(self.update_date, CronTrigger(hour=0, minute=0, second=1), jobstore='memo') self.scheduler.start() def _setup_style(self): # scrollbars for widget in ['Events', 'Tasks']: bg = CONFIG.get(widget, 'background', fallback='gray10') fg = CONFIG.get(widget, 'foreground', fallback='white') widget_bg = self.winfo_rgb(bg) widget_fg = tuple( round(c * 255 / 65535) for c in self.winfo_rgb(fg)) active_bg = active_color(*widget_bg) active_bg2 = active_color(*active_color(*widget_bg, 'RGB')) slider_vert = Image.new('RGBA', (13, 28), active_bg) slider_vert.putalpha(self._slider_alpha) slider_vert_active = Image.new('RGBA', (13, 28), widget_fg) slider_vert_active.putalpha(self._slider_alpha) slider_vert_prelight = Image.new('RGBA', (13, 28), active_bg2) slider_vert_prelight.putalpha(self._slider_alpha) self._im_trough[widget].put(" ".join( ["{" + " ".join([bg] * 15) + "}"] * 15)) self._im_slider_vert_active[widget].paste(slider_vert_active) self._im_slider_vert[widget].paste(slider_vert) self._im_slider_vert_prelight[widget].paste(slider_vert_prelight) for widget in self.widgets.values(): widget.update_style() def report_callback_exception(self, *args): err = ''.join(traceback.format_exception(*args)) logging.error(err) showerror('Exception', str(args[1]), err, parent=self) def save(self): logging.info('Save event database') data = [ev.to_dict() for ev in self.events.values()] with open(DATA_PATH, 'wb') as file: pick = Pickler(file) pick.dump(data) def update_date(self, *args): """Update Calendar's selected day and Events' list.""" self.widgets['Calendar'].update_date() self.widgets['Events'].display_evts() self.update_idletasks() # --- bindings def _select(self, event): if not self.tree.identify_row(event.y): self.tree.selection_remove(*self.tree.selection()) def _edit_on_click(self, event): sel = self.tree.selection() if sel: sel = sel[0] self.edit(sel) # --- class bindings @staticmethod def clear_selection(event): combo = event.widget combo.selection_clear() @staticmethod def select_all(event): event.widget.selection_range(0, "end") return "break" # --- show / hide def _menu_widgets_trace(self, item): self.menu_widgets.set_item_value(_(item), self.widgets[item].variable.get()) def display_hide_widget(self, item): value = self.menu_widgets.get_item_value(_(item)) if value: self.widgets[item].show() else: self.widgets[item].hide() def hide(self): self._visible.set(False) self.withdraw() self.save() def show(self): self._visible.set(True) self.deiconify() def _visibility_trace(self, *args): self.icon.menu.set_item_value(_('Manager'), self._visible.get()) def display_hide(self, toggle=False): value = self.icon.menu.get_item_value(_('Manager')) if toggle: value = not value self.icon.menu.set_item_value(_('Manager'), value) self._visible.set(value) if not value: self.withdraw() self.save() else: self.deiconify() # --- event management def event_add(self, event): self.nb += 1 iid = str(self.nb) self.events[iid] = event self.tree.insert('', 'end', iid, values=event.values()) self.tree.item(iid, tags=str(self.tree.index(iid) % 2)) self.widgets['Calendar'].add_event(event) self.widgets['Events'].display_evts() self.widgets['Tasks'].display_tasks() self.save() def event_configure(self, iid): self.tree.item(iid, values=self.events[iid].values()) self.widgets['Calendar'].add_event(self.events[iid]) self.widgets['Events'].display_evts() self.widgets['Tasks'].display_tasks() self.save() def add(self, date=None): iid = str(self.nb + 1) if date is not None: event = Event(self.scheduler, iid=iid, Start=date) else: event = Event(self.scheduler, iid=iid) Form(self, event, new=True) def delete(self, iid): index = self.tree.index(iid) self.tree.delete(iid) for k, item in enumerate(self.tree.get_children('')[index:]): tags = [ t for t in self.tree.item(item, 'tags') if t not in ['1', '0'] ] tags.append(str((index + k) % 2)) self.tree.item(item, tags=tags) self.events[iid].reminder_remove_all() self.widgets['Calendar'].remove_event(self.events[iid]) del (self.events[iid]) self.widgets['Events'].display_evts() self.widgets['Tasks'].display_tasks() self.save() def edit(self, iid): self.widgets['Calendar'].remove_event(self.events[iid]) Form(self, self.events[iid]) def check_outdated(self): """Check for outdated events every 15 min.""" now = datetime.now() for iid, event in self.events.items(): if not event['Repeat'] and event['Start'] < now: tags = list(self.tree.item(iid, 'tags')) if 'outdated' not in tags: tags.append('outdated') self.tree.item(iid, tags=tags) self.after_id = self.after(15 * 60 * 1000, self.check_outdated) def delete_outdated_events(self): now = datetime.now() outdated = [] for iid, prop in self.events.items(): if prop['End'] < now: if not prop['Repeat']: outdated.append(iid) elif prop['Repeat']['Limit'] != 'always': end = prop['End'] enddate = datetime.fromordinal( prop['Repeat']['EndDate'].toordinal()) enddate.replace(hour=end.hour, minute=end.minute) if enddate < now: outdated.append(iid) for item in outdated: self.delete(item) logging.info('Deleted outdated events') def refresh_reminders(self): """ Reschedule all reminders. Required when APScheduler is updated. """ for event in self.events.values(): reminders = [date for date in event['Reminders'].values()] event.reminder_remove_all() for date in reminders: event.reminder_add(date) logging.info('Refreshed reminders') # --- sorting def _move_item(self, item, index): self.tree.move(item, "", index) tags = [t for t in self.tree.item(item, 'tags') if t not in ['1', '0']] tags.append(str(index % 2)) self.tree.item(item, tags=tags) @staticmethod def to_datetime(date): date_format = get_date_format("short", CONFIG.get("General", "locale")).pattern dayfirst = date_format.startswith("d") yearfirst = date_format.startswith("y") return parse(date, dayfirst=dayfirst, yearfirst=yearfirst) def _sort_by_date(self, col, reverse): l = [(self.to_datetime(self.tree.set(k, col)), k) for k in self.tree.get_children('')] l.sort(reverse=reverse) # rearrange items in sorted positions for index, (val, k) in enumerate(l): self._move_item(k, index) # reverse sort next time self.tree.heading(col, command=lambda: self._sort_by_date(col, not reverse)) def _sort_by_desc(self, col, reverse): l = [(self.tree.set(k, col), k) for k in self.tree.get_children('')] l.sort(reverse=reverse, key=lambda x: x[0].lower()) # rearrange items in sorted positions for index, (val, k) in enumerate(l): self._move_item(k, index) # reverse sort next time self.tree.heading(col, command=lambda: self._sort_by_desc(col, not reverse)) # --- filter def update_filter_val(self, event): col = self.filter_col.get() self.filter_val.set("") if col: l = set() for k in self.events: l.add(self.tree.set(k, col)) self.filter_val.configure(values=tuple(l)) else: self.filter_val.configure(values=[]) self.apply_filter(event) def apply_filter(self, event): col = self.filter_col.get() val = self.filter_val.get() items = list(self.events.keys()) if not col: for item in items: self._move_item(item, int(item)) else: i = 0 for item in items: if self.tree.set(item, col) == val: self._move_item(item, i) i += 1 else: self.tree.detach(item) # --- manager's menu def _post_menu(self, event): self.right_click_iid = self.tree.identify_row(event.y) self.tree.selection_remove(*self.tree.selection()) self.tree.selection_add(self.right_click_iid) if self.right_click_iid: try: self.menu.delete(_('Progress')) except TclError: pass state = self.events[self.right_click_iid]['Task'] if state: self._task_var.set(state) if '%' in state: self._img_dot = PhotoImage(master=self, file=IM_DOT) else: self._img_dot = tkPhotoImage(master=self) self.menu_task.entryconfigure(1, image=self._img_dot) self.menu.insert_cascade(0, menu=self.menu_task, label=_('Progress')) self.menu.tk_popup(event.x_root, event.y_root) def _delete_menu(self): if self.right_click_iid: self.delete(self.right_click_iid) def _edit_menu(self): if self.right_click_iid: self.edit(self.right_click_iid) def _set_progress(self): if self.right_click_iid: self.events[self.right_click_iid]['Task'] = self._task_var.get() self.widgets['Tasks'].display_tasks() if '%' in self._task_var.get(): self._img_dot = PhotoImage(master=self, file=IM_DOT) else: self._img_dot = tkPhotoImage(master=self) self.menu_task.entryconfigure(1, image=self._img_dot) # --- icon menu def exit(self): self.save() rep = self.widgets['Pomodoro'].stop(self.widgets['Pomodoro'].on) if not rep: return self.menu_eyes.quit() self.after_cancel(self.after_id) try: self.scheduler.shutdown() except SchedulerNotRunningError: pass self.destroy() def settings(self): splash_supp = CONFIG.get('General', 'splash_supported', fallback=True) dialog = Settings(self) self.wait_window(dialog) self._setup_style() if splash_supp != CONFIG.get('General', 'splash_supported'): for widget in self.widgets.values(): widget.update_position() # --- week schedule def get_next_week_events(self): """Return events scheduled for the next 7 days """ locale = CONFIG.get("General", "locale") next_ev = {} today = datetime.now().date() for d in range(7): day = today + timedelta(days=d) evts = self.widgets['Calendar'].get_events(day) if evts: evts = [self.events[iid] for iid in evts] evts.sort(key=lambda ev: ev.get_start_time()) desc = [] for ev in evts: if ev["WholeDay"]: date = "" else: date = "%s - %s " % ( format_time(ev['Start'], locale=locale), format_time(ev['End'], locale=locale)) place = "(%s)" % ev['Place'] if place == "()": place = "" desc.append(("%s%s %s\n" % (date, ev['Summary'], place), ev['Description'])) next_ev[day.strftime('%A')] = desc return next_ev # --- tasks def get_tasks(self): # TODO: find events with repetition in the week # TODO: better handling of events on several days tasks = [] for event in self.events.values(): if event['Task']: tasks.append(event) return tasks