Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 171 additions & 7 deletions hotkey_tagger.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

import json
from pathlib import Path
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple

from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QFont, QPixmap, QKeySequence
Expand Down Expand Up @@ -336,6 +336,30 @@ def _init_ui(self, hotkey_map: Dict[str, str]) -> None:
btn_row.addStretch()
layout.addLayout(btn_row)

# Sorting and import controls
sort_row = QHBoxLayout()
sort_az_btn = QPushButton("Sort A→Z (Tag)", self)
sort_az_btn.setToolTip("Sort rows alphabetically by tag name")
sort_az_btn.clicked.connect(self._sort_by_tag)
sort_key_btn = QPushButton("Sort by Key (Keyboard Order)", self)
sort_key_btn.setToolTip(
"Sort rows by keyboard position of their first key "
"(numbers row → QWERTY row → ASDF row → ZXCV row)"
)
sort_key_btn.clicked.connect(self._sort_by_key)
load_btn = QPushButton("Load Hotkeys from File…", self)
load_btn.setToolTip(
"Import hotkey bindings from a hotkeys.json file "
"(e.g. copied from a previous image folder)"
)
load_btn.clicked.connect(self._load_from_file)
sort_row.addWidget(sort_az_btn)
sort_row.addWidget(sort_key_btn)
sort_row.addSpacing(16)
sort_row.addWidget(load_btn)
sort_row.addStretch()
layout.addLayout(sort_row)

# OK / Cancel
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
buttons.accepted.connect(self.accept)
Expand Down Expand Up @@ -385,6 +409,105 @@ def _remove_selected_row(self) -> None:
if 0 <= row < self.table.rowCount():
self.table.removeRow(row)

# ------------------------------------------------------------------
# Table helpers: extract, populate, sort, and import
# ------------------------------------------------------------------

def _extract_rows(self) -> List[Tuple[str, str]]:
"""Return all table rows as a list of (keys_text, tag_text) tuples."""
rows: List[Tuple[str, str]] = []
for row in range(self.table.rowCount()):
key_item = self.table.item(row, 0)
tag_item = self.table.item(row, 1)
rows.append((
key_item.text() if key_item else "",
tag_item.text() if tag_item else "",
))
return rows

def _populate_table(self, rows: List[Tuple[str, str]]) -> None:
"""Replace all table contents with the given (keys_text, tag_text) rows."""
self.table.blockSignals(True)
self.table.setRowCount(0)
for keys, tag in rows:
self._add_row(keys, tag)
self.table.blockSignals(False)
self._refresh_keyboard_preview()

def _sort_by_tag(self) -> None:
"""Sort table rows alphabetically by tag name (case-insensitive)."""
rows = self._extract_rows()
rows.sort(key=lambda r: r[1].lower())
self._populate_table(rows)

def _sort_by_key(self) -> None:
"""Sort table rows by the keyboard position of their first assigned key.

The order follows: numbers row (1–0), QWERTY row, ASDF row, ZXCV row,
then any remaining characters, then rows with no recognised keys last.
"""
rows = self._extract_rows()

def _key_order(row: Tuple[str, str]) -> Tuple[int, str]:
keys = parse_keys_field(row[0])
if keys:
# Use the position of the lowest-order key in the row
order = min(KEYBOARD_ORDER.get(k, len(KEYBOARD_ORDER)) for k in keys)
else:
order = len(KEYBOARD_ORDER) # sort unrecognised keys to the end
return (order, row[1].lower())

rows.sort(key=_key_order)
self._populate_table(rows)

def _load_from_file(self) -> None:
"""Import hotkey bindings from a user-chosen hotkeys.json file.

The current table contents are *replaced* by the imported bindings.
"""
path_str, _ = QFileDialog.getOpenFileName(
self,
"Load Hotkeys from File",
"",
"JSON Files (*.json);;All Files (*)",
)
if not path_str:
return

try:
data = json.loads(Path(path_str).read_text(encoding="utf-8"))
except Exception as exc:
QMessageBox.warning(self, "Load Hotkeys", f"Could not read file:\n{exc}")
return

if not isinstance(data, dict):
QMessageBox.warning(
self, "Load Hotkeys", "Invalid format – expected a JSON object."
)
return

