diff --git a/.gitignore b/.gitignore index 68db932..b36b855 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,6 @@ my_tags.csv onnx_models/* !onnx_models/ONNX vision models go here -# Misc -docs/Workpad.md +# Misc +test_space/ diff --git a/README.md b/README.md index 0eabf3b..d38cf70 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ Example folder structures: # 💡 Tips and Features +For detailed information on the tools and features, see the [User Guide✨](https://github.com/Nenotriple/img-txt_viewer/blob/main/docs/User_Guide.md) in the repo docs. + - **Shortcuts:** - `ALT + LEFT/RIGHT`: Quickly move between img-txt pairs. - `SHIFT + DEL`: Move the current pair to a local trash folder. @@ -159,12 +161,6 @@ Example folder structures: - Text cleanup is optimized for CSV-format captions and can be disabled via the Clean-Text option in the menu. -# ✨ User Guide - - -For more detailed information regarding the various tools and features, please refer to the [User Guide✨](https://github.com/Nenotriple/img-txt_viewer/blob/main/docs/User_Guide.md)found in the repo docs. - -
@@ -241,4 +237,4 @@ python img-txt_viewer.py **img-txt Viewer** is completely private, in every sense of the word. - The app operates entirely on your device, ensuring your data remains solely under your control. - **No data is collected, transmitted, or stored**, aside from a basic configuration file for app settings. -- The app functions 100% offline and never connects to external servers. **No data is ever shared or sent elsewhere.** +- The app functions 100% offline and never connects to external servers. **No data is ever shared or sent elsewhere.** \ No newline at end of file diff --git a/docs/Workpad.md b/docs/Workpad.md new file mode 100644 index 0000000..93b89bf --- /dev/null +++ b/docs/Workpad.md @@ -0,0 +1,180 @@ +# Workpad + +Notes, code reference, ideas, etc. + + + + + +## Window Size on Resize + +Prints Tkinter window dimensions on resize: + +```python +root.bind("", lambda event: print(f"Window size (W,H): {event.width},{event.height}") if event.widget == root else None) +``` + +Example Output: `Window size (W,H): 800,600` + + + + + +## Widget Size on Resize + +Prints Tkinter widget dimensions on resize: + +```python +widget.bind("", lambda event: print(f"Widget size (W,H): {event.width},{event.height}")) +``` + +Example Output: `Widget size (W,H): 200,150` + + + + + +## Caller Function Name + +Prints the name of the function that called the current function: + +```python +import inspect + +def function_a(): + function_b() + +def function_b(): + caller_name = inspect.stack()[1].function + print(f"Called by function: {caller_name}") +``` + +Example Output: `Called by function: function_a` + + + + + +## Timing a Function + +Measures the execution time of a function. + +`:.2f` formats the `elapsed_time` (float) variable, to two decimal places. + +```python +import time + +def my_function(): + start_time = time.time() + # ...Code to measure... + elapsed_time = time.time() - start_time + print(f"Elapsed time: {elapsed_time:.2f} seconds") +``` + +Example Output: `Elapsed time: 0.50 seconds` + + + + + +## Tkinter error message with option to copy error to clipboard + +Present the user with an error message using `messagebox.askokcancel()`. + +Use `traceback.format_exc()` to get the full error message. + +If the user clicks `OK`, copy the error to the clipboard. + +```python +import traceback +from tkinter import messagebox + +def my_function(): + # ...Some code... + except Exception as e: + error_message = traceback.format_exc() + if messagebox.askokcancel("Error", f"An error occurred:\n\n{error_message}\n\nPress OK to copy the error message to the clipboard."): + # Clear the clipboard + root.clipboard_clear() + # Copy to the clipboard + root.clipboard_append(error_message) + # Update the clipboard + root.update() +``` + +In this example, the full error message is displayed, but you could show the exception using `e` instead of `error_message` to display a more concise error message. + + + + +# ImgTxtViewer - Ideas + +## General: +- Organize the various alt-UIs into a tabbed tkinter Notebook widget. +- File list selector. + + + + + +## New Tool - Group img-txt Pairs +Create an interface to view one image at a time and easily select a folder to send the image to. +The interface consists of: +- Image and Text Pair (left): + - The image is displayed with the text below it. + - The text is un-editable and shown in a small widget. +- Treeview Display (right): + - Displays the image name, text, and folder name. + - Shows the pairs and their respective folders. +- User-Defined Folder Buttons (bottom): + - An Entry widget to input the folder name and an "Add" button to create a new folder button. + - Right-click on a folder button to rename or delete it. +Both the image and text pair (if present) are moved to the selected folder. Before starting, define at least two folders. The working directory (where the pairs are initially stored) can be used as the first folder. + + +## New Tool - Consolidate Similar Tags +Consolidate similar tags based on the following rules: `If a tag is a substring of another, combine them` +- Two modes are supported: + - Descriptive: Combine into the longest possible tag. + - Example 1: `["hair", "long hair", "black hair"]` → `long black hair` + - Example 2: `["shirt", "black shirt", "t-shirt"]` → `black t-shirt` + - Concise: Combine into the shortest possible tag. + - Example 1: `["hair", "long hair", "black hair"]` → `hair` + - Example 2: `["shirt", "black shirt", "t-shirt"]` → `shirt` + + + + + +## MyTags Tab +- Show all tags in a list, with a count of how many times each tag is used. +- Allow the user to quickly insert tags from "AllTags" into "MyTags". +- Allow the user to quickly insert tags from "AllTags" into the text box. + + +## AutoTag Tab +- Add a "Presets" button to quickly save and load tagging presets. + + +## Filter Tab +- Add a listbox to display all filtered img-txt pairs. +- Allow the user to select item(s) from the listbox and: + - Prefix, Append, or Replace tags. + - Move to another folder. + - Delete img-txt pairs. +- Add more filtering options like: + - `Resolution`, `Aspect ratio`, `File size`, `File name`: + - Is equal to `=` + - Is not equal to `!=` + - Is greater than `>` + - Is less than `<` + - Similar to `*` + - `File type`: + - Is equal to `=` + - Is not equal to `!=` + - `Tags`: + - Contains `a,b` + - Does not contain `!` + - Starts with `a,b` + - Ends with `a,b` + - Similar to `*` diff --git a/img-txt_viewer.py b/img-txt_viewer.py index 264cecc..2e8feb7 100644 --- a/img-txt_viewer.py +++ b/img-txt_viewer.py @@ -1,7 +1,7 @@ """ ######################################## # IMG-TXT VIEWER # -# Version : v1.96 # +# Version : v1.97 # # Author : github.com/Nenotriple # ######################################## @@ -44,7 +44,7 @@ # Third-Party Libraries import numpy from TkToolTip.TkToolTip import TkToolTip as ToolTip -from PIL import Image, ImageTk, ImageSequence, ImageOps, UnidentifiedImageError +from PIL import Image, ImageTk, ImageSequence, UnidentifiedImageError # Custom Libraries @@ -52,6 +52,7 @@ about_img_txt_viewer, calculate_file_stats, batch_resize_images, + custom_scrolledtext, batch_crop_images, settings_manager, batch_tag_edit, @@ -66,6 +67,7 @@ from main.scripts.Autocomplete import SuggestionHandler from main.scripts.PopUpZoom import PopUpZoom as PopUpZoom from main.scripts.OnnxTagger import OnnxTagger as OnnxTagger +from main.scripts.ThumbnailPanel import ThumbnailPanel #endregion @@ -75,7 +77,7 @@ class ImgTxtViewer: def __init__(self, root): - self.app_version = "v1.96" + self.app_version = "v1.97" self.root = root self.application_path = self.get_app_path() self.set_appid() @@ -136,7 +138,6 @@ def initial_class_setup(self): self.image_files = [] self.deleted_pairs = [] self.new_text_files = [] - self.thumbnail_cache = {} self.image_info_cache = {} # Misc variables @@ -144,7 +145,6 @@ def initial_class_setup(self): self.panes_swap_ew_var = BooleanVar(value=False) self.panes_swap_ns_var = BooleanVar(value=False) self.text_modified_var = False - self.is_alt_arrow_pressed = False self.filepath_contains_images_var = False self.toggle_zoom_var = None self.undo_state = StringVar(value="disabled") @@ -180,6 +180,9 @@ def initial_class_setup(self): ] # Blue Pink Green Brown Purple + self.is_image_grid_visible_var = BooleanVar(value=False) + + # -------------------------------------- # Settings # -------------------------------------- @@ -260,8 +263,8 @@ def setup_general_binds(self): self.root.bind("", lambda event: self.prev_pair(event)) self.root.bind('', lambda event: self.delete_pair()) self.root.bind('', self.handle_window_configure) - self.root.bind('', lambda event: self.toggle_zoom_popup(event)) - self.root.bind('', lambda event: self.open_image_grid(event)) + self.root.bind('', lambda event: self.toggle_image_grid(event)) + self.root.bind('', lambda event: self.toggle_zoom_popup(event)) self.root.bind('', lambda event: self.open_image_in_editor(event)) self.root.bind('', lambda event: self.show_batch_tag_edit(event)) self.root.bind('', lambda event: self.on_closing(event)) @@ -313,7 +316,7 @@ def create_options_menu(self): self.options_subMenu.add_separator() self.options_subMenu.add_checkbutton(label="Always On Top", underline=0, variable=self.always_on_top_var, command=self.set_always_on_top) self.options_subMenu.add_checkbutton(label="Toggle Zoom", accelerator="F1", variable=self.toggle_zoom_var, command=self.toggle_zoom_popup) - self.options_subMenu.add_checkbutton(label="Toggle Thumbnail Panel", variable=self.thumbnails_visible, command=self.update_thumbnail_panel) + self.options_subMenu.add_checkbutton(label="Toggle Thumbnail Panel", variable=self.thumbnails_visible, command=self.debounce_update_thumbnail_panel) self.options_subMenu.add_checkbutton(label="Toggle Edit Panel", variable=self.edit_panel_visible_var, command=self.edit_panel.toggle_edit_panel) self.options_subMenu.add_checkbutton(label="Vertical View", underline=0, variable=self.panes_swap_ns_var, command=self.swap_pane_orientation) self.options_subMenu.add_checkbutton(label="Swap img-txt Sides", underline=0, variable=self.panes_swap_ew_var, command=self.swap_pane_sides) @@ -437,7 +440,6 @@ def create_tools_menu(self): self.toolsMenu.add_command(label="Edit Image...", state="disable", underline=6, accelerator="F4", command=self.open_image_in_editor) self.toolsMenu.add_separator() self.toolsMenu.add_command(label="Next Empty Text File", state="disable", accelerator="Ctrl+E", command=self.index_goto_next_empty) - self.toolsMenu.add_command(label="Open Image-Grid...", state="disable", accelerator="F2", underline=11, command=self.open_image_grid) #endregion @@ -460,12 +462,19 @@ def setup_primary_frames(self): self.primary_paned_window.grid(row=0, column=0, sticky="nsew") self.primary_paned_window.bind("", self.disable_button) - # master_image_frame : is exclusively used for the displayed image, thumbnails, image info. + # master_image_frame : is exclusively used for the master_image_inner_frame and image_grid. self.master_image_frame = Frame(self.root) - self.master_image_frame.bind('', lambda event: self.debounce_update_thumbnail_panel(event)) - self.master_image_frame.grid_rowconfigure(1, weight=1) + self.master_image_frame.grid_rowconfigure(0, weight=0) # stats frame row + self.master_image_frame.grid_rowconfigure(1, weight=1) # image frame row self.master_image_frame.grid_columnconfigure(0, weight=1) self.primary_paned_window.add(self.master_image_frame, stretch="always") + self.master_image_inner_frame = Frame(self.master_image_frame) + self.master_image_inner_frame.grid(row=1, column=0, sticky="nsew") + self.master_image_inner_frame.grid_columnconfigure(0, weight=1) + self.master_image_inner_frame.grid_rowconfigure(1, weight=1) + self.image_grid = image_grid.ImageGrid(self.master_image_frame, self) + self.image_grid.grid(row=1, column=0, sticky="nsew") + self.image_grid.grid_remove() # master_control_frame : serves as a container for all primary UI frames, with the exception of the master_image_frame. self.master_control_frame = Frame(self.root) @@ -478,14 +487,17 @@ def setup_primary_frames(self): def create_primary_widgets(self): # Image stats self.stats_frame = Frame(self.master_image_frame) - self.stats_frame.grid(row=0, column=0, sticky="ew") + self.stats_frame.grid(row=0, column=0, sticky="new") + self.stats_frame.grid_columnconfigure(1, weight=1) + # View Menu self.view_menubutton = ttk.Menubutton(self.stats_frame, text="View", state="disable") self.view_menubutton.grid(row=0, column=0) self.view_menu = Menu(self.view_menubutton, tearoff=0) self.view_menubutton.config(menu=self.view_menu) - self.view_menu.add_checkbutton(label="Toggle Zoom", accelerator="F1", variable=self.toggle_zoom_var, command=self.toggle_zoom_popup) - self.view_menu.add_checkbutton(label="Toggle Thumbnail Panel", variable=self.thumbnails_visible, command=self.update_thumbnail_panel) + self.view_menu.add_checkbutton(label="Toggle Image-Grid", accelerator="F1", variable=self.is_image_grid_visible_var, command=self.toggle_image_grid) + self.view_menu.add_checkbutton(label="Toggle Zoom", accelerator="F2", variable=self.toggle_zoom_var, command=self.toggle_zoom_popup) + self.view_menu.add_checkbutton(label="Toggle Thumbnail Panel", variable=self.thumbnails_visible, command=self.debounce_update_thumbnail_panel) self.view_menu.add_checkbutton(label="Toggle Edit Panel", variable=self.edit_panel_visible_var, command=self.edit_panel.toggle_edit_panel) self.view_menu.add_checkbutton(label="Vertical View", underline=0, variable=self.panes_swap_ns_var, command=self.swap_pane_orientation) self.view_menu.add_checkbutton(label="Swap img-txt Sides", underline=0, variable=self.panes_swap_ew_var, command=self.swap_pane_sides) @@ -493,12 +505,13 @@ def create_primary_widgets(self): self.view_menu.add_cascade(label="Image Display Quality", menu=image_quality_menu) for value in ["High", "Normal", "Low"]: image_quality_menu.add_radiobutton(label=value, variable=self.image_quality_var, value=value, command=self.set_image_quality) + # Image Stats self.label_image_stats = Label(self.stats_frame, text="...") self.label_image_stats.grid(row=0, column=1, sticky="ew") # Primary Image - self.primary_display_image = Label(self.master_image_frame, cursor="hand2") + self.primary_display_image = Label(self.master_image_inner_frame, cursor="hand2") self.primary_display_image.grid(row=1, column=0, sticky="nsew") self.primary_display_image.bind("", lambda event: self.open_image(index=self.current_index, event=event)) self.primary_display_image.bind('', self.open_image_directory) @@ -512,13 +525,11 @@ def create_primary_widgets(self): self.image_preview_tooltip = ToolTip.create(self.primary_display_image, "Right-Click for more\nMiddle-click to open in file explorer\nDouble-Click to open in your system image viewer\nALT+Left/Right or Mouse-Wheel to move between pairs", 1000, 6, 12) # Thumbnail Panel - self.set_custom_ttk_button_highlight_style() - self.thumbnail_panel = Frame(self.master_image_frame) + self.thumbnail_panel = ThumbnailPanel(master=self.master_image_inner_frame, parent=self) self.thumbnail_panel.grid(row=3, column=0, sticky="ew") - self.thumbnail_panel.bind("", self.mouse_scroll) # Edit Image Panel - self.edit_image_panel = Frame(self.master_image_frame, relief="ridge", bd=1) + self.edit_image_panel = Frame(self.master_image_inner_frame, relief="ridge", bd=1) self.edit_image_panel.grid(row=2, column=0, sticky="ew") self.edit_image_panel.grid_remove() @@ -584,8 +595,8 @@ def create_primary_widgets(self): # Navigation Buttons nav_button_frame = Frame(self.master_control_frame) nav_button_frame.pack(fill="x", padx=2) - self.next_button = ttk.Button(nav_button_frame, text="Next--->", width=12, state="disabled", takefocus=False, command=lambda: self.update_pair("next")) - self.prev_button = ttk.Button(nav_button_frame, text="<---Previous", width=12, state="disabled", takefocus=False, command=lambda: self.update_pair("prev")) + self.next_button = ttk.Button(nav_button_frame, text="Next", width=12, state="disabled", takefocus=False, command=lambda: self.update_pair("next")) + self.prev_button = ttk.Button(nav_button_frame, text="Previous", width=12, state="disabled", takefocus=False, command=lambda: self.update_pair("prev")) self.next_button.pack(side="right", fill="x", expand=True) self.prev_button.pack(side="right", fill="x", expand=True) ToolTip.create(self.next_button, "Hotkey: ALT+R\nHold shift to advance by 5", 1000, 6, 12) @@ -668,7 +679,7 @@ def create_text_box(self): self.text_frame = Frame(self.master_control_frame) self.text_pane.add(self.text_frame, stretch="always") self.text_pane.paneconfigure(self.text_frame, minsize=80) - self.text_box = scrolledtext.ScrolledText(self.text_frame, wrap="word", undo=True, maxundo=200, inactiveselectbackground="#0078d7") + self.text_box = custom_scrolledtext.CustomScrolledText(self.text_frame, wrap="word", undo=True, maxundo=200, inactiveselectbackground="#0078d7") self.text_box.pack(side="top", expand="yes", fill="both") self.text_box.tag_configure("highlight", background="#5da9be", foreground="white") self.text_box.config(font=(self.font_var.get(), self.font_size_var.get())) @@ -729,12 +740,14 @@ def adjust_text_pane_height(self, event): 'Filter': 60, 'Highlight': 60, 'Font': 60, - 'MyTags': 240, - 'Stats': 240 + 'MyTags': 340, + 'Stats': 340 } selected_tab = event.widget.tab("current", "text") tab_height = 60 if self.initialize_text_pane else tab_heights.get(selected_tab, 60) self.initialize_text_pane = False + if selected_tab == "MyTags": + self.text_controller.refresh_all_tags_listbox(tags=self.stat_calculator.sorted_captions) self.text_pane.paneconfigure(self.text_widget_frame, height=tab_height) @@ -749,7 +762,7 @@ def set_text_box_binds(self): self.text_box.bind("", lambda event: (self.delete_tag_under_mouse(event), self.sync_title_with_content(event))) self.text_box.bind("", lambda event: (self.show_text_context_menu(event))) # Update the autocomplete suggestion label after every KeyRelease event. - self.text_box.bind("", lambda event: (self.autocomplete.update_suggestions(event), self.sync_title_with_content(event))) + self.text_box.bind("", lambda event: (self.autocomplete.update_suggestions(event), self.sync_title_with_content(event), self.get_text_summary())) # Insert a newline after inserting an autocomplete suggestion when list_mode is active. self.text_box.bind('', self.autocomplete.insert_newline_listmode) # Highlight duplicates when selecting text with keyboard or mouse. @@ -806,7 +819,7 @@ def show_text_context_menu(self, event): text_context_menu.add_separator() text_context_menu.add_command(label="Open Text Directory...", command=self.open_text_directory) text_context_menu.add_command(label="Open Text File...", command=self.open_textfile) - text_context_menu.add_command(label="Add Selected Text to MyTags", state=select_state, command=lambda: self.add_to_custom_dictionary(origin="text_box")) + text_context_menu.add_command(label="Add to MyTags", state=select_state, command=lambda: self.add_to_custom_dictionary(origin="text_box")) text_context_menu.add_separator() text_context_menu.add_command(label="Highlight all Duplicates", accelerator="Ctrl+F", command=self.highlight_all_duplicates) text_context_menu.add_command(label="Next Empty Text File", accelerator="Ctrl+E", command=self.index_goto_next_empty) @@ -827,7 +840,6 @@ def show_image_context_menu(self, event): # Open self.image_context_menu.add_command(label="Open Current Directory...", command=self.open_image_directory) self.image_context_menu.add_command(label="Open Current Image...", command=self.open_image) - self.image_context_menu.add_command(label="Open Image-Grid...", accelerator="F2", command=self.open_image_grid) self.image_context_menu.add_command(label="Edit Image...", accelerator="F4", command=self.open_image_in_editor) self.image_context_menu.add_command(label="AutoTag", command=self.text_controller.interrogate_image_tags) self.image_context_menu.add_separator() @@ -849,8 +861,9 @@ def show_image_context_menu(self, event): self.image_context_menu.add_command(label="Flip", command=self.flip_current_image) self.image_context_menu.add_separator() # Misc + self.image_context_menu.add_checkbutton(label="Toggle Image-Grid", accelerator="F1", variable=self.is_image_grid_visible_var, command=self.toggle_image_grid) self.image_context_menu.add_checkbutton(label="Toggle Zoom", accelerator="F1", variable=self.toggle_zoom_var, command=self.toggle_zoom_popup) - self.image_context_menu.add_checkbutton(label="Toggle Thumbnail Panel", variable=self.thumbnails_visible, command=self.update_thumbnail_panel) + self.image_context_menu.add_checkbutton(label="Toggle Thumbnail Panel", variable=self.thumbnails_visible, command=self.debounce_update_thumbnail_panel) self.image_context_menu.add_checkbutton(label="Toggle Edit Panel", variable=self.edit_panel_visible_var, command=self.edit_panel.toggle_edit_panel) self.image_context_menu.add_checkbutton(label="Vertical View", underline=0, variable=self.panes_swap_ns_var, command=self.swap_pane_orientation) self.image_context_menu.add_checkbutton(label="Swap img-txt Sides", underline=0, variable=self.panes_swap_ew_var, command=self.swap_pane_sides) @@ -946,6 +959,19 @@ def get_default_font(self): self.default_font_size = self.current_font_size + def get_text_summary(self): + try: + if hasattr(self, 'text_box'): + text_content = self.text_box.get("1.0", "end-1c") + char_count = len(text_content) + word_count = len([word for word in text_content.split() if word.strip()]) + self.text_controller.info_label.config(text=f"Characters: {char_count} | Words: {word_count}") + return char_count, word_count + return 0, 0 + except Exception: + return 0, 0 + + #endregion ################################################################################################################################################ #region - Additional Interface Setup @@ -1102,7 +1128,6 @@ def enable_menu_options(self): "Open Current Image...", "Edit Image...", "Next Empty Text File", - "Open Image-Grid...", ] options_commands = [ "Options", @@ -1213,6 +1238,19 @@ def snap_sash_to_half(self, event): def configure_pane(self): self.primary_paned_window.paneconfigure(self.master_image_frame, minsize=200, stretch="always") self.primary_paned_window.paneconfigure(self.master_control_frame, minsize=200, stretch="always") + if self.is_image_grid_visible_var.get(): + self.image_grid.reload_grid() + + +# -------------------------------------- +# Thumbnail Panel +# -------------------------------------- + def debounce_update_thumbnail_panel(self, event=None): + if not hasattr(self, 'thumbnail_panel'): + return + if self.update_thumbnail_job_id is not None: + self.root.after_cancel(self.update_thumbnail_job_id) + self.update_thumbnail_job_id = self.root.after(250, self.thumbnail_panel.update_panel) #endregion @@ -1313,115 +1351,21 @@ def toggle_indiv_ops_menu_items(self, all=False, item=None, state="disabled", ev menu.entryconfig(item, state=state) -#endregion -################################################################################################################################################ -#region - Thumbnail Panel - - - def debounce_update_thumbnail_panel(self, event): - if self.update_thumbnail_job_id is not None: - self.root.after_cancel(self.update_thumbnail_job_id) - self.update_thumbnail_job_id = self.root.after(250, self.update_thumbnail_panel) - - - def update_thumbnail_panel(self): - # Clear only if necessary - if len(self.thumbnail_panel.winfo_children()) != len(self.image_files): - for widget in self.thumbnail_panel.winfo_children(): - widget.destroy() - if not self.thumbnails_visible.get() or not self.image_files: - self.thumbnail_panel.grid_remove() - return - self.thumbnail_panel.grid() - thumbnail_width = self.thumbnail_width.get() - panel_width = self.thumbnail_panel.winfo_width() or self.master_image_frame.winfo_width() - num_thumbnails = max(1, panel_width // (thumbnail_width + 10)) - # Handle edge cases: Adjust start index to avoid wrapping - half_visible = num_thumbnails // 2 - if self.current_index < half_visible: - # If near the start, display from the first image - start_index = 0 - elif self.current_index >= len(self.image_files) - half_visible: - # If near the end, shift the view back to fit thumbnails - start_index = max(0, len(self.image_files) - num_thumbnails) +# -------------------------------------- +# ImgTxtViewer states +# -------------------------------------- + def toggle_image_grid(self, event=None): + if event is not None: + self.is_image_grid_visible_var.set(not self.is_image_grid_visible_var.get()) + if self.master_image_inner_frame.winfo_viewable(): + self.master_image_inner_frame.grid_remove() + self.image_grid.initialize() + self.image_grid.grid() + self.root.after(250, self.image_grid.reload_grid) else: - # Otherwise, center the current index - start_index = self.current_index - half_visible - # Ensure the correct number of thumbnails are displayed - total_thumbnails = min(len(self.image_files), num_thumbnails) - thumbnail_buttons = [] - for thumbnail_index in range(total_thumbnails): - index = start_index + thumbnail_index - image_file = self.image_files[index] - # Use cached image info or load it if not present - if image_file not in self.image_info_cache: - self.image_info_cache[image_file] = self.get_image_info(image_file) - image_info = self.image_info_cache[image_file] - # Generate or retrieve cached thumbnail - cache_key = (image_file, thumbnail_width) - thumbnail_photo = self.thumbnail_cache.get(cache_key) - if not thumbnail_photo: - with Image.open(image_file) as img: - img.thumbnail((thumbnail_width, thumbnail_width), self.quality_filter) - if img.mode != "RGBA": - img = img.convert("RGBA") - padded_img = ImageOps.pad(img, (thumbnail_width, thumbnail_width), color=(0, 0, 0, 0)) - thumbnail_photo = ImageTk.PhotoImage(padded_img) - self.thumbnail_cache[cache_key] = thumbnail_photo - # Create the thumbnail button - thumbnail_button = ttk.Button(self.thumbnail_panel, image=thumbnail_photo, cursor="hand2", command=lambda idx=index: self.jump_to_image(idx)) - thumbnail_button.image = thumbnail_photo - # Highlight the current index - if index == self.current_index: - thumbnail_button.config(style="Highlighted.TButton") - # Bind events - thumbnail_button.bind("", self.create_thumb_context_menu(thumbnail_button, index)) - thumbnail_button.bind("", self.mouse_scroll) - ToolTip.create(thumbnail_button, f"#{index + 1} | {image_info['filename']} | {image_info['resolution']} | {image_info['size']} | {image_info['color_mode']}", delay=100, pady=-25, origin='widget') - # Add to the list of thumbnail buttons - thumbnail_buttons.append(thumbnail_button) - # Display the thumbnails - for thumbnail_index, button in enumerate(thumbnail_buttons): - button.grid(row=0, column=thumbnail_index) - self.thumbnail_panel.update_idletasks() - - - def create_thumb_context_menu(self, thumbnail_button, index): - def show_context_menu(event): - thumb_menu = Menu(thumbnail_button, tearoff=0) - # Open Image - thumb_menu.add_command(label="Open Image", command=lambda: self.open_image(index=index)) - thumb_menu.add_command(label="Delete Pair", command=lambda: self.delete_pair(index=index)) - thumb_menu.add_command(label="Edit Image", command=lambda: self.open_image_in_editor(index=index)) - thumb_menu.add_separator() - # Toggle Thumbnail Panel - thumb_menu.add_checkbutton(label="Toggle Thumbnail Panel", variable=self.thumbnails_visible, command=self.update_thumbnail_panel) - # Thumbnail Size - thumbnail_size_menu = Menu(thumb_menu, tearoff=0) - thumb_menu.add_cascade(label="Thumbnail Size", menu=thumbnail_size_menu) - thumbnail_sizes = {"Small": 25, "Medium": 50, "Large": 100} - for label, size in thumbnail_sizes.items(): - thumbnail_size_menu.add_radiobutton(label=label, variable=self.thumbnail_width, value=size, command=self.update_thumbnail_panel) - thumb_menu.add_separator() - # Clear and Rebuild Cache - thumb_menu.add_command(label="Refresh Thumbnails", command=self.refresh_thumbnails) - thumb_menu.post(event.x_root, event.y_root) - return show_context_menu - - - def refresh_thumbnails(self): - self.thumbnail_cache.clear() - self.image_info_cache.clear() - self.refresh_file_lists() - self.update_thumbnail_panel() - - - def set_custom_ttk_button_highlight_style(self): - style = ttk.Style(self.root) - style.configure("Highlighted.TButton", background="#005dd7") - style.configure("Red.TButton", foreground="red") - style.configure("Blue.TButton", foreground="blue") - style.configure("Blue+.TButton", foreground="blue", background="#005dd7") + self.refresh_image() + self.image_grid.grid_remove() + self.master_image_inner_frame.grid() #endregion @@ -1606,16 +1550,19 @@ def load_text_file(self, text_file): def load_image_file(self, image_file, text_file): try: - with Image.open(image_file) as img: - self.original_image_size = img.size - max_size = (self.quality_max_size, self.quality_max_size) - img.thumbnail(max_size, self.quality_filter) - if img.format == 'GIF': - self.gif_frames = [frame.copy() for frame in ImageSequence.Iterator(img)] - self.frame_durations = [frame.info['duration'] for frame in ImageSequence.Iterator(img)] - else: - self.gif_frames = [img.copy()] - self.frame_durations = [None] + if not self.is_image_grid_visible_var.get(): + with Image.open(image_file) as img: + self.original_image_size = img.size + max_size = (self.quality_max_size, self.quality_max_size) + img.thumbnail(max_size, self.quality_filter) + if img.format == 'GIF': + self.gif_frames = [frame.copy() for frame in ImageSequence.Iterator(img)] + self.frame_durations = [frame.info['duration'] for frame in ImageSequence.Iterator(img)] + else: + self.gif_frames = [img.copy()] + self.frame_durations = [None] + else: + img = None except (FileNotFoundError, UnidentifiedImageError): self.update_image_file_count() self.image_files.remove(image_file) @@ -1630,6 +1577,8 @@ def display_image(self): self.image_file = self.image_files[self.current_index] text_file = self.text_files[self.current_index] if self.current_index < len(self.text_files) else None image = self.load_image_file(self.image_file, text_file) + if image is None: + return text_file, None, None, None resize_event = Event() resize_event.height = self.primary_display_image.winfo_height() resize_event.width = self.primary_display_image.winfo_width() @@ -1655,7 +1604,7 @@ def display_image(self): def display_animated_gif(self): if self.animation_job_id is not None: - root.after_cancel(self.animation_job_id) + self.root.after_cancel(self.animation_job_id) self.animation_job_id = None if self.frame_iterator is not None: try: @@ -1675,7 +1624,7 @@ def display_animated_gif(self): self.primary_display_image.config(image=self.current_gif_frame_image) self.primary_display_image.image = self.current_gif_frame_image delay = self.frame_durations[self.current_frame_index] if self.frame_durations[self.current_frame_index] else 100 - self.animation_job_id = root.after(delay, self.display_animated_gif) + self.animation_job_id = self.root.after(delay, self.display_animated_gif) self.current_frame_index = (self.current_frame_index + 1) % len(self.gif_frames) except StopIteration: self.frame_iterator = iter(self.gif_frames) @@ -1710,39 +1659,45 @@ def show_pair(self): if self.image_files: text_file, image, max_img_width, max_img_height = self.display_image() self.load_text_file(text_file) - self.primary_display_image.config(width=max_img_width, height=max_img_height) - self.original_image = image - self.current_image = self.original_image.copy() - self.current_max_img_height = max_img_height - self.current_max_img_width = max_img_width - self.primary_display_image.unbind("") - self.primary_display_image.bind("", self.resize_and_scale_image_event) + if not self.is_image_grid_visible_var.get(): + self.primary_display_image.config(width=max_img_width, height=max_img_height) + self.original_image = image + self.current_image = self.original_image.copy() + self.current_max_img_height = max_img_height + self.current_max_img_width = max_img_width + self.primary_display_image.unbind("") + self.primary_display_image.bind("", self.resize_and_scale_image_event) self.toggle_list_mode() self.autocomplete.clear_suggestions() self.highlight_custom_string() self.highlight_all_duplicates_var.set(False) - self.update_thumbnail_panel() + self.debounce_update_thumbnail_panel() + self.get_text_summary() else: self.primary_display_image.unbind("") def resize_and_scale_image_event(self, event): - display_width = event.width if event.width else self.primary_display_image.winfo_width() - display_height = event.height if event.height else self.primary_display_image.winfo_height() - self.resize_and_scale_image(self.current_image, display_width, display_height, None, Image.NEAREST) + if not self.is_image_grid_visible_var.get(): + display_width = event.width if event.width else self.primary_display_image.winfo_width() + display_height = event.height if event.height else self.primary_display_image.winfo_height() + self.resize_and_scale_image(self.current_image, display_width, display_height, None, Image.NEAREST) def refresh_image(self): if self.image_files: self.display_image() - self.update_thumbnail_panel() + self.debounce_update_thumbnail_panel() def debounce_refresh_image(self, event): if hasattr(self, 'text_box'): if self.is_resizing_job_id: - root.after_cancel(self.is_resizing_job_id) - self.is_resizing_job_id = root.after(250, self.refresh_image) + self.root.after_cancel(self.is_resizing_job_id) + if not self.is_image_grid_visible_var.get(): + self.is_resizing_job_id = self.root.after(250, self.refresh_image) + else: + self.is_resizing_job_id = self.root.after(250, self.image_grid.reload_grid) def handle_window_configure(self, event): # Window resize @@ -1753,7 +1708,7 @@ def handle_window_configure(self, event): # Window resize self.debounce_refresh_image(event) - def update_imageinfo(self, percent_scale): + def update_imageinfo(self, percent_scale=100): if self.image_files: self.image_file = self.image_files[self.current_index] if self.image_file not in self.image_info_cache: @@ -1785,7 +1740,6 @@ def get_image_info(self, image_file): def update_pair(self, direction=None, save=True, step=1, silent=False): if self.image_dir.get() == "Choose Directory..." or len(self.image_files) == 0: return - self.is_alt_arrow_pressed = True self.check_image_dir() if not self.text_modified_var: self.root.title(self.title) @@ -1802,6 +1756,8 @@ def update_pair(self, direction=None, save=True, step=1, silent=False): self.show_pair() self.image_index_entry.delete(0, "end") self.image_index_entry.insert(0, f"{self.current_index + 1}") + if self.is_image_grid_visible_var.get(): + self.image_grid.highlight_thumbnail(self.current_index) def next_pair(self, event=None, step=1): @@ -1838,6 +1794,8 @@ def jump_to_image(self, index=None, event=None): pass self.image_index_entry.delete(0, "end") self.image_index_entry.insert(0, index + 1) + if self.is_image_grid_visible_var.get(): + self.image_grid.highlight_thumbnail(self.current_index) except ValueError: self.image_index_entry.delete(0, "end") self.image_index_entry.insert(0, self.current_index + 1) @@ -2177,22 +2135,13 @@ def duplicate_pair(self): self.update_pair("next") - def open_image_grid(self, event=None): - if not self.image_files: - return - main_window_width = root.winfo_width() - main_window_height = root.winfo_height() - window_x = root.winfo_x() + -330 + main_window_width // 2 - window_y = root.winfo_y() - 300 + main_window_height // 2 - image_grid.ImageGrid(self.root, self, window_x, window_y, self.jump_to_image) - - def open_image_in_editor(self, event=None, index=None): try: if self.image_files: app_path = self.external_image_editor_path - if not os.path.isfile(app_path): - raise FileNotFoundError(f"The specified image editor was not found:\n\n{app_path}") + if app_path != "mspaint": + if not os.path.isfile(app_path): + raise FileNotFoundError(f"The specified image editor was not found:\n\n{app_path}") image_index = index if index is not None else self.current_index image_path = self.image_files[image_index] subprocess.Popen([app_path, image_path]) @@ -2235,7 +2184,7 @@ def sync_title_with_content(self, event=None): if self.current_index < len(self.text_files): text_file = self.text_files[self.current_index] try: - with open(text_file, 'r', encoding="utf-8") as file: + with open(text_file, "r", encoding="utf-8") as file: file_content = file.read() except FileNotFoundError: file_content = "" @@ -2260,7 +2209,7 @@ def ignore_key_event(self, event): def set_always_on_top(self, initial=False): if initial: self.always_on_top_var = BooleanVar(value=False) - root.attributes('-topmost', self.always_on_top_var.get()) + self.root.attributes('-topmost', self.always_on_top_var.get()) def toggle_list_menu(self): @@ -2990,6 +2939,7 @@ def additional_window_setup(self): self.set_always_on_top(initial=True) self.root.attributes('-topmost', 0) self.root.protocol("WM_DELETE_WINDOW", self.on_closing) + self.set_custom_ttk_button_highlight_style() def set_icon(self): @@ -3011,6 +2961,14 @@ def get_app_path(self): return "" + def set_custom_ttk_button_highlight_style(self): + style = ttk.Style(self.root) + style.configure("Highlighted.TButton", background="#005dd7") + style.configure("Red.TButton", foreground="red") + style.configure("Blue.TButton", foreground="blue") + style.configure("Blue+.TButton", foreground="blue", background="#005dd7") + + # -------------------------------------- # Mainloop # -------------------------------------- diff --git a/main/scripts/Autocomplete.py b/main/scripts/Autocomplete.py index ddbfe37..433801e 100644 --- a/main/scripts/Autocomplete.py +++ b/main/scripts/Autocomplete.py @@ -255,49 +255,52 @@ def _handle_suggestion_event(self, event): self._insert_selected_suggestion(selected_suggestion.strip()) self.clear_suggestions() elif keysym in ("Alt_L", "Alt_R"): - if self.suggestions and not self.is_alt_arrow_pressed: - self.selected_suggestion_index = (self.selected_suggestion_index - 1) % len(self.suggestions) if keysym == "Alt_R" else (self.selected_suggestion_index + 1) % len(self.suggestions) + if self.suggestions: + if keysym == "Alt_R": + self.selected_suggestion_index = (self.selected_suggestion_index - 1) % len(self.suggestions) + else: + self.selected_suggestion_index = (self.selected_suggestion_index + 1) % len(self.suggestions) self._highlight_suggestions() - self.is_alt_arrow_pressed = False + return False # Do not clear suggestions elif keysym in ("Up", "Down", "Left", "Right") or event.char == ",": - self.clear_suggestions() + return True else: return False - return True def update_suggestions(self, event=None): """Refresh suggestions based on current input state.""" - if event is None: - event = type('', (), {})() - event.keysym = '' - event.char = '' + # Initialize empty event if none provided + event = type('', (), {'keysym': '', 'char': ''})() if event is None else event cursor_position = self.parent.text_box.index("insert") + # Early returns for special cases if self._cursor_inside_tag(cursor_position): self.clear_suggestions() return if self._handle_suggestion_event(event): + self.clear_suggestions() return text = self.parent.text_box.get("1.0", "insert") - self.clear_suggestions() + # Get current word based on mode if self.parent.last_word_match_var.get(): - words = text.split() - current_word = words[-1] if words else '' + current_word = (text.split() or [''])[-1] else: - if self.parent.list_mode_var.get(): - elements = [element.strip() for element in text.split('\n')] - else: - elements = [element.strip() for element in text.split(',')] - current_word = elements[-1] - current_word = current_word.strip() - if current_word and (len(self.selected_csv_files) >= 1 or self.parent.use_mytags_var.get()): - suggestions = self.autocomplete.get_suggestion(current_word) - suggestions.sort(key=lambda x: self.autocomplete.get_score(x[0], current_word), reverse=True) - self.suggestions = [(suggestion[0].replace("_", " ") if suggestion[0] not in self.autocomplete.tags_with_underscore else suggestion[0], suggestion[1]) for suggestion in suggestions] - if self.suggestions: - self._highlight_suggestions() - else: - self.clear_suggestions() + separator = '\n' if self.parent.list_mode_var.get() else ',' + current_word = (text.split(separator) or [''])[-1].strip() + # Check if suggestions should be shown + if not current_word or (len(self.selected_csv_files) < 1 and not self.parent.use_mytags_var.get()): + self.clear_suggestions() + return + # Get and process suggestions + suggestions = self.autocomplete.get_suggestion(current_word) + if not suggestions: + self.clear_suggestions() + return + # Transform suggestions for display + self.suggestions = [(suggestion[0] if suggestion[0] in self.autocomplete.tags_with_underscore else suggestion[0].replace("_", " "), suggestion[1]) for suggestion in sorted(suggestions, key=lambda x: self.autocomplete.get_score(x[0], current_word), reverse=True)] + # Show or clear suggestions + if self.suggestions: + self._highlight_suggestions() else: self.clear_suggestions() diff --git a/main/scripts/CropUI.py b/main/scripts/CropUI.py index eeb328a..db3c6ac 100644 --- a/main/scripts/CropUI.py +++ b/main/scripts/CropUI.py @@ -1880,7 +1880,7 @@ def close_crop_ui(self, event=None): self.parent.show_primary_paned_window() self.parent.refresh_text_box() self.parent.refresh_file_lists() - self.parent.update_thumbnail_panel() + self.parent.debounce_update_thumbnail_panel() #endregion diff --git a/main/scripts/ThumbnailPanel.py b/main/scripts/ThumbnailPanel.py new file mode 100644 index 0000000..d68586f --- /dev/null +++ b/main/scripts/ThumbnailPanel.py @@ -0,0 +1,252 @@ +""" +This module contains the ThumbnailPanel class for displaying image thumbnails in a horizontal scrollable view. + +It's fairly reliant on the parent application's Attributes and methods, and is designed to be used as a child widget. + +""" + + +################################################################################################################################################ +#region - Imports + + +# Standard Library +from dataclasses import dataclass +from typing import List, Dict, Callable, Any + +# Standard Library - GUI +from tkinter import ttk, Frame, Menu, BooleanVar, IntVar + +# Third-Party Libraries +from PIL import Image, ImageTk, ImageOps + +# Custom Libraries +from TkToolTip.TkToolTip import TkToolTip as ToolTip + + +#endregion +################################################################################################################################################ +#region CLS: ParentData + + + +@dataclass +class ParentData: + """ + Interface for the parent application that ThumbnailPanel expects. + These attributes and methods must be implemented by the parent. + """ + # Attributes + thumbnails_visible: BooleanVar # Visibility state of the thumbnail panel + thumbnail_width: IntVar # Width of thumbnail images; Related to thumbnail cache: "Small": 25, "Medium": 50, "Large": 100 + current_index: int # Index of the currently displayed image + image_files: List[str] # List of image file paths + quality_filter: Any # PIL filter type; e.g. Image.NEAREST + master_image_frame: Frame # The main image display frame + + # Methods + mouse_scroll: Callable # ~ Navigation + refresh_file_lists: Callable # ~ Refresh + get_image_info: Callable # ~ Info + jump_to_image: Callable # ~ Navigation + open_image: Callable # ~ File + delete_pair: Callable # ~ File + open_image_in_editor: Callable # ~ File + + +#endregion +################################################################################################################################################ +#region CLS: ThumbnailPanel + + +class ThumbnailPanel(Frame): + """ + A panel that displays thumbnails of images in a horizontal scrollable view. + + This class extends tkinter's Frame to create a thumbnail navigation panel + that shows previews of images, handles thumbnail caching, and provides + context menu interactions. + """ + + def __init__(self, master, parent: ParentData): + """ + Initialize the ThumbnailPanel. + + Args: + master: The master widget + parent: The main application instance implementing ThumbnailPanelParent interface + """ + super().__init__(master) + self.parent = parent + self.thumbnail_cache: Dict[tuple, ImageTk.PhotoImage] = {} + self.image_info_cache: Dict[str, dict] = {} + self.bind("", self.parent.mouse_scroll) + + +#endregion +################################################################################################################################################ +#region Main + + + def update_panel(self) -> None: + """ + Update the thumbnail panel display. + + Hides the panel if needed, clears existing thumbnails, calculates layout, and displays new thumbnails. + + Call this function whenever the panel should be updated. + """ + if not self._should_display_thumbnails(): + self.grid_remove() + return + self.grid() + self._clear_panel_if_needed() + layout_info = self._calculate_layout() + thumbnail_buttons = self._create_thumbnail_buttons(layout_info) + self._display_thumbnails(thumbnail_buttons) + + + def refresh_thumbnails(self): + """ + Clear thumbnail and image info caches and refresh the thumbnail panel. + + Forces a complete regeneration of all thumbnails. + + Call this function whenever the image list has changed. + """ + self.thumbnail_cache.clear() + self.image_info_cache.clear() + self.parent.refresh_file_lists() + self.update_panel() + + +#endregion +################################################################################################################################################ +#region Mechanics + + + def _should_display_thumbnails(self) -> bool: + """Check if thumbnails should be displayed.""" + return self.parent.thumbnails_visible.get() and bool(self.parent.image_files) + + + def _clear_panel_if_needed(self) -> None: + """Clear existing thumbnails if the number of images has changed.""" + if len(self.winfo_children()) != len(self.parent.image_files): + for widget in self.winfo_children(): + widget.destroy() + + + def _calculate_layout(self) -> dict: + """Calculate layout parameters for thumbnails.""" + thumbnail_width = self.parent.thumbnail_width.get() + panel_width = self.winfo_width() or self.parent.master_image_frame.winfo_width() + num_thumbnails = max(1, panel_width // (thumbnail_width + 10)) + total_images = len(self.parent.image_files) + half_visible = num_thumbnails // 2 + # Normalize current index for circular navigation + self.parent.current_index = self.parent.current_index % total_images + # Calculate start index + start_index = (0 if total_images <= num_thumbnails else (self.parent.current_index - half_visible) % total_images) + return { + 'start_index': start_index, + 'num_thumbnails': min(total_images, num_thumbnails), + 'total_images': total_images, + 'thumbnail_width': thumbnail_width + } + + + def _create_thumbnail(self, image_file: str, thumbnail_width: int) -> ImageTk.PhotoImage: + """Create and cache a thumbnail for the given image file.""" + cache_key = (image_file, thumbnail_width) + if cache_key in self.thumbnail_cache: + return self.thumbnail_cache[cache_key] + try: + with Image.open(image_file) as img: + img.thumbnail((thumbnail_width, thumbnail_width), self.parent.quality_filter) + img = img.convert("RGBA") if img.mode != "RGBA" else img + padded_img = ImageOps.pad(img, (thumbnail_width, thumbnail_width), color=(0, 0, 0, 0)) + thumbnail_photo = ImageTk.PhotoImage(padded_img) + self.thumbnail_cache[cache_key] = thumbnail_photo + return thumbnail_photo + except Exception as e: + print(f"Error creating thumbnail for {image_file}: {e}") + return None + + + def _create_thumbnail_buttons(self, layout_info: dict) -> list: + """Create thumbnail buttons with proper bindings and tooltips.""" + thumbnail_buttons = [] + for i in range(layout_info['num_thumbnails']): + index = (layout_info['start_index'] + i) % layout_info['total_images'] + image_file = self.parent.image_files[index] + # Cache image info if needed + if image_file not in self.image_info_cache: + self.image_info_cache[image_file] = self.parent.get_image_info(image_file) + # Create thumbnail + thumbnail_photo = self._create_thumbnail(image_file, layout_info['thumbnail_width']) + if not thumbnail_photo: + continue + # Create and configure button + button = self._create_single_thumbnail_button(thumbnail_photo, index) + thumbnail_buttons.append(button) + return thumbnail_buttons + + + def _create_single_thumbnail_button(self, thumbnail_photo: ImageTk.PhotoImage, index: int) -> ttk.Button: + """Create a single thumbnail button with all necessary configuration.""" + button = ttk.Button(self, image=thumbnail_photo, cursor="hand2", command=lambda idx=index: self.parent.jump_to_image(idx)) + button.image = thumbnail_photo + # Style current selection + if index == self.parent.current_index: + button.config(style="Highlighted.TButton") + # Add bindings + button.bind("", self._create_context_menu(button, index)) + button.bind("", self.parent.mouse_scroll) + # Add tooltip + image_info = self.image_info_cache[self.parent.image_files[index]] + tooltip_text = f"#{index + 1} | {image_info['filename']} | {image_info['resolution']} | {image_info['size']} | {image_info['color_mode']}" + ToolTip.create(button, tooltip_text, delay=100, pady=-25, origin='widget') + return button + + + def _display_thumbnails(self, thumbnail_buttons: list) -> None: + """Display the thumbnail buttons in the panel.""" + for idx, button in enumerate(thumbnail_buttons): + button.grid(row=0, column=idx) + self.update_idletasks() + + +#endregion +################################################################################################################################################ +#region Context Menu + + + def _create_context_menu(self, thumbnail_button, index): + """ + Create a context menu for thumbnail buttons. + + Args: + thumbnail_button: The button widget to attach the menu to + index: The index of the image in the file list + + Returns: + function: Event handler for showing the context menu + """ + def show_context_menu(event): + thumb_menu = Menu(thumbnail_button, tearoff=0) + thumb_menu.add_command(label="Open Image", command=lambda: self.parent.open_image(index=index)) + thumb_menu.add_command(label="Delete Pair", command=lambda: self.parent.delete_pair(index=index)) + thumb_menu.add_command(label="Edit Image", command=lambda: self.parent.open_image_in_editor(index=index)) + thumb_menu.add_separator() + thumb_menu.add_checkbutton(label="Toggle Thumbnail Panel", variable=self.parent.thumbnails_visible, command=self.update_panel) + # Thumbnail Size submenu + thumbnail_size_menu = Menu(thumb_menu, tearoff=0) + thumb_menu.add_cascade(label="Thumbnail Size", menu=thumbnail_size_menu) + thumbnail_sizes = {"Small": 25, "Medium": 50, "Large": 100} + for label, size in thumbnail_sizes.items(): + thumbnail_size_menu.add_radiobutton(label=label, variable=self.parent.thumbnail_width, value=size, command=self.update_panel) + thumb_menu.add_separator() + thumb_menu.add_command(label="Refresh Thumbnails", command=self.refresh_thumbnails) + thumb_menu.post(event.x_root, event.y_root) + return show_context_menu diff --git a/main/scripts/batch_tag_edit.py b/main/scripts/batch_tag_edit.py index fdbf2f8..050f9ce 100644 --- a/main/scripts/batch_tag_edit.py +++ b/main/scripts/batch_tag_edit.py @@ -232,11 +232,6 @@ def setup_edit_frame(self): self.edit_label.grid(row=0, column=0, padx=2) ToolTip.create(self.edit_label, "Select an option and enter text to apply to the selected tags", 250, 6, 12, justify="left") - self.edit_combobox = ttk.Combobox(self.edit_frame, values=["Replace", "Delete"], state="readonly", width=12) - self.edit_combobox.set("Replace") - self.edit_combobox.grid(row=0, column=1, padx=2, sticky="e") - self.edit_combobox.bind("<>", self.toggle_edit_entry_state) - self.edit_entry = ttk.Entry(self.edit_frame, width=20) self.edit_entry.grid(row=0, column=2, padx=2, sticky="ew") self.edit_entry.bind("", self.apply_commands_to_listbox) @@ -250,6 +245,10 @@ def setup_edit_frame(self): self.edit_reset_button.grid(row=0, column=4, padx=2, sticky="e") ToolTip.create(self.edit_reset_button, "Clear any filters or pending changes", 250, 6, 12) + self.delete_button = ttk.Button(self.edit_frame, text="Delete", width=6, command=lambda: self.apply_commands_to_listbox(delete=True)) + self.delete_button.grid(row=1, column=0, padx=2, sticky="e") + ToolTip.create(self.delete_button, "Delete the selected tags", 250, 6, 12) + def setup_help_frame(self): self.help_frame = Frame(self.option_frame) @@ -354,7 +353,7 @@ def apply_commands_to_listbox(self, event=None, delete=False, edit=None): if current_text.startswith("DELETE :") or current_text.startswith("EDIT :"): # Strip away "DELETE :" or "EDIT :" to get the original item item = current_text.split(":", 1)[1].strip().split(">", 1)[0].strip() - if delete: # If the delete, add delete command + if delete: # If delete, add delete command self.listbox.delete(i) self.listbox.insert(i, f"DELETE : {item}") self.listbox.itemconfig(i, {'fg': 'red'}) @@ -506,13 +505,6 @@ def clear_filter(self, warn=True): self.refresh_counts() - def toggle_edit_entry_state(self, event=None): - if self.edit_combobox.get() == "Delete": - self.edit_entry.config(state="disabled") - else: - self.edit_entry.config(state="normal") - - def toggle_info_message(self): if self.help_frame.winfo_viewable(): self.help_frame.grid_remove() diff --git a/main/scripts/calculate_file_stats.py b/main/scripts/calculate_file_stats.py index 1b1ea26..dfb52fb 100644 --- a/main/scripts/calculate_file_stats.py +++ b/main/scripts/calculate_file_stats.py @@ -32,17 +32,28 @@ def __init__(self, parent, root): self.sorted_captions = [] - def calculate_file_stats(self, manual_refresh=None): - """Calculate and display file statistics.""" + def calculate_file_stats(self, manual_refresh=None, text_only=False, image_only=False): + """Calculate and display file statistics. + + Args: + manual_refresh: If True, display a message box after refreshing + text_only: If True, only refresh text statistics + image_only: If True, only refresh image statistics + """ + if text_only and image_only: + raise ValueError("Cannot set both text_only and image_only to True") self.initialize_counters() num_total_files, num_txt_files, num_img_files, formatted_total_files = self.filter_and_update_textfiles(initial_call=True) - # Process text and image files to calculate statistics - self.process_text_files() - if self.parent.process_image_stats_var.get(): + # Process files based on flags + if not image_only: + self.process_text_files() + if (self.parent.process_image_stats_var.get() and not text_only) or image_only: self.process_image_files() # Format statistics into a text string stats_text = self.compile_file_statistics(formatted_total_files) self.update_tab8_textbox(stats_text, manual_refresh) + if self.sorted_captions: + self.parent.text_controller.refresh_all_tags_listbox(tags=self.sorted_captions) def initialize_counters(self): diff --git a/main/scripts/custom_scrolledtext.py b/main/scripts/custom_scrolledtext.py new file mode 100644 index 0000000..d6e48e7 --- /dev/null +++ b/main/scripts/custom_scrolledtext.py @@ -0,0 +1,73 @@ +"""TextWrapper extends ScrolledText to add text wrapping functionality for brackets and quotes.""" + + +import tkinter as tk +from tkinter import scrolledtext + + +class CustomScrolledText(scrolledtext.ScrolledText): + def __init__(self, master=None, **kwargs): + super().__init__(master, **kwargs) + self.bind_wrapping_events() + + + def bind_wrapping_events(self): + """Bind necessary events to widget.""" + keys_to_bind = ["(", "[", "{", '"', "'"] + for key in keys_to_bind: + self.bind(f"", self.on_key_press, add="+") + + + def on_key_press(self, event): + """Handle key press events for bracket wrapping.""" + opening_brackets = {'(': ')', '[': ']', '{': '}', '"': '"', "'": "'"} + if event.char in opening_brackets: + self.wrap_selected_text(event.char, opening_brackets[event.char]) + return "break" + + + def wrap_selected_text(self, opening_char, closing_char): + """Wrap the selected text with the given opening and closing characters.""" + try: + start, end, selected_text = self.get_selection() + if selected_text: + self.replace_selection_with_wrapped_text(start, end, selected_text, opening_char, closing_char) + self.reselect_wrapped_text(start, opening_char, selected_text, closing_char) + else: + self.insert_empty_wrap(opening_char, closing_char) + except tk.TclError: + pass + + + def get_selection(self): + """Get the currently selected text.""" + try: + start = self.index("sel.first") + end = self.index("sel.last") + selected_text = self.get(start, end) + except tk.TclError: + start = end = self.index(tk.INSERT) + selected_text = "" + return start, end, selected_text + + + def replace_selection_with_wrapped_text(self, start, end, selected_text, opening_char, closing_char): + """Replace the selected text with the wrapped text.""" + self.delete(start, end) + self.insert(start, f"{opening_char}{selected_text}{closing_char}") + + + def reselect_wrapped_text(self, start, opening_char, selected_text, closing_char): + """Reselect the newly wrapped text.""" + self.tag_remove(tk.SEL, 1.0, tk.END) + new_end = self.index(f"{start}+{len(opening_char + selected_text + closing_char)}c") + self.tag_add(tk.SEL, start, new_end) + self.mark_set(tk.INSERT, new_end) + self.see(tk.INSERT) + + + def insert_empty_wrap(self, opening_char, closing_char): + """Insert an empty wrap at the current cursor position.""" + cursor_pos = self.index(tk.INSERT) + self.insert(cursor_pos, f"{opening_char}{closing_char}") + self.mark_set(tk.INSERT, f"{cursor_pos}+1c") diff --git a/main/scripts/image_grid.py b/main/scripts/image_grid.py index cfe5f04..244a7b8 100644 --- a/main/scripts/image_grid.py +++ b/main/scripts/image_grid.py @@ -1,15 +1,14 @@ """ ######################################## # Image-Grid # -# Version : v1.04 # +# Version : v1.05 # # Author : github.com/Nenotriple # ######################################## Description: ------------- -Display a grid of images, clicking an image returns the index as defined in 'natural_sort'. +A Tkinter widget that displays a grid of images. Clicking an image returns the index as defined in 'natural_sort'. Images without a text pair have a red flag placed over them. - """ @@ -19,14 +18,13 @@ # Standard Library import os -import configparser # Standard Library - GUI from tkinter import ( - ttk, Toplevel, messagebox, + ttk, IntVar, StringVar, BooleanVar, - Frame, Label, Button, Menu, Scrollbar, Canvas, + Frame, Label, Menu, Scrollbar, Canvas, TclError ) @@ -40,38 +38,39 @@ #region - CLASS: ImageGrid -class ImageGrid: +class ImageGrid(ttk.Frame): image_cache = {1: {}, 2: {}, 3: {}} # Cache for each thumbnail size image_size_cache = {} # Cache to store image sizes text_file_cache = {} # Cache to store text file pairs - def __init__(self, master, img_txt_viewer, window_x, window_y, jump_to_image): - # Window configuration - self.create_window(master, window_x, window_y) - + def __init__(self, master, parent): + super().__init__(master) # Initialize ImgTxtViewer variables and methods - self.img_txt_viewer = img_txt_viewer - self.sort_key = self.img_txt_viewer.get_file_sort_key() - self.reverse_sort_direction_var = self.img_txt_viewer.reverse_load_order_var.get() - self.working_folder = self.img_txt_viewer.image_dir.get() - - # Setup configparser and settings file - self.config = configparser.ConfigParser() - self.settings_file = "settings.cfg" - - # Image navigation function - self.ImgTxt_jump_to_image = jump_to_image - + self.parent = parent + self.is_initialized = False # Supported file types self.supported_filetypes = (".png", ".webp", ".jpg", ".jpeg", ".jpg_large", ".jfif", ".tif", ".tiff", ".bmp", ".gif") + # Used keeping track of thumbnail buttons + self.thumbnail_buttons = {} + # Used for highlighting the selected thumbnail + self.initial_selected_thumbnail = None + self.selected_thumbnail = None + + + def initialize(self): + '''Initialize the ImageGrid widget. This must be called before using the widget.''' + if self.is_initialized: + return + # Parent variables + self.reverse_sort_direction_var = self.parent.reverse_load_order_var.get() + self.working_folder = self.parent.image_dir.get() # Image grid configuration self.max_width = 80 # Thumbnail width self.max_height = 80 # Thumbnail height - self.rows = 500 # Max rows - self.cols = 8 # Max columns self.images_per_load = 250 # Num of images to load per set + self.padding = 4 # Default padding between thumbnails # Image loading and filtering self.loaded_images = 0 # Num of images loaded to the UI @@ -86,17 +85,22 @@ def __init__(self, master, img_txt_viewer, window_x, window_y, jump_to_image): self.num_total_images = len(self.image_file_list) # Number of images in the folder # Default thumbnail size. Range=(1,2,3). Set to 3 if total_images is less than 25. - self.image_size = IntVar(value=3) if self.num_total_images < 25 else IntVar(value=2) - - # Toggle window auto-close when selecting an image - self.auto_close = BooleanVar() - - # Read and set settings - self.read_settings() + self.image_size = IntVar(value=2) if self.num_total_images < 25 else IntVar(value=2) # Interface creation self.create_interface() self.load_images() + self.is_initialized = True + + + def calculate_columns(self): + frame_width = self.frame_thumbnails.winfo_width() + if frame_width <= 1: + frame_width = self.frame_thumbnails.winfo_reqwidth() + available_width = frame_width - (2 * self.padding) + thumbnail_width_with_padding = self.max_width + (2 * self.padding) + cols = max(1, available_width // thumbnail_width_with_padding) + return int(cols) #endregion @@ -105,32 +109,13 @@ def __init__(self, master, img_txt_viewer, window_x, window_y, jump_to_image): def create_interface(self): - self.create_top_handle() self.create_canvas() self.create_control_row() self.create_filtering_row() - def create_top_handle(self): - self.frame_top_Handle = Frame(self.top) - self.frame_top_Handle.pack(fill="both") - # Title bar - title = Label(self.frame_top_Handle, cursor="size", text="Image-Grid", font=("", 16)) - title.pack(side="top", fill="x", padx=5, pady=5) - title.bind("", self.start_drag) - title.bind("", self.stop_drag) - title.bind("", self.dragging_window) - # Close button - self.button_close = Button(self.frame_top_Handle, text="X", overrelief="groove", relief="flat", command=self.close_window) - self.button_close.place(anchor="nw", relx=0.945, height=40, width=40) - self.bind_widget_highlight(self.button_close, color='#ffcac9') - ToolTip.create(self.button_close, "Close", 500, 6, 12) - separator = ttk.Separator(self.frame_top_Handle) - separator.pack(side="top", fill="x") - - def create_canvas(self): - self.frame_main = Frame(self.top) + self.frame_main = Frame(self) self.frame_main.pack(fill="both", expand=True) self.scrollbar = Scrollbar(self.frame_main) self.scrollbar.pack(side="right", fill="y") @@ -155,10 +140,6 @@ def create_control_row(self): self.slider_image_size.bind("", lambda event: self.reload_grid()) self.slider_image_size.pack(side="left") ToolTip.create(self.slider_image_size, "Adjust grid size", 500, 6, 12) - # Grip - self.grip_window_size = ttk.Sizegrip(self.frame_bottom) - self.grip_window_size.pack(side="right", padx=(5, 0)) - ToolTip.create(self.grip_window_size, "Adjust window size", 500, 6, 12) # Refresh self.button_refresh = ttk.Button(self.frame_bottom, text="Refresh", command=self.reload_grid) self.button_refresh.pack(side="right", padx=5) @@ -171,10 +152,6 @@ def create_control_row(self): self.label_image_info = Label(self.frame_bottom, width=14) self.label_image_info.pack(side="right", padx=5) ToolTip.create(self.label_image_info, "Filtered Images : Loaded Images, Total Images", 500, 6, 12) - # Auto-Close - self.checkbutton_auto_close = ttk.Checkbutton(self.frame_bottom, text="Auto-Close", variable=self.auto_close) - self.checkbutton_auto_close.pack(side="right", padx=5) - ToolTip.create(self.checkbutton_auto_close, "Uncheck this to keep the window open after selecting an image", 500, 6, 12) def create_filtering_row(self): @@ -284,7 +261,7 @@ def update_cache_and_grid(self): def update_image_cache(self): image_size_key = self.image_size.get() - filtered_sorted_files = list(filter(self.filter_images, sorted(self.image_file_list, key=self.sort_key, reverse=self.reverse_sort_direction_var))) + filtered_sorted_files = list(filter(self.filter_images, sorted(self.image_file_list, key=self.parent.get_file_sort_key(), reverse=self.reverse_sort_direction_var))) current_text_file_sizes = { os.path.splitext(os.path.join(self.working_folder, filename))[0] + '.txt': os.path.getsize(os.path.splitext(os.path.join(self.working_folder, filename))[0] + '.txt') if os.path.exists(os.path.splitext(os.path.join(self.working_folder, filename))[0] + '.txt') else 0 for filename in filtered_sorted_files} @@ -324,6 +301,7 @@ def set_size_settings(self): 3: (170, 170, 4) } self.max_width, self.max_height, self.cols = size_settings.get(self.image_size.get(), (80, 80, 8)) + self.cols = self.calculate_columns() def create_image_grid(self): @@ -337,10 +315,15 @@ def create_image_grid(self): def populate_image_grid(self): for index, (image, filepath, image_index) in enumerate(self.images): row, col = divmod(index, self.cols) - thumbnail = ttk.Button(self.frame_image_grid, image=image, takefocus=False, command=lambda path=filepath: self.on_mouse_click(path)) + button_style = "Highlighted.TButton" if index == self.parent.current_index else "TButton" + thumbnail = ttk.Button(self.frame_image_grid, image=image, takefocus=False, style=button_style) + thumbnail.configure(command=lambda idx=image_index: self.on_mouse_click(idx)) thumbnail.image = image thumbnail.grid(row=row, column=col) thumbnail.bind("", self.on_mousewheel) + self.thumbnail_buttons[image_index] = thumbnail + if index == self.parent.current_index: + self.initial_selected_thumbnail = thumbnail filesize = os.path.getsize(filepath) filesize = f"{filesize / 1024:.2f} KB" if filesize < 1024 * 1024 else f"{filesize / 1024 / 1024:.2f} MB" with Image.open(filepath) as img: @@ -358,7 +341,7 @@ def load_images(self, all_images=False): def load_image_set(self): images = [] image_size_key = self.image_size.get() - filtered_sorted_files = list(filter(self.filter_images, sorted(self.image_file_list, key=self.sort_key, reverse=self.reverse_sort_direction_var))) + filtered_sorted_files = list(filter(self.filter_images, sorted(self.image_file_list, key=self.parent.get_file_sort_key(), reverse=self.reverse_sort_direction_var))) current_text_file_sizes = { os.path.splitext(os.path.join(self.working_folder, filename))[0] + '.txt': os.path.getsize(os.path.splitext(os.path.join(self.working_folder, filename))[0] + '.txt') if os.path.exists(os.path.splitext(os.path.join(self.working_folder, filename))[0] + '.txt') else 0 for filename in filtered_sorted_files} @@ -538,7 +521,7 @@ def check_tags(tags): def update_filtered_images(self): - self.filtered_images = sum(1 for _ in filter(self.filter_images, sorted(self.image_file_list, key=self.sort_key, reverse=self.reverse_sort_direction_var))) + self.filtered_images = sum(1 for _ in filter(self.filter_images, sorted(self.image_file_list, key=self.parent.get_file_sort_key(), reverse=self.reverse_sort_direction_var))) def get_image_and_text_paths(self, filename): @@ -584,7 +567,7 @@ def create_image_flag(self): def get_image_index(self, directory, filename): filename = os.path.basename(filename) - image_files = sorted((file for file in os.listdir(directory) if file.lower().endswith(self.supported_filetypes)), key=self.sort_key, reverse=self.reverse_sort_direction_var) + image_files = sorted((file for file in os.listdir(directory) if file.lower().endswith(self.supported_filetypes)), key=self.parent.get_file_sort_key(), reverse=self.reverse_sort_direction_var) return image_files.index(filename) if filename in image_files else -1 @@ -606,11 +589,26 @@ def add_load_more_button(self): ToolTip.create(load_more_button, "Load the next 150 images", 500, 6, 12) - def on_mouse_click(self, path): - index = self.get_image_index(self.working_folder, path) - self.ImgTxt_jump_to_image(index) - if self.auto_close.get(): - self.close_window() + def highlight_thumbnail(self, index): + button = self.thumbnail_buttons.get(index) + if not button: + return + try: + if self.initial_selected_thumbnail: + self.initial_selected_thumbnail.configure(style="TButton") + self.initial_selected_thumbnail = None + if self.selected_thumbnail: + self.selected_thumbnail.configure(style="TButton") + except TclError: + pass + self.selected_thumbnail = button + button.configure(style="Highlighted.TButton") + + + def on_mouse_click(self, index): + self.highlight_thumbnail(index) + self.parent.jump_to_image(index) + self.parent.update_imageinfo() def on_mousewheel(self, event): @@ -704,131 +702,34 @@ def get_image_files(self): #endregion ################################################################################################################################################ -#region - Window drag - - - def start_drag(self, event): - self.start_x = event.x - self.start_y = event.y - - - def stop_drag(self, event): - self.start_x = None - self.start_y = None - - - def dragging_window(self, event): - dx = event.x - self.start_x - dy = event.y - self.start_y - x = self.top.winfo_x() + dx - y = self.top.winfo_y() + dy - self.top.geometry(f"+{x}+{y}") - - -#endregion -################################################################################################################################################ -#region - Save / Read Settings - - - def save_settings(self): - try: - # Read existing settings - if os.path.exists(self.settings_file): - self.config.read(self.settings_file) - # Auto-Close - if not self.config.has_section("Other"): - self.config.add_section("Other") - self.config.set("Other", "auto_close", str(self.auto_close.get())) - # Write updated settings back to file - with open(self.settings_file, "w", encoding="utf-8") as f: - self.config.write(f) - except (PermissionError, IOError) as e: - messagebox.showerror("Error", f"Error: An error occurred while saving the user settings.\n\n{e}") - - - def read_settings(self): - try: - # Read existing settings - if os.path.exists(self.settings_file): - self.config.read(self.settings_file) - # Auto-Close - self.auto_close.set(value=self.config.getboolean("Other", "auto_close", fallback=False)) - except Exception as e: - messagebox.showerror("Error", f"Error: An unexpected error occurred.\n\n{e}") - - -#endregion -################################################################################################################################################ -#region - Widget highlighting - - - def bind_widget_highlight(self, widget, add=False, color=None): - add = '+' if add else '' - if color: - widget.bind("", lambda event: self.mouse_enter(event, color), add=add) - else: - widget.bind("", self.mouse_enter, add=add) - widget.bind("", self.mouse_leave, add=add) +#region - Changelog - def mouse_enter(self, event, color='#e5f3ff'): - if event.widget['state'] == 'normal': - event.widget['background'] = color +''' - def mouse_leave(self, event): - event.widget['background'] = 'SystemButtonFace' +v1.05 changes: -#endregion -################################################################################################################################################ -#region - Framework - - - def create_window(self, master, window_x, window_y): - self.transparent_top = Toplevel(master) - self.transparent_top.attributes('-alpha', 0.0) - self.transparent_top.iconify() - self.transparent_top.title("Image-Grid") - self.transparent_top.bind("", lambda event: self.top.focus_force()) - self.top = Toplevel(master, borderwidth=2, relief="groove") - self.top.overrideredirect(True) - window_size = "750x600" - window_position = f"+{window_x}+{window_y}" - self.top.geometry(f"{window_size}{window_position}") - self.top.minsize(750, 300) - self.top.maxsize(750, 6000) - self.top.focus_force() - self.top.bind("", lambda event: self.close_window(event)) - self.top.bind('', lambda event: self.close_window(event)) - self.top.protocol('WM_DELETE_WINDOW', self.close_window) - - - def close_window(self, event=None): - self.save_settings() - self.transparent_top.destroy() - self.top.destroy() + - New: + - Refactored ImageGrid to act similar to a regular Tkinter widget. + - Removed window management code. + - Modified initialization to accept standard widget parameters. -#endregion -################################################################################################################################################ -#region - Changelog +
-''' + - Fixed: + - -v1.04 changes: - - New: - -
- - Fixed: - - -
- Other changes: - - Widgets are now made with ttk (when appropriate) for better styling on Windows 11. + - + ''' @@ -840,12 +741,15 @@ def close_window(self, event=None): ''' + - Todo - + - Tofix - + ''' diff --git a/main/scripts/settings_manager.py b/main/scripts/settings_manager.py index 1520955..d6afb9e 100644 --- a/main/scripts/settings_manager.py +++ b/main/scripts/settings_manager.py @@ -1,4 +1,9 @@ -"""This module contains the SettingsManager class, which is responsible for saving and loading user settings.""" +""" + +Manages saving and loading user settings, overwriting defaults with user preferences. +Default settings should be set in both the main application and the SettingsManager. + +""" #endregion @@ -303,7 +308,7 @@ def reset_settings(self): # Extra panels self.parent.thumbnails_visible.set(value=True) self.parent.thumbnail_width.set(value=50) - self.parent.update_thumbnail_panel() + self.parent.debounce_update_thumbnail_panel() self.parent.edit_panel_visible_var.set(value=False) self.parent.edit_panel.toggle_edit_panel() # Title diff --git a/main/scripts/text_controller.py b/main/scripts/text_controller.py index 8ccc8e0..76f65c5 100644 --- a/main/scripts/text_controller.py +++ b/main/scripts/text_controller.py @@ -15,7 +15,7 @@ from tkinter import ( ttk, Toplevel, messagebox, StringVar, BooleanVar, - Frame, Menu, Scrollbar, scrolledtext, + Frame, Menu, Scrollbar, scrolledtext, PanedWindow, Label, Listbox, font, TclError ) @@ -46,6 +46,11 @@ def __init__(self, parent, root): self.auto_exclude_tags_var = BooleanVar(value=False) self.filter_is_active = False + # MyTags + self.show_all_tags_var = BooleanVar(value=True) + self.hide_mytags_controls_var = BooleanVar(value=False) + self.hide_alltags_controls_var = BooleanVar(value=False) + #endregion ################################################################################################################################################ @@ -317,20 +322,20 @@ def copy_selection(): batch_interrogate_checkbutton = ttk.Checkbutton(top_frame, text="Batch", takefocus=False, variable=self.batch_interrogate_images_var) batch_interrogate_checkbutton.pack(side='right') ToolTip.create(batch_interrogate_checkbutton, "Interrogate all images\nAn Auto-Insert mode must be selected", 200, 6, 12) - # Main Frame - widget_frame = Frame(self.parent.tab4) - widget_frame.pack(fill='both', expand=True) + + # Main Paned Window + paned_window = PanedWindow(self.parent.tab4, orient='horizontal', sashwidth=6, bg="#d0d0d0") + paned_window.pack(fill='both', expand=True) + # Listbox Frame - listbox_frame = Frame(widget_frame) - listbox_frame.pack(side='left', fill='both', expand=True) + listbox_frame = Frame(paned_window) + paned_window.add(listbox_frame, stretch="never") + paned_window.paneconfig(listbox_frame, width=200, minsize=40) listbox_y_scrollbar = Scrollbar(listbox_frame, orient="vertical") - listbox_x_scrollbar = Scrollbar(listbox_frame, orient="horizontal") - self.auto_tag_listbox = Listbox(listbox_frame, width=20, selectmode="extended", exportselection=False, yscrollcommand=listbox_y_scrollbar.set, xscrollcommand=listbox_x_scrollbar.set) + self.auto_tag_listbox = Listbox(listbox_frame, width=20, selectmode="extended", exportselection=False, yscrollcommand=listbox_y_scrollbar.set) self.auto_tag_listbox.bind('<>', lambda event: self.update_auto_tag_stats_label()) self.auto_tag_listbox.bind("", lambda event: listbox_context_menu.tk_popup(event.x_root, event.y_root)) listbox_y_scrollbar.config(command=self.auto_tag_listbox.yview) - listbox_x_scrollbar.config(command=self.auto_tag_listbox.xview) - listbox_x_scrollbar.pack(side='bottom', fill='x') self.auto_tag_listbox.pack(side='left', fill='both', expand=True) listbox_y_scrollbar.pack(side='left', fill='y') # Listbox - Context Menu @@ -345,8 +350,9 @@ def copy_selection(): listbox_context_menu.add_command(label="Selection: Clear", command=clear_selection) listbox_context_menu.add_command(label="Selection: Add to MyTags", command=lambda: self.parent.add_to_custom_dictionary(origin="auto_tag")) # Control Frame - control_frame = Frame(widget_frame) - control_frame.pack(side='left', fill='both', expand=True) + control_frame = Frame(paned_window) + paned_window.add(control_frame, stretch="always") + paned_window.paneconfig(control_frame, minsize=200) # Model Selection model_selection_frame = Frame(control_frame) model_selection_frame.pack(side='top', fill='x', padx=2, pady=2) @@ -403,7 +409,7 @@ def copy_selection(): excluded_tags_label = Label(excluded_entry_frame, text="Exclude:", width=9, anchor="w") excluded_tags_label.pack(side='left') ToolTip.create(excluded_tags_label, "Enter tags that will be excluded from interrogation\nSeparate tags with commas", 200, 6, 12) - self.excluded_tags_entry = ttk.Entry(excluded_entry_frame, width=25) + self.excluded_tags_entry = ttk.Entry(excluded_entry_frame, width=5) self.excluded_tags_entry.pack(side='left', fill='both', expand=True) self.bind_entry_functions(self.excluded_tags_entry) auto_exclude_tags_checkbutton = ttk.Checkbutton(excluded_entry_frame, text="Auto", takefocus=False, variable=self.auto_exclude_tags_var) @@ -925,16 +931,18 @@ def set_font_and_size(font, size): size = int(size) self.parent.text_box.config(font=(font, size)) self.font_size_label.config(text=f"Size: {size}") + font_box_tooltip.config(text=f"{font}") def reset_to_defaults(): - self.parent.font_var.set(self.default_font) - self.size_scale.set(self.default_font_size) - set_font_and_size(self.default_font, self.default_font_size) + self.parent.font_var.set(self.parent.default_font) + self.size_scale.set(self.parent.default_font_size) + set_font_and_size(self.parent.default_font, self.parent.default_font_size) font_label = Label(self.parent.tab7, width=8, text="Font:") font_label.pack(side="left", anchor="n", pady=4) ToolTip.create(font_label, "Recommended Fonts: Courier New, Ariel, Consolas, Segoe UI", 200, 6, 12) font_box = ttk.Combobox(self.parent.tab7, textvariable=self.parent.font_var, width=4, takefocus=False, state="readonly", values=list(font.families())) font_box.set(self.parent.current_font_name) font_box.bind("<>", lambda event: set_font_and_size(self.parent.font_var.get(), self.size_scale.get())) + font_box_tooltip = ToolTip.create(font_box, f"{self.parent.current_font_name}", 200, 6, 12) font_box.pack(side="left", anchor="n", pady=4, fill="x", expand=True) self.font_size_label = Label(self.parent.tab7, text=f"Size: {self.parent.font_size_var.get()}", width=14) self.font_size_label.pack(side="left", anchor="n", pady=4) @@ -978,34 +986,39 @@ def add_tag(): def remove_tag(): listbox = self.custom_dictionary_listbox selected_indices = listbox.curselection() + if not selected_indices: + return for index in reversed(selected_indices): listbox.delete(index) # EDIT def edit_tag(): listbox = self.custom_dictionary_listbox selected_indices = listbox.curselection() - if selected_indices: - index = selected_indices[0] - tag = listbox.get(index) - tag_entry.delete(0, 'end') - tag_entry.insert(0, tag) - listbox.delete(index) + if not selected_indices: + return + index = selected_indices[0] + tag = listbox.get(index) + tag_entry.delete(0, 'end') + tag_entry.insert(0, tag) + listbox.delete(index) # INSERT - def insert_tag(position='end'): - listbox = self.custom_dictionary_listbox + def insert_tag(listbox, position='start'): selected_indices = listbox.curselection() + if not selected_indices: + return for index in selected_indices: tag = listbox.get(index) current_text = self.parent.text_box.get('1.0', 'end-1c') separator = ', ' if current_text else '' if position == 'start': self.parent.text_box.insert('1.0', f"{tag}{separator}") - else: # position == 'end' + else: # 'end' self.parent.text_box.insert('end', f"{separator}{tag}") # MOVE - def move(direction): - listbox = self.custom_dictionary_listbox + def move(listbox, direction): selected_indices = listbox.curselection() + if not selected_indices: + return delta = -1 if direction == 'up' else 1 for index in (selected_indices if direction == 'up' else reversed(selected_indices)): new_index = index + delta @@ -1014,9 +1027,19 @@ def move(direction): listbox.delete(index) listbox.insert(new_index, tag) listbox.selection_set(new_index) + # ADD TO MYTAGS + def add_to_mytags(): + selected_indices = self.all_tags_listbox.curselection() + if not selected_indices: + return + existing_tags = set(self.custom_dictionary_listbox.get(0, 'end')) + for index in selected_indices: + tag = self.all_tags_listbox.get(index) + if tag not in existing_tags: + self.custom_dictionary_listbox.insert('end', tag) # CONTEXT MENU def show_context_menu(event): - listbox = self.custom_dictionary_listbox + listbox = event.widget index = listbox.nearest(event.y) if not listbox.curselection(): listbox.selection_clear(0, 'end') @@ -1024,65 +1047,192 @@ def show_context_menu(event): elif index not in listbox.curselection(): listbox.selection_clear(0, 'end') listbox.selection_set(index) + # ALL + def select_all(): + listbox.selection_set(0, 'end') + # INVERT + def invert_selection(): + current = set(listbox.curselection()) + all_indices = set(range(listbox.size())) + inverted = all_indices - current + listbox.selection_clear(0, 'end') + for i in inverted: + listbox.selection_set(i) + # MENU if listbox.curselection(): menu = Menu(listbox, tearoff=0) - menu.add_command(label="Prefix", command=lambda: insert_tag('start')) - menu.add_command(label="Append", command=lambda: insert_tag('end')) - menu.add_separator() - menu.add_command(label="Edit", command=edit_tag) - menu.add_command(label="Remove", command=remove_tag) + if listbox == self.custom_dictionary_listbox: + menu.add_command(label="Prefix", command=lambda: insert_tag(listbox, 'start')) + menu.add_command(label="Append", command=lambda: insert_tag(listbox, 'end')) + menu.add_separator() + menu.add_command(label="Edit", command=edit_tag) + menu.add_command(label="Remove", command=remove_tag) + menu.add_separator() + menu.add_command(label="Move Up", command=lambda: move(listbox, 'up')) + menu.add_command(label="Move Down", command=lambda: move(listbox, 'down')) + else: + menu.add_command(label="Prefix", command=lambda: insert_tag(listbox, 'start')) + menu.add_command(label="Append", command=lambda: insert_tag(listbox, 'end')) + menu.add_separator() + menu.add_command(label="Add to MyTags", command=add_to_mytags) + menu.add_separator() + menu.add_command(label="Refresh", command=self.refresh_all_tags_listbox) menu.add_separator() - menu.add_command(label="Move Up", command=lambda: move('up')) - menu.add_command(label="Move Down", command=lambda: move('down')) + menu.add_command(label="Selection: All", command=select_all) + menu.add_command(label="Selection: Invert", command=invert_selection) menu.tk_popup(event.x_root, event.y_root) # INTERFACE self.parent.create_custom_dictionary(refresh=False) tab_frame = Frame(self.parent.tab8) tab_frame.pack(side='top', fill='both', expand=True) - # Top Row + tab_frame.grid_rowconfigure(1, weight=1) + tab_frame.grid_columnconfigure(0, weight=1) + # Top Row - Row 0 top_frame = Frame(tab_frame) - top_frame.pack(side='top', fill='x', pady=4) - info_label = Label(top_frame, text="Manage your custom tags:") - info_label.pack(side='left') - save_button = ttk.Button(top_frame, text="Save Tags", takefocus=False, command=save) - save_button.pack(side='right') - use_mytags_checkbutton = ttk.Checkbutton(top_frame, text="Use MyTags", variable=self.parent.use_mytags_var, takefocus=False, command=self.parent.refresh_custom_dictionary) - use_mytags_checkbutton.pack(side='right', fill='x') - # Middle Row - text_frame = Frame(tab_frame) - text_frame.pack(side='top', fill='both', expand=True) - self.custom_dictionary_listbox = Listbox(text_frame, selectmode='extended', height=1) - self.custom_dictionary_listbox.pack(side='left', fill='both', expand=True) - self.custom_dictionary_listbox.bind("", show_context_menu) - # Sidebar - listbox_button_frame = Frame(text_frame) - listbox_button_frame.pack(side='left', fill='both') - prefix_button = ttk.Button(listbox_button_frame, text="Prefix", command=lambda: insert_tag('start')) - prefix_button.grid(row=0, column=0) - append_button = ttk.Button(listbox_button_frame, text="Append", command=insert_tag) - append_button.grid(row=0, column=1) - ttk.Separator(listbox_button_frame, orient='horizontal').grid(row=1, column=0, columnspan=2, sticky='ew', pady=4) - edit_button = ttk.Button(listbox_button_frame, text="Edit", command=edit_tag) - edit_button.grid(row=2, column=0) - remove_button = ttk.Button(listbox_button_frame, text="Remove", command=remove_tag) - remove_button.grid(row=2, column=1) - ttk.Separator(listbox_button_frame, orient='horizontal').grid(row=3, column=0, columnspan=2, sticky='ew', pady=4) - move_up_button = ttk.Button(listbox_button_frame, text="Move Up", command=lambda: move('up')) - move_up_button.grid(row=4, column=0) - move_down_button = ttk.Button(listbox_button_frame, text="Move Down", command=lambda: move('down')) - move_down_button.grid(row=4, column=1) - # Bottom Row - entry_frame = Frame(tab_frame) - entry_frame.pack(side='top', fill='x', pady=4) + top_frame.grid(row=0, column=0, sticky='ew') + help_button = ttk.Button(top_frame, text="?", takefocus=False, width=2, command=self.show_my_tags_help) + help_button.pack(side='left') + options_menu = ttk.Menubutton(top_frame, text="Options", takefocus=False) + options_menu.pack(side='left') + options_menu.menu = Menu(options_menu, tearoff=0) + options_menu["menu"] = options_menu.menu + options_menu.menu.add_checkbutton(label="Use: MyTags", variable=self.parent.use_mytags_var, command=self.parent.refresh_custom_dictionary) + options_menu.menu.add_checkbutton(label="Show: All Tags", variable=self.show_all_tags_var, command=self.toggle_all_tags_listbox) + options_menu.menu.add_separator() + options_menu.menu.add_command(label="Refresh: My Tags", command=load_tag_file) + options_menu.menu.add_command(label="Refresh: All Tags", command=self.refresh_all_tags_listbox) + options_menu.menu.add_separator() + options_menu.menu.add_checkbutton(label="Hide: My Tags - Controls", variable=self.hide_mytags_controls_var, command=self.toggle_mytags_controls) + options_menu.menu.add_checkbutton(label="Hide: All Tags - Controls", variable=self.hide_alltags_controls_var, command=self.toggle_alltags_controls) + options_menu.menu.add_separator() + options_menu.menu.add_command(label="Open MyTags File...", command=lambda: self.parent.open_textfile(self.parent.my_tags_csv)) + # entry_frame + entry_frame = Frame(top_frame) + entry_frame.pack(side='left', fill='x', expand=True, pady=4) tag_entry = ttk.Entry(entry_frame) tag_entry.pack(side='left', fill='x', expand=True) tag_entry.bind('', lambda event: add_tag()) add_button = ttk.Button(entry_frame, text="Add", command=add_tag) add_button.pack(side='left') + save_button = ttk.Button(top_frame, text="Save Tags", takefocus=False, command=save) + save_button.pack(side='right') + # Middle Row + self.text_frame = ttk.PanedWindow(tab_frame, orient='horizontal') + self.text_frame.grid(row=1, column=0, sticky='nsew') + # My Tags section + my_tags_frame = Frame(self.text_frame) + header_frame = Frame(my_tags_frame) + header_frame.grid(row=0, column=0, sticky='ew', padx=2, pady=(2,0)) + my_tags_label = ttk.Label(header_frame, text="My Tags:") + my_tags_label.pack(side='left', padx=(0,5)) + self.custom_dictionary_listbox = Listbox(my_tags_frame, selectmode='extended') + self.custom_dictionary_listbox.grid(row=1, column=0, sticky='nsew') + my_tags_frame.grid_rowconfigure(1, weight=1) + my_tags_frame.grid_columnconfigure(0, weight=1) + self.custom_dictionary_listbox.bind("", show_context_menu) + self.custom_dictionary_listbox.bind("", lambda event: insert_tag(self.custom_dictionary_listbox, 'end')) + # Buttons + self.my_tags_button_frame = Frame(my_tags_frame) + self.my_tags_button_frame.grid(row=2, column=0, sticky='ew', pady=(2,0)) + self.my_tags_button_frame.grid_columnconfigure(0, weight=1) + self.my_tags_button_frame.grid_columnconfigure(1, weight=1) + prefix_button = ttk.Button(self.my_tags_button_frame, text="Prefix", command=lambda: insert_tag(self.custom_dictionary_listbox, 'start')) + prefix_button.grid(row=0, column=0, sticky='ew', padx=2) + append_button = ttk.Button(self.my_tags_button_frame, text="Append", command=lambda: insert_tag(self.custom_dictionary_listbox, 'end')) + append_button.grid(row=0, column=1, sticky='ew', padx=2) + edit_button = ttk.Button(self.my_tags_button_frame, text="Edit", command=edit_tag) + edit_button.grid(row=2, column=0, sticky='ew', padx=2) + remove_button = ttk.Button(self.my_tags_button_frame, text="Remove", command=remove_tag) + remove_button.grid(row=2, column=1, sticky='ew', padx=2) + move_up_button = ttk.Button(self.my_tags_button_frame, text="Move Up", command=lambda: move(self.custom_dictionary_listbox, 'up')) + move_up_button.grid(row=4, column=0, sticky='ew', padx=2) + move_down_button = ttk.Button(self.my_tags_button_frame, text="Move Down", command=lambda: move(self.custom_dictionary_listbox, 'down')) + move_down_button.grid(row=4, column=1, sticky='ew', padx=2) + # All Tags section + self.all_tags_frame = Frame(self.text_frame) + self.all_tags_frame.grid_rowconfigure(1, weight=1) + self.all_tags_frame.grid_columnconfigure(0, weight=1) + all_tags_label = ttk.Label(self.all_tags_frame, text="All Tags") + all_tags_label.grid(row=0, column=0, sticky='w', padx=2, pady=(2,0)) + self.all_tags_listbox = Listbox(self.all_tags_frame, selectmode='extended') + self.all_tags_listbox.grid(row=1, column=0, columnspan=2, sticky='nsew') + self.all_tags_listbox.bind("", show_context_menu) + self.all_tags_listbox.bind("", lambda event: insert_tag(self.all_tags_listbox, 'end')) + # Add frames to PanedWindow + self.text_frame.add(my_tags_frame, weight=1) + self.text_frame.add(self.all_tags_frame, weight=1) + # Buttons + self.all_tags_button_frame = Frame(self.all_tags_frame) + self.all_tags_button_frame.grid(row=2, column=0, sticky='ew', pady=(2,0)) + self.all_tags_button_frame.grid_columnconfigure(0, weight=1) + self.all_tags_button_frame.grid_columnconfigure(1, weight=0) + self.all_tags_button_frame.grid_columnconfigure(2, weight=1) + prefix_button = ttk.Button(self.all_tags_button_frame, text="Prefix", command=lambda: insert_tag(self.all_tags_listbox, 'start')) + prefix_button.grid(row=0, column=0, sticky='ew', padx=2) + add_button = ttk.Button(self.all_tags_button_frame, text="<", command=add_to_mytags, width=2) + add_button.grid(row=0, column=1) + ToolTip.create(add_button, "Add selected tags to 'My Tags'", 200, 6, 12) + append_button = ttk.Button(self.all_tags_button_frame, text="Append", command=lambda: insert_tag(self.all_tags_listbox, 'end')) + append_button.grid(row=0, column=2, sticky='ew', padx=2) load_tag_file() self.parent.refresh_custom_dictionary() + def refresh_all_tags_listbox(self, tags=None): + listbox = self.all_tags_listbox + if not tags: + self.parent.stat_calculator.calculate_file_stats() + tags = self.parent.stat_calculator.sorted_captions + listbox.delete(0, 'end') + for tag, count in tags: + listbox.insert('end', tag) + + + def toggle_all_tags_listbox(self): + if self.show_all_tags_var.get(): + self.all_tags_frame.grid(row=0, column=2, sticky='nsew') + self.text_frame.add(self.all_tags_frame, weight=1) + else: + self.text_frame.remove(self.all_tags_frame) + + + def toggle_mytags_controls(self): + if self.hide_mytags_controls_var.get(): + self.my_tags_button_frame.grid_remove() + else: + self.my_tags_button_frame.grid(row=2, column=0, sticky='ew', pady=(2,0)) + + + def toggle_alltags_controls(self): + if self.hide_alltags_controls_var.get(): + self.all_tags_button_frame.grid_remove() + else: + self.all_tags_button_frame.grid(row=2, column=0, sticky='ew', pady=(2,0)) + + + def show_my_tags_help(self): + messagebox.showinfo("Help", + "MyTags:\n" + "A list of custom tags/keywords that will be used for autocomplete suggestions or for quick insertion into the text box.\n\n" + "Basic Operations:\n" + "• Add tags: Type + Enter, right-click text, or use All Tags list\n" + "• Insert tags: Select and use Prefix/Append buttons or right-click menu\n" + "• Double-click any tag to instantly insert it (append)\n\n" + "Tag Management:\n" + "• Edit/Remove selected tags\n" + "• Reorder with Move Up/Down (affects autocomplete priority)\n" + "• Save changes to file (required to apply changes)\n\n" + "Features:\n" + "• Use MyTags: Toggle autocomplete suggestions\n" + "• Show All Tags: View tags from all text files\n" + "• Refresh: Update My Tags or All Tags lists\n" + "• Hide Controls: Toggle visibility of control buttons\n" + "• Open my_tags.csv: Edit tags directly in text editor\n\n" + "Note: Tags are stored in 'my_tags.csv'\n" + "Use 'Batch Tag Edit' tool to modify All Tags" + ) + + #endregion ################################################################################################################################################ #region (9) File Stats @@ -1093,8 +1243,8 @@ def create_stats_widgets_tab9(self): tab_frame.pack(fill='both', expand=True) button_frame = Frame(tab_frame) button_frame.pack(side='top', fill='x', pady=4) - info_label = Label(button_frame, text="^^^Expand this frame^^^") - info_label.pack(side='left') + self.info_label = Label(button_frame, text="Characters: 0 | Words: 0") + self.info_label.pack(side='left') refresh_button = ttk.Button(button_frame, width=10, text="Refresh", takefocus=False, command=lambda: self.parent.stat_calculator.calculate_file_stats(manual_refresh=True)) refresh_button.pack(side='right') ToolTip.create(refresh_button, "Refresh the file stats", 200, 6, 12)