All checks were successful
Gitea/kapitanbooru-uploader/pipeline/head This commit looks good
307 lines
12 KiB
Python
307 lines
12 KiB
Python
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
|