# Normalise to lowercase single-char keys (same logic as _load_folder_hotkeys)
hotkey_map: Dict[str, str] = {
str(k).lower(): str(v)
for k, v in data.items()
if len(str(k)) == 1
}

if not hotkey_map:
QMessageBox.information(
self, "Load Hotkeys", "No valid single-key bindings found in the file."
)
return

# Rebuild the table from the imported map
by_tag = group_keys_by_tag(hotkey_map)
rows: List[Tuple[str, str]] = [(",".join(keys), tag) for tag, keys in by_tag.items()]
self._populate_table(rows)

# ------------------------------------------------------------------
# Hotkey map extraction
# ------------------------------------------------------------------

def get_hotkey_map(self) -> Dict[str, str]:
"""
Return a flattened key->tag map.
Expand Down Expand Up @@ -496,20 +619,25 @@ def _init_ui(self) -> None:

# ---- Navigation row ----
nav = QHBoxLayout()
prev_btn = QPushButton("◀ Prev")
prev_btn.clicked.connect(self.prev_image)
self.first_btn = QPushButton("⏮ First")
self.first_btn.clicked.connect(self.first_image)

# Keep a reference so the label can be toggled by _on_next_mode_changed
self.prev_btn = QPushButton("◀ Prev")
self.prev_btn.clicked.connect(self.prev_action)

self.next_btn = QPushButton("Next ▶") # keep a reference; we change its label
self.next_btn.clicked.connect(self.next_action)

# NEW: checkbox to toggle "next untagged" mode
# Checkbox to toggle "untagged" mode for both Prev and Next
self.chk_next_untagged = QCheckBox("Next = untagged")
self.chk_next_untagged.stateChanged.connect(self._on_next_mode_changed)

self.progress_label = QLabel("0 / 0")
self.progress_label.setAlignment(Qt.AlignCenter)

nav.addWidget(prev_btn)
nav.addWidget(self.first_btn)
nav.addWidget(self.prev_btn)
nav.addWidget(self.progress_label, stretch=1)
nav.addWidget(self.chk_next_untagged) # <-- add checkbox in the row
nav.addWidget(self.next_btn)
Expand Down Expand Up @@ -557,7 +685,7 @@ def add(seq: str, fn):
self._shortcuts.append(sc)

# Previous / Next
add("Left", self.prev_image)
add("Left", self.prev_action)
add("Right", self.next_action)

# Also advance with Space and Enter/Return
Expand Down Expand Up @@ -738,24 +866,60 @@ def _show_current_image(self) -> None:
# Navigation
# ------------------------------------------------------------------ #

def first_image(self) -> None:
if self.image_files and self.current_index != 0:
self.current_index = 0
self._show_current_image()
self.settings.last_image_index = self.current_index

def prev_image(self) -> None:
if self.image_files and self.current_index > 0:
self.current_index -= 1
self._show_current_image()
self.settings.last_image_index = self.current_index

def prev_action(self) -> None:
"""Delegate to sequential prev or prev-untagged based on checkbox."""
if self.chk_next_untagged.isChecked():
self.prev_untagged()
else:
self.prev_image()

def prev_untagged(self) -> None:
"""Jump to the previous image (before current) that has no tags."""
if not self.image_files:
return

end = self.current_index - 1
found = None
for i in range(end, -1, -1):
rel = self._relpath_for_index(i)
if rel is not None and not self.tags_dict.get(rel, []):
found = i
break

if found is None:
self.status_bar.showMessage("No untagged images before current")
return

self.current_index = found
self._show_current_image()
self.settings.last_image_index = self.current_index

def next_image(self) -> None:
if self.image_files and self.current_index < len(self.image_files) - 1:
self.current_index += 1
self._show_current_image()
self.settings.last_image_index = self.current_index

def _on_next_mode_changed(self, state) -> None:
"""Update the Next button label when the mode changes."""
"""Update the Prev and Next button labels when the mode changes."""
if self.chk_next_untagged.isChecked():
self.prev_btn.setText("◀ Prev Untagged")
self.next_btn.setText("Next Untagged ▶")
self.status_bar.showMessage("Next mode: jump to next untagged image")
else:
self.prev_btn.setText("◀ Prev")
self.next_btn.setText("Next ▶")
self.status_bar.showMessage("Next mode: sequential")

Expand Down
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
PyQt5>=5.15
numpy>=1.21
Pillow>=9.0
scikit-learn>=1.0
Loading