Kapitan 9361bc0363
All checks were successful
Gitea/kapitanbooru-uploader/pipeline/head This commit looks good
Translations, suggestion box, UX
2025-03-03 00:47:38 +01:00

307 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import tkinter as tk
from tkinter import font
from .I18N import _
from .TagsRepo import TagsRepo
from .common import open_tag_wiki_url
from .tag_processing import process_tag
from .settings import Settings
class AutocompleteEntry(tk.Entry):
def __init__(self, master, tags_repo: TagsRepo, callback=None, *args, **kwargs):
super().__init__(master, *args, **kwargs)
self.tags_repo = tags_repo
self.callback = callback
self.listbox = None
self.listbox_window = None
self.suggestions = []
self.suggestion_map = {}
self.search_after_id = None # Przechowuje ID opóźnionego wyszukiwania
self.selection_index = -1
self.bind("<KeyRelease>", self.on_keyrelease)
self.bind("<Down>", self.on_down)
self.bind("<Up>", self.on_up)
self.bind("<Return>", self.on_return)
self.bind("<Tab>", self.on_return)
self.bind("<FocusOut>", self.on_focus_out)
def on_focus_out(self, event):
self.after(10, self.delayed_focus_check)
def delayed_focus_check(self):
try:
# Get current focus using Tk's internal command
focused_name = self.tk.call("focus")
if not focused_name:
self.hide_listbox()
return
# Convert to widget object
focused_widget = self.nametowidget(focused_name)
# Check if focus is in our listbox hierarchy
if self.listbox_window and self.is_child_of_listbox_window(focused_widget):
return
except (KeyError, tk.TclError):
pass
self.hide_listbox()
def is_child_of_listbox_window(self, widget):
current = widget
while current:
if current == self.listbox_window:
return True
current = current.master
return False
def on_keyrelease(self, event):
if event.keysym in ("Down", "Up", "Return", "Tab"):
return
if event.keysym == "Escape":
self.hide_listbox()
return
if self.search_after_id:
self.after_cancel(self.search_after_id)
self.search_after_id = self.after(200, self.update_suggestions)
def update_suggestions(self):
self.search_after_id = None
# Pobieramy cały tekst oraz indeks kursora
full_text = self.get()
# self.index(tk.INSERT) zwraca indeks w formacie "linia.kolumna", możemy go wykorzystać jako indeks znakowy
text_before_cursor = full_text[: self.index(tk.INSERT)]
# Jeżeli ostatni znak to spacja, to znaczy, że użytkownik zakończył ostatni tag nie sugerujemy
if text_before_cursor and text_before_cursor[-1].isspace():
self.hide_listbox()
return
# Podziel tekst przed kursorem na tokeny (oddzielone spacjami)
tokens = text_before_cursor.split()
prefix = tokens[-1] if tokens else ""
if not prefix:
self.hide_listbox()
return
# Pobieramy sugestie na podstawie prefixu
self.suggestions = self.get_suggestions(prefix)
if self.suggestions:
self.show_listbox()
else:
self.hide_listbox()
def on_return(self, event):
if self.listbox and self.selection_index >= 0:
selected_display = self.listbox.get(self.selection_index)
# Pobieramy wartość do wstawienia z mapy (czyli bez liczby postów)
suggestion = self.suggestion_map.get(selected_display, selected_display)
tag = suggestion
else:
tag = self.get().strip()
if tag and self.callback:
self.callback(tag)
self.delete(0, tk.END)
self.hide_listbox()
return "break"
def get_suggestions(self, prefix):
try:
conn = self.tags_repo.get_conn()
cursor = conn.cursor()
query = """
SELECT name, category, post_count FROM tags
WHERE name LIKE ? AND post_count >= 1
ORDER BY post_count DESC
LIMIT 10
"""
cursor.execute(query, (prefix + "%",))
results = cursor.fetchall()
conn.close()
# Mapowanie kategorii na prefiksy
prefix_map = {1: "artist:", 3: "copyright:", 4: "character:", 5: "meta:"}
suggestions = []
# Utwórz słownik mapujący tekst wyświetlany (z liczbą) na tekst do wstawienia (bez liczby)
self.suggestion_map = {}
for row in results:
name, category, post_count = row
tag_insert = prefix_map.get(category, "") + name
display_text = f"{tag_insert} ({post_count})"
suggestions.append(display_text)
self.suggestion_map[display_text] = tag_insert
return suggestions
except Exception as e:
print(_("Błąd przy pobieraniu sugestii:"), e)
return []
def show_listbox(self):
if self.listbox_window:
self.listbox_window.destroy()
self.listbox_window = tk.Toplevel(self)
self.listbox_window.wm_overrideredirect(True)
self.listbox = tk.Listbox(self.listbox_window, height=6)
self.listbox.bind("<Button-1>", self.on_listbox_click)
self.listbox.bind("<Motion>", self.on_listbox_motion)
for suggestion in self.suggestions:
self.listbox.insert(tk.END, suggestion)
self.listbox.pack(fill=tk.BOTH, expand=True)
# Pobierz czcionkę używaną w listboxie
list_font = font.Font(font=self.listbox.cget("font"))
# Oblicz maksymalną szerokość na podstawie najdłuższego elementu
max_width = (
max(list_font.measure(item) for item in self.suggestions) + 20
) # +20 dla marginesu
# Ustaw szerokość okna na podstawie najszerszego elementu
self.listbox_window.geometry(f"{max_width}x200") # 200 - wysokość okna
# Umieszczamy okno poniżej pola autouzupełniania
x = self.winfo_rootx()
y = self.winfo_rooty() + self.winfo_height()
self.listbox_window.geometry("+%d+%d" % (x, y))
self.listbox_window.deiconify()
self.selection_index = -1
def hide_listbox(self):
if self.listbox_window:
self.listbox_window.destroy()
self.listbox_window = None
self.listbox = None
self.selection_index = -1
def on_listbox_click(self, event):
if self.listbox:
# Process click first before changing focus
index = self.listbox.nearest(event.y)
if index >= 0:
# Get the display text from listbox
selected_display = self.listbox.get(index)
# Get the actual tag from the suggestion map
tag = self.suggestion_map.get(selected_display, selected_display)
if self.callback:
self.callback(tag)
# Clear the entry and hide listbox
self.delete(0, tk.END)
# Then explicitly manage focus
self.focus_set() # Use focus_set() instead of focus_force()
self.hide_listbox()
return "break"
def on_listbox_motion(self, event):
if self.listbox:
self.listbox.selection_clear(0, tk.END)
index = self.listbox.nearest(event.y)
self.listbox.selection_set(first=index)
self.selection_index = index
def on_down(self, event):
if self.listbox:
self.selection_index = (self.selection_index + 1) % self.listbox.size()
self.listbox.selection_clear(0, tk.END)
self.listbox.selection_set(self.selection_index)
self.listbox.activate(self.selection_index)
return "break"
def on_up(self, event):
if self.listbox:
self.selection_index = (self.selection_index - 1) % self.listbox.size()
self.listbox.selection_clear(0, tk.END)
self.listbox.selection_set(self.selection_index)
self.listbox.activate(self.selection_index)
return "break"
class TagManager(tk.Frame):
"""
This widget holds a tag input entry (with autocompletion) and a display area
that shows the entered tags. In the display area, left-clicking on a tag removes it,
and right-clicking opens its wiki URL. Tag appearance is adjusted (color/underline)
based on custom logic.
"""
def __init__(
self,
master,
settings: Settings,
tags_repo: TagsRepo,
tag_change_callback=None,
*args,
**kwargs,
):
super().__init__(master, *args, **kwargs)
self.tag_change_callback = tag_change_callback
self.tags_repo = tags_repo
self.settings = settings
self.manual_tags = [] # List to hold manually entered tags
# Entry for new tags (with autocompletion)
self.entry = AutocompleteEntry(
self, callback=self.add_tag, tags_repo=self.tags_repo
)
self.entry.pack(fill=tk.X, padx=5, pady=5)
# Text widget for displaying already entered tags
self.tags_display = tk.Text(self, wrap=tk.WORD, height=4)
self.tags_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.tags_display.config(state=tk.DISABLED)
# (Optional: add a scrollbar if needed)
def add_tag(self, tag):
"""Add a new tag if it is not already present."""
if tag and tag not in self.manual_tags:
self.manual_tags.append(tag)
self.update_tags_display()
def update_tags_display(self):
"""Refresh the text widget to display all manual tags with styling and event bindings."""
self.tags_display.config(state="normal")
self.tags_display.delete("1.0", tk.END)
for tag in self.manual_tags:
# Process tag to decide its style
_, deprecated = process_tag(tag, self.tags_repo)
if deprecated is True:
color = "red"
underline = 1
elif deprecated is None:
color = "darkorange"
underline = 1
else:
color = "blue"
underline = 0
start_index = self.tags_display.index(tk.INSERT)
self.tags_display.insert(tk.INSERT, tag)
end_index = self.tags_display.index(tk.INSERT)
tag_name = "manual_" + tag
self.tags_display.tag_add(tag_name, start_index, end_index)
self.tags_display.tag_configure(
tag_name, foreground=color, underline=underline
)
# Left-click: remove tag; Right-click: open wiki URL
self.tags_display.tag_bind(tag_name, "<Button-1>", self.remove_tag)
self.tags_display.tag_bind(tag_name, "<Button-3>", self.open_tag_wiki_url)
self.tags_display.insert(tk.INSERT, " ")
self.tags_display.config(state=tk.DISABLED)
if self.tag_change_callback:
self.tag_change_callback()
def remove_tag(self, event):
"""Remove the clicked tag from the list and update the display."""
index = self.tags_display.index("@%d,%d" % (event.x, event.y))
for t in self.tags_display.tag_names(index):
if t.startswith("manual_"):
tag = t[len("manual_") :]
if tag in self.manual_tags:
self.manual_tags.remove(tag)
self.update_tags_display()
break
def open_tag_wiki_url(self, event):
"""Open a wiki URL for the clicked tag."""
index = self.tags_display.index("@%d,%d" % (event.x, event.y))
for t in self.tags_display.tag_names(index):
if t.startswith("manual_"):
tag = t[len("manual_") :]
open_tag_wiki_url(tag, self.settings)
break