diff --git a/CMakeLists.txt b/CMakeLists.txt
index 40bc02a..5aa5152 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -8,7 +8,12 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
-find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Sql)
+find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Sql Concurrent)
+
+# Add resources file
+set(RESOURCE_FILES
+ resources.qrc
+)
set(PROJECT_SOURCES
src/main.cpp
@@ -18,18 +23,39 @@ set(PROJECT_SOURCES
src/imagegallery.h
src/databasemanager.cpp
src/databasemanager.h
+ src/settingsdialog.cpp
+ src/settingsdialog.h
)
-add_executable(screenshot-gallery ${PROJECT_SOURCES})
+add_executable(screenshot-gallery ${PROJECT_SOURCES} ${RESOURCE_FILES})
+
+# Install icons to standard system locations
+install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/icons/orcs-gallery-64.png"
+ DESTINATION "${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/64x64/apps"
+ RENAME "orcs-gallery.png")
+install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/icons/orcs-gallery-128.png"
+ DESTINATION "${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/128x128/apps"
+ RENAME "orcs-gallery.png")
+install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/icons/orcs-gallery-256.png"
+ DESTINATION "${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/256x256/apps"
+ RENAME "orcs-gallery.png")
+install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/icons/orcs-gallery-512.png"
+ DESTINATION "${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/512x512/apps"
+ RENAME "orcs-gallery.png")
target_link_libraries(screenshot-gallery PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Widgets
Qt6::Sql
+ Qt6::Concurrent
)
install(TARGETS screenshot-gallery
BUNDLE DESTINATION .
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
+
+# Install desktop file
+install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/screenshot-gallery.desktop"
+ DESTINATION "${CMAKE_INSTALL_PREFIX}/share/applications")
diff --git a/OCR-scripts/direct_rofi_ocr.sh b/OCR-scripts/direct_rofi_ocr.sh
new file mode 100755
index 0000000..528e476
--- /dev/null
+++ b/OCR-scripts/direct_rofi_ocr.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+
+# direct_rofi_ocr.sh - Direct script to search OCR'd screenshots with rofi
+# This script displays OCR data from SQLite and allows opening files with rofi
+
+# Database path
+DB_PATH="/home/master/screenshot_ocr.db"
+
+# Check dependencies
+check_deps() {
+ local missing=0
+
+ if ! command -v rofi &> /dev/null; then
+ echo "Error: rofi is not installed. Please install it with: sudo pacman -S rofi"
+ missing=1
+ fi
+
+ if ! command -v sqlite3 &> /dev/null; then
+ echo "Error: sqlite3 is not installed. Please install it with: sudo pacman -S sqlite"
+ missing=1
+ fi
+
+ if ! [ -f "$DB_PATH" ]; then
+ echo "Error: Database not found at $DB_PATH"
+ echo "Please run the OCR script first to create and populate the database."
+ missing=1
+ fi
+
+ return $missing
+}
+
+# Format OCR text (remove newlines, limit length)
+format_text() {
+ local text="$1"
+ text=$(echo "$text" | tr '\n' ' ' | tr -s ' ')
+
+ if [ ${#text} -gt 80 ]; then
+ text="${text:0:80}..."
+ fi
+
+ echo "$text"
+}
+
+# Main function
+main() {
+ # Check dependencies
+ if ! check_deps; then
+ exit 1
+ fi
+
+ # Create temporary file for mapping
+ TEMP_FILE=$(mktemp)
+ trap 'rm -f $TEMP_FILE' EXIT
+
+ # Extract data from database
+ echo "Querying database..."
+ sqlite3 "$DB_PATH" "SELECT filename, full_path, ocr_text FROM ocr_results" | \
+ while IFS='|' read -r filename path text; do
+ # Format the text for display
+ formatted=$(format_text "$text")
+ # Write to temp file: display_text|file_path
+ echo "$filename | $formatted|$path" >> "$TEMP_FILE"
+ done
+
+ # Check if we got any results
+ if [ ! -s "$TEMP_FILE" ]; then
+ echo "No OCR data found in database. Run ocr_screenshots.py first."
+ exit 1
+ fi
+
+ # Display in rofi
+ echo "Opening rofi dialog..."
+ selection=$(cat "$TEMP_FILE" | cut -d'|' -f1-2 | rofi -dmenu -i -p "Screenshot OCR" -width 80)
+
+ # Check if user made a selection
+ if [ -z "$selection" ]; then
+ echo "No selection made."
+ exit 0
+ fi
+
+ # Find the corresponding path
+ display_text=$(echo "$selection" | sed 's/|.*$//')
+ path=$(grep -F "$display_text" "$TEMP_FILE" | cut -d'|' -f3)
+
+ # Open the file
+ if [ -n "$path" ] && [ -f "$path" ]; then
+ echo "Opening: $path"
+ xdg-open "$path" &
+ else
+ echo "Error: Could not find file path for selection."
+ echo "Selected: $display_text"
+ echo "Path: $path"
+ fi
+}
+
+# Run main function
+main
diff --git a/OCR-scripts/ocr_rofi.sh b/OCR-scripts/ocr_rofi.sh
new file mode 100755
index 0000000..d9d32f3
--- /dev/null
+++ b/OCR-scripts/ocr_rofi.sh
@@ -0,0 +1,92 @@
+#!/bin/bash
+
+# ocr_rofi.sh - Search OCR'd screenshots with rofi
+# This script displays OCR data from SQLite and allows opening files with rofi
+
+# Database path
+DB_PATH="$HOME/screenshot_ocr.db"
+SCREENSHOTS_DIR="$HOME/Screenshots"
+MAX_TEXT_LENGTH=100
+
+# Check dependencies
+if ! command -v rofi &> /dev/null; then
+ echo "Error: rofi is not installed. Please install it with: sudo pacman -S rofi"
+ exit 1
+fi
+
+if ! command -v sqlite3 &> /dev/null; then
+ echo "Error: sqlite3 is not installed. Please install it with: sudo pacman -S sqlite"
+ exit 1
+fi
+
+if [ ! -f "$DB_PATH" ]; then
+ echo "Error: Database not found at $DB_PATH"
+ echo "Run the OCR script first to create and populate the database."
+ exit 1
+fi
+
+# Create temporary files
+ENTRIES_FILE=$(mktemp)
+PATHS_FILE=$(mktemp)
+trap "rm -f $ENTRIES_FILE $PATHS_FILE" EXIT
+
+# Query the database and format for rofi
+echo "Preparing OCR data for search..."
+sqlite3 -separator "|" "$DB_PATH" "SELECT filename, ocr_text, full_path FROM ocr_results ORDER BY filename" | while IFS="|" read -r filename ocr_text path; do
+ # Clean up text (remove newlines, limit length)
+ clean_text=$(echo "$ocr_text" | tr '\n' ' ' | tr -s ' ')
+ if [ ${#clean_text} -gt $MAX_TEXT_LENGTH ]; then
+ clean_text="${clean_text:0:$MAX_TEXT_LENGTH}..."
+ fi
+
+ # Save formatted entry for rofi
+ echo "$filename | $clean_text" >> "$ENTRIES_FILE"
+
+ # Save path in corresponding line
+ echo "$path" >> "$PATHS_FILE"
+done
+
+# Count entries
+entry_count=$(wc -l < "$ENTRIES_FILE")
+if [ "$entry_count" -eq 0 ]; then
+ echo "No OCR data found in database. Run ocr_screenshots.py first."
+ exit 1
+fi
+
+echo "Found $entry_count screenshots with OCR data."
+
+# Display rofi menu
+selected_line=$(cat "$ENTRIES_FILE" | rofi -dmenu -i -p "Screenshot OCR" \
+ -width 80 -lines 15 -font "mono 10")
+
+# Exit if no selection
+if [ -z "$selected_line" ]; then
+ echo "No selection made."
+ exit 0
+fi
+
+# Get filename from selection
+selected_filename=$(echo "$selected_line" | cut -d '|' -f 1 | sed 's/ *$//')
+
+# Find corresponding line number
+line_num=1
+while IFS= read -r line; do
+ if [[ "$line" == "$selected_filename"* ]]; then
+ break
+ fi
+ line_num=$((line_num + 1))
+done < "$ENTRIES_FILE"
+
+# Get full path from paths file
+selected_path=$(sed "${line_num}q;d" "$PATHS_FILE")
+
+# Open the file
+if [ -n "$selected_path" ] && [ -f "$selected_path" ]; then
+ echo "Opening: $selected_path"
+ xdg-open "$selected_path" &
+else
+ echo "Error: Could not find file: $selected_path"
+ exit 1
+fi
+
+exit 0
diff --git a/OCR-scripts/ocr_screenshots.py b/OCR-scripts/ocr_screenshots.py
new file mode 100755
index 0000000..0dd31ac
--- /dev/null
+++ b/OCR-scripts/ocr_screenshots.py
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+
+import glob
+import os
+import sqlite3
+import subprocess
+from datetime import datetime
+
+# Configuration
+SCREENSHOTS_DIR = os.path.expanduser("~/Screenshots")
+DATABASE_PATH = os.path.expanduser("~/screenshot_ocr.db")
+
+
+def create_database():
+ """Create SQLite database and table if they don't exist."""
+ conn = sqlite3.connect(DATABASE_PATH)
+ cursor = conn.cursor()
+
+ # Create table for OCR results
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS ocr_results (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ filename TEXT UNIQUE,
+ full_path TEXT,
+ ocr_text TEXT,
+ file_size INTEGER,
+ created_date TEXT,
+ ocr_date TEXT
+ )
+ """)
+
+ conn.commit()
+ conn.close()
+ print(f"Database initialized at {DATABASE_PATH}")
+
+
+def get_processed_files():
+ """Get a set of filenames that have already been processed."""
+ conn = sqlite3.connect(DATABASE_PATH)
+ cursor = conn.cursor()
+
+ cursor.execute("SELECT filename FROM ocr_results")
+ processed_files = {row[0] for row in cursor.fetchall()}
+
+ conn.close()
+ return processed_files
+
+
+def perform_ocr(image_path):
+ """Perform OCR on an image file using tesseract."""
+ try:
+ # Create a temporary output file
+ temp_output = f"/tmp/{os.path.basename(image_path)}.txt"
+ temp_base = temp_output.replace(".txt", "")
+
+ # Run tesseract
+ subprocess.run(
+ ["tesseract", image_path, temp_base],
+ check=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+
+ # Read OCR text from the output file
+ with open(temp_output, "r", encoding="utf-8") as f:
+ ocr_text = f.read().strip()
+
+ # Clean up temporary file
+ os.remove(temp_output)
+
+ return ocr_text
+ except subprocess.CalledProcessError as e:
+ print(f"Error running tesseract on {image_path}: {str(e)}")
+ return ""
+ except Exception as e:
+ print(f"Error processing {image_path}: {str(e)}")
+ return ""
+
+
+def add_to_database(filename, full_path, ocr_text, file_size, created_date):
+ """Add OCR result to the database."""
+ conn = sqlite3.connect(DATABASE_PATH)
+ cursor = conn.cursor()
+
+ try:
+ cursor.execute(
+ """
+ INSERT INTO ocr_results
+ (filename, full_path, ocr_text, file_size, created_date, ocr_date)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ filename,
+ full_path,
+ ocr_text,
+ file_size,
+ created_date,
+ datetime.now().isoformat(),
+ ),
+ )
+
+ conn.commit()
+ print(f"Added {filename} to database")
+ except sqlite3.IntegrityError:
+ print(f"File {filename} already exists in database")
+ except Exception as e:
+ print(f"Error adding {filename} to database: {str(e)}")
+ finally:
+ conn.close()
+
+
+def main():
+ """Main function to process screenshot images."""
+ print("Starting OCR process for screenshots...")
+
+ # Create database if it doesn't exist
+ create_database()
+
+ # Get list of already processed files
+ processed_files = get_processed_files()
+ print(f"Found {len(processed_files)} already processed files")
+
+ # Get list of PNG and JPG files
+ image_files = glob.glob(os.path.join(SCREENSHOTS_DIR, "*.png"))
+ image_files.extend(glob.glob(os.path.join(SCREENSHOTS_DIR, "*.jpg")))
+ image_files.extend(glob.glob(os.path.join(SCREENSHOTS_DIR, "*.jpeg")))
+ print(f"Found {len(image_files)} image files")
+
+ # Process each image file
+ processed_count = 0
+ skipped_count = 0
+ error_count = 0
+
+ for image_path in image_files:
+ filename = os.path.basename(image_path)
+
+ # Skip if already processed
+ if filename in processed_files:
+ print(f"Skipping {filename} (already processed)")
+ skipped_count += 1
+ continue
+
+ print(f"Processing {filename}...")
+
+ # Get file information
+ file_stats = os.stat(image_path)
+ file_size = file_stats.st_size
+ created_date = datetime.fromtimestamp(file_stats.st_mtime).isoformat()
+
+ # Perform OCR
+ ocr_text = perform_ocr(image_path)
+
+ if ocr_text:
+ # Add to database
+ add_to_database(filename, image_path, ocr_text, file_size, created_date)
+ processed_count += 1
+ else:
+ print(f"No OCR text extracted from {filename}")
+ error_count += 1
+
+ print("\nOCR process completed:")
+ print(f"- Processed: {processed_count}")
+ print(f"- Skipped (already in database): {skipped_count}")
+ print(f"- Errors: {error_count}")
+ print(f"- Total files in database: {len(processed_files) + processed_count}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/OCR-scripts/ocr_screenshots.sh b/OCR-scripts/ocr_screenshots.sh
new file mode 100755
index 0000000..bc56eeb
--- /dev/null
+++ b/OCR-scripts/ocr_screenshots.sh
@@ -0,0 +1,58 @@
+#!/bin/bash
+
+# OCR screenshot images and store results in SQLite database
+# This script is a wrapper for the ocr_screenshots.py Python script
+
+# Set up variables
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PYTHON_SCRIPT="${SCRIPT_DIR}/ocr_screenshots.py"
+DATABASE_PATH="$HOME/screenshot_ocr.db"
+SCREENSHOTS_DIR="$HOME/Screenshots"
+
+# Check if Python script exists
+if [ ! -f "$PYTHON_SCRIPT" ]; then
+ echo "Error: Python script not found at $PYTHON_SCRIPT"
+ exit 1
+fi
+
+# Check if tesseract is installed
+if ! command -v tesseract &> /dev/null; then
+ echo "Error: tesseract not installed. Please install it with:"
+ echo " sudo pacman -S tesseract tesseract-data-eng"
+ exit 1
+fi
+
+# Check if SQLite is installed
+if ! command -v sqlite3 &> /dev/null; then
+ echo "Error: sqlite3 not installed. Please install it with:"
+ echo " sudo pacman -S sqlite"
+ exit 1
+fi
+
+# Check if screenshots directory exists
+if [ ! -d "$SCREENSHOTS_DIR" ]; then
+ echo "Error: Screenshots directory not found at $SCREENSHOTS_DIR"
+ exit 1
+fi
+
+echo "Starting OCR process for screenshots..."
+echo "Database path: $DATABASE_PATH"
+echo "Screenshots directory: $SCREENSHOTS_DIR"
+
+# Run the Python script
+python "$PYTHON_SCRIPT"
+
+# Check return code
+if [ $? -ne 0 ]; then
+ echo "Error: OCR process failed"
+ exit 1
+else
+ # Count entries in database
+ if [ -f "$DATABASE_PATH" ]; then
+ count=$(sqlite3 "$DATABASE_PATH" "SELECT COUNT(*) FROM ocr_results")
+ echo "Database contains $count entries"
+ fi
+ echo "OCR process completed successfully"
+fi
+
+exit 0
diff --git a/OCR-scripts/ocr_search_rofi.py b/OCR-scripts/ocr_search_rofi.py
new file mode 100755
index 0000000..a60d243
--- /dev/null
+++ b/OCR-scripts/ocr_search_rofi.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+
+import os
+import sqlite3
+import subprocess
+import sys
+import tempfile
+
+# Configuration
+DB_PATH = os.path.expanduser("~/screenshot_ocr.db")
+SCREENSHOTS_DIR = os.path.expanduser("~/Screenshots")
+ROFI_PROMPT = "OCR Search"
+
+
+def check_dependencies():
+ """Check if required dependencies are installed."""
+ # Check if rofi is installed
+ try:
+ subprocess.run(
+ ["which", "rofi"],
+ check=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ except subprocess.CalledProcessError:
+ print(
+ "Error: rofi is not installed. Please install it with: sudo pacman -S rofi"
+ )
+ return False
+
+ # Check if database exists
+ if not os.path.exists(DB_PATH):
+ print(f"Error: Database not found at {DB_PATH}")
+ print("Run the OCR script first to create and populate the database.")
+ return False
+
+ return True
+
+
+def format_text(text, max_length=100):
+ """Format OCR text for display in rofi."""
+ # Replace newlines with spaces
+ text = text.replace("\n", " ")
+
+ # Replace multiple spaces with a single space
+ while " " in text:
+ text = text.replace(" ", " ")
+
+ # Truncate if too long
+ if len(text) > max_length:
+ text = text[:max_length] + "..."
+
+ return text
+
+
+def get_screenshot_data():
+ """Get data from the SQLite database."""
+ try:
+ conn = sqlite3.connect(DB_PATH)
+ cursor = conn.cursor()
+
+ # Get all records
+ cursor.execute(
+ "SELECT filename, ocr_text, full_path FROM ocr_results ORDER BY filename"
+ )
+
+ results = []
+ for filename, ocr_text, full_path in cursor.fetchall():
+ formatted_text = format_text(ocr_text)
+ # Format for display: filename | ocr_text
+ display_text = f"{filename} | {formatted_text}"
+ results.append((display_text, full_path))
+
+ conn.close()
+ return results
+
+ except sqlite3.Error as e:
+ print(f"Database error: {e}")
+ return []
+
+
+def show_rofi_menu(items):
+ """Display rofi menu with the given items and return selection."""
+ if not items:
+ print("No items to display.")
+ return None, None
+
+ # Create temporary file with menu items
+ with tempfile.NamedTemporaryFile(mode="w+", delete=False) as temp_file:
+ for display_text, _ in items:
+ temp_file.write(f"{display_text}\n")
+ temp_file_path = temp_file.name
+
+ try:
+ # Run rofi command
+ cmd = [
+ "rofi",
+ "-dmenu",
+ "-i", # Case-insensitive matching
+ "-p",
+ ROFI_PROMPT,
+ "-width",
+ "80",
+ "-lines",
+ "15",
+ "-font",
+ "mono 10",
+ ]
+
+ process = subprocess.Popen(
+ cmd,
+ stdin=open(temp_file_path, "r"),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ universal_newlines=True,
+ )
+
+ stdout, stderr = process.communicate()
+
+ if process.returncode != 0:
+ print(f"Rofi error: {stderr}")
+ return None, None
+
+ selection = stdout.strip()
+ if not selection:
+ return None, None
+
+ # Find the matching item
+ for i, (display_text, full_path) in enumerate(items):
+ if display_text == selection:
+ return display_text, full_path
+
+ return None, None
+
+ finally:
+ # Clean up temporary file
+ os.unlink(temp_file_path)
+
+
+def open_file(file_path):
+ """Open the selected file with the default application."""
+ if os.path.exists(file_path):
+ try:
+ subprocess.Popen(
+ ["xdg-open", file_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE
+ )
+ print(f"Opening: {file_path}")
+ return True
+ except Exception as e:
+ print(f"Error opening file: {e}")
+ return False
+ else:
+ print(f"Error: File not found: {file_path}")
+ return False
+
+
+def main():
+ """Main function."""
+ if not check_dependencies():
+ sys.exit(1)
+
+ # Get screenshot data
+ items = get_screenshot_data()
+ if not items:
+ print("No OCR data found in the database.")
+ sys.exit(1)
+
+ # Show rofi menu
+ selection, file_path = show_rofi_menu(items)
+
+ # Open selected file
+ if selection and file_path:
+ open_file(file_path)
+ else:
+ print("No selection made.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/OCR-scripts/rofi_ocr_search.sh b/OCR-scripts/rofi_ocr_search.sh
new file mode 100755
index 0000000..adb1e82
--- /dev/null
+++ b/OCR-scripts/rofi_ocr_search.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+
+# rofi_ocr_search.sh - Search and open OCR'd screenshot files using rofi
+# This script displays OCR'd screenshot data in rofi and allows opening selected files
+
+# Constants
+DATABASE_PATH="$HOME/screenshot_ocr.db"
+SCREENSHOTS_DIR="$HOME/Screenshots"
+ROFI_PROMPT="Screenshot OCR"
+MAX_DISPLAY_LENGTH=100
+
+# Check if required programs are installed
+check_dependencies() {
+ local missing=0
+
+ if ! command -v rofi >/dev/null 2>&1; then
+ echo "Error: rofi is not installed. Please install it with:"
+ echo " sudo pacman -S rofi"
+ missing=1
+ fi
+
+ if ! command -v sqlite3 >/dev/null 2>&1; then
+ echo "Error: sqlite3 is not installed. Please install it with:"
+ echo " sudo pacman -S sqlite"
+ missing=1
+ fi
+
+ if ! command -v xdg-open >/dev/null 2>&1; then
+ echo "Error: xdg-open is not installed. Please install it with:"
+ echo " sudo pacman -S xdg-utils"
+ missing=1
+ fi
+
+ if [ ! -f "$DATABASE_PATH" ]; then
+ echo "Error: Database not found at $DATABASE_PATH"
+ echo "Run the OCR script first to create and populate the database."
+ missing=1
+ fi
+
+ if [ $missing -eq 1 ]; then
+ exit 1
+ fi
+}
+
+# Format OCR text for display in rofi
+format_ocr_text() {
+ local text="$1"
+
+ # Replace newlines with spaces
+ text="${text//$'\n'/ }"
+
+ # Remove multiple spaces
+ text=$(echo "$text" | tr -s ' ')
+
+ # Truncate if too long
+ if [ ${#text} -gt $MAX_DISPLAY_LENGTH ]; then
+ text="${text:0:$MAX_DISPLAY_LENGTH}..."
+ fi
+
+ echo "$text"
+}
+
+# Get entries from database and format for rofi
+get_entries_for_rofi() {
+ sqlite3 -separator '|' "$DATABASE_PATH" "
+ SELECT filename, ocr_text, full_path
+ FROM ocr_results
+ ORDER BY filename
+ " | while IFS='|' read -r filename ocr_text path; do
+ formatted_text=$(format_ocr_text "$ocr_text")
+ echo "$filename | $formatted_text"
+ echo "$path" >> /tmp/ocr_paths.$$
+ done
+}
+
+# Main function
+main() {
+ check_dependencies
+
+ # Create temporary file for paths
+ rm -f /tmp/ocr_paths.$$ 2>/dev/null
+ touch /tmp/ocr_paths.$$
+
+ # Get all entries and display in rofi
+ selection=$(get_entries_for_rofi | rofi -dmenu -i -p "$ROFI_PROMPT" \
+ -width 80 \
+ -lines 15 \
+ -font "mono 10" \
+ -matching fuzzy)
+
+ # Exit if no selection made
+ if [ -z "$selection" ]; then
+ rm -f /tmp/ocr_paths.$$ 2>/dev/null
+ exit 0
+ fi
+
+ # Extract filename from selection
+ filename=$(echo "$selection" | cut -d '|' -f1 | xargs)
+
+ # Find the corresponding line number
+ line_number=1
+ sqlite3 -separator '|' "$DATABASE_PATH" "
+ SELECT filename
+ FROM ocr_results
+ ORDER BY filename
+ " | while read -r db_filename; do
+ if [ "$db_filename" = "$filename" ]; then
+ break
+ fi
+ line_number=$((line_number + 1))
+ done
+
+ # Get the full path from our temporary file
+ file_path=$(sed -n "${line_number}p" /tmp/ocr_paths.$$)
+
+ # Open the file if path exists
+ if [ -n "$file_path" ] && [ -f "$file_path" ]; then
+ xdg-open "$file_path" &
+ echo "Opening: $file_path"
+ else
+ echo "Error: Could not find file path for $filename"
+ fi
+
+ # Clean up
+ rm -f /tmp/ocr_paths.$$ 2>/dev/null
+}
+
+# Run the main function
+main
diff --git a/OCR-scripts/screenshot_ocr.db b/OCR-scripts/screenshot_ocr.db
new file mode 100644
index 0000000..7d6fdb2
Binary files /dev/null and b/OCR-scripts/screenshot_ocr.db differ
diff --git a/OCR-scripts/search_ocr.py b/OCR-scripts/search_ocr.py
new file mode 100755
index 0000000..7650b1f
--- /dev/null
+++ b/OCR-scripts/search_ocr.py
@@ -0,0 +1,247 @@
+#!/usr/bin/env python3
+
+import argparse
+import os
+import sqlite3
+import sys
+from datetime import datetime
+
+# Constants
+DATABASE_PATH = os.path.expanduser("~/screenshot_ocr.db")
+
+
+def parse_arguments():
+ """Parse command line arguments."""
+ parser = argparse.ArgumentParser(
+ description="Search for text in OCR results from screenshots."
+ )
+ parser.add_argument(
+ "query", help="Text to search for in the OCR results", nargs="*"
+ )
+ parser.add_argument(
+ "-i",
+ "--case-insensitive",
+ action="store_true",
+ help="Perform case-insensitive search",
+ )
+ parser.add_argument(
+ "-e",
+ "--exact",
+ action="store_true",
+ help="Match exact text only (default is substring match)",
+ )
+ parser.add_argument(
+ "-l",
+ "--limit",
+ type=int,
+ default=25,
+ help="Limit number of results (default: 25, 0 for no limit)",
+ )
+ parser.add_argument(
+ "-v",
+ "--verbose",
+ action="store_true",
+ help="Show more details including file path and date",
+ )
+ parser.add_argument(
+ "-c", "--count", action="store_true", help="Only show the count of matches"
+ )
+ parser.add_argument(
+ "--list-all", action="store_true", help="List all files in the database"
+ )
+ parser.add_argument(
+ "-d", "--date", action="store_true", help="Sort results by date (newest first)"
+ )
+ parser.add_argument(
+ "-o",
+ "--open",
+ action="store_true",
+ help="Open the first matching file (requires xdg-open)",
+ )
+ return parser.parse_args()
+
+
+def check_database():
+ """Check if the database exists and has the expected structure."""
+ if not os.path.exists(DATABASE_PATH):
+ print(f"Error: Database not found at {DATABASE_PATH}")
+ print("Run the OCR script first to create and populate the database.")
+ sys.exit(1)
+
+
+def search_ocr_database(args):
+ """Search the OCR database for the given query."""
+ conn = sqlite3.connect(DATABASE_PATH)
+ cursor = conn.cursor()
+
+ # Join all query words
+ query_text = " ".join(args.query)
+
+ if args.list_all:
+ cursor.execute(
+ """
+ SELECT filename, ocr_date, created_date, length(ocr_text), full_path
+ FROM ocr_results
+ ORDER BY filename ASC
+ """
+ )
+
+ results = cursor.fetchall()
+ conn.close()
+ return results
+
+ # Construct the SQL query based on arguments
+ if args.exact:
+ if args.case_insensitive:
+ sql_query = "SELECT * FROM ocr_results WHERE LOWER(ocr_text) = LOWER(?)"
+ else:
+ sql_query = "SELECT * FROM ocr_results WHERE ocr_text = ?"
+ else:
+ if args.case_insensitive:
+ sql_query = "SELECT * FROM ocr_results WHERE LOWER(ocr_text) LIKE LOWER(?)"
+ query_text = f"%{query_text}%"
+ else:
+ sql_query = "SELECT * FROM ocr_results WHERE ocr_text LIKE ?"
+ query_text = f"%{query_text}%"
+
+ # Add ordering
+ if args.date:
+ sql_query += " ORDER BY created_date DESC"
+ else:
+ sql_query += " ORDER BY filename ASC"
+
+ # Add limit
+ if args.limit > 0:
+ sql_query += f" LIMIT {args.limit}"
+
+ # Execute the query
+ cursor.execute(sql_query, (query_text,))
+ results = cursor.fetchall()
+
+ # Close the database connection
+ conn.close()
+
+ return results
+
+
+def display_results(results, args):
+ """Display the search results."""
+ if not results or len(results) == 0:
+ print("No matches found.")
+ return
+
+ if args.count:
+ print(f"Found {len(results)} matches.")
+ return
+
+ print(
+ f"Found {len(results)} matches{' (showing first ' + str(args.limit) + ')' if args.limit > 0 and len(results) == args.limit else ''}:"
+ )
+ print("-" * 80)
+
+ # Handle list_all format differently
+ if args.list_all:
+ for filename, ocr_date, created_date, text_length, full_path in results:
+ created_str = (
+ created_date.split("T")[0] if "T" in created_date else created_date
+ )
+ if args.verbose:
+ print(f"File: {filename}")
+ print(f"Path: {full_path}")
+ print(f"Created: {created_date}")
+ print(f"OCR Date: {ocr_date}")
+ print(f"Text Length: {text_length} chars")
+ print("-" * 40)
+ else:
+ print(f"{filename} | {created_str} | {text_length} chars")
+ return
+
+ # Display regular search results
+ for row in results:
+ id, filename, full_path, ocr_text, file_size, created_date, ocr_date = row
+
+ # Display the result
+ if args.verbose:
+ print(f"File: {filename}")
+ print(f"Path: {full_path}")
+ print(f"Created: {created_date}")
+ print(f"OCR Date: {ocr_date}")
+ print(f"Size: {file_size} bytes")
+ print("Text:")
+ print("-" * 40)
+ print(ocr_text)
+ print("-" * 80)
+ else:
+ print(f"[{filename}]")
+ # Show just a snippet of text around the match if not exact
+ if not args.exact and args.query:
+ search_term = " ".join(args.query).lower()
+ text_lower = ocr_text.lower()
+ pos = text_lower.find(search_term)
+ if pos >= 0:
+ start = max(0, pos - 40)
+ end = min(len(ocr_text), pos + len(search_term) + 40)
+ # Find word boundaries
+ if start > 0:
+ while start > 0 and ocr_text[start].isalnum():
+ start -= 1
+ if end < len(ocr_text):
+ while end < len(ocr_text) and ocr_text[end].isalnum():
+ end += 1
+
+ snippet = ocr_text[start:end]
+ if start > 0:
+ snippet = "..." + snippet
+ if end < len(ocr_text):
+ snippet = snippet + "..."
+
+ print(snippet)
+ else:
+ # If can't find the term (which is odd), just show first bit
+ print(ocr_text[:80] + "..." if len(ocr_text) > 80 else ocr_text)
+ else:
+ # Just show the first bit of text
+ print(ocr_text[:80] + "..." if len(ocr_text) > 80 else ocr_text)
+ print("-" * 40)
+
+
+def open_file(path):
+ """Open a file using the default application."""
+ try:
+ import subprocess
+
+ subprocess.Popen(
+ ["xdg-open", path], stdout=subprocess.PIPE, stderr=subprocess.PIPE
+ )
+ print(f"Opening: {path}")
+ return True
+ except Exception as e:
+ print(f"Error opening file: {e}")
+ return False
+
+
+def main():
+ """Main function to run the OCR search."""
+ args = parse_arguments()
+ check_database()
+
+ if not args.list_all and len(args.query) == 0:
+ print("Error: Please provide a search query or use --list-all")
+ sys.exit(1)
+
+ results = search_ocr_database(args)
+
+ # Open the first matching file if requested
+ if args.open and results and len(results) > 0:
+ if args.list_all:
+ open_file(results[0][4]) # full_path is at index 4 for list_all
+ else:
+ open_file(results[0][2]) # full_path is at index 2 for regular search
+
+ # Always display results unless we're only opening
+ if not (args.open and not args.verbose):
+ display_results(results, args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/OCR-scripts/simple_rofi_ocr.sh b/OCR-scripts/simple_rofi_ocr.sh
new file mode 100755
index 0000000..6ca7aa2
--- /dev/null
+++ b/OCR-scripts/simple_rofi_ocr.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+
+# simple_rofi_ocr.sh - A simplified script to search OCR'd screenshots with rofi
+
+# Database location
+DB_PATH="$HOME/screenshot_ocr.db"
+
+# Check if database exists
+if [ ! -f "$DB_PATH" ]; then
+ echo "Error: Database not found at $DB_PATH"
+ echo "Run the OCR script first to create and populate the database."
+ exit 1
+fi
+
+# Check if rofi exists
+if ! command -v rofi &> /dev/null; then
+ echo "Error: rofi is not installed. Please install it with:"
+ echo " sudo pacman -S rofi"
+ exit 1
+fi
+
+# Check if sqlite3 exists
+if ! command -v sqlite3 &> /dev/null; then
+ echo "Error: sqlite3 is not installed. Please install it with:"
+ echo " sudo pacman -S sqlite"
+ exit 1
+fi
+
+# Create a temporary file to store data
+TMP_FILE=$(mktemp)
+trap 'rm -f $TMP_FILE' EXIT
+
+# Get data from database and format for rofi
+sqlite3 -separator '|' "$DB_PATH" "
+ SELECT full_path, filename, ocr_text
+ FROM ocr_results
+ ORDER BY filename
+" > "$TMP_FILE"
+
+# Process each line to format for rofi and create menu items
+menu_items=""
+while IFS='|' read -r path filename ocr_text; do
+ # Clean up text for display (replace newlines, truncate)
+ clean_text=$(echo "$ocr_text" | tr '\n' ' ' | sed 's/ / /g')
+ if [ ${#clean_text} -gt 80 ]; then
+ clean_text="${clean_text:0:80}..."
+ fi
+
+ # Add to menu items
+ menu_items+="$filename | $clean_text\n"
+done < "$TMP_FILE"
+
+# Display rofi menu
+selection=$(echo -e "$menu_items" | rofi -dmenu -i -p "Screenshot OCR" -width 80 -lines 15 -font "mono 10")
+
+# Exit if no selection
+if [ -z "$selection" ]; then
+ exit 0
+fi
+
+# Extract filename from selection
+selected_filename=$(echo "$selection" | cut -d'|' -f1 | sed 's/ *$//')
+
+# Find the path for the selected filename
+selected_path=$(grep -F "|$selected_filename|" "$TMP_FILE" | cut -d'|' -f1)
+
+# Open the file if found
+if [ -n "$selected_path" ] && [ -f "$selected_path" ]; then
+ echo "Opening: $selected_path"
+ xdg-open "$selected_path" &
+else
+ echo "Error: Could not find file: $selected_path"
+fi
+
+exit 0
diff --git a/README.md b/README.md
index 9cb2a8c..649c345 100644
--- a/README.md
+++ b/README.md
@@ -5,11 +5,21 @@ A Qt6-based image gallery application that allows you to search through OCR data
## Features
- Fast visual navigation through your screenshot collection
-- Ultra-responsive live search through OCR text as you type with optimized performance
+- Non-blocking UI with background threaded search operations
+- Smart typing detection with 500ms inactivity timer before searching
+- Visual feedback while typing and searching with animated status indicators
+- Settings dialog to customize database location and screenshots directory
+- Settings stored in ~/.config/ScreenshotOCRGallery/settings.ini for easy access
+- Customizable image preload count for performance tuning
+- Prominent "Load More Images" button for easy one-click pagination
+- Optimized lazy loading that only loads images when needed
+- Ultra-responsive live search through OCR text using SQLite FTS5 full-text search technology
+- Extremely fast search even with large databases containing thousands of screenshots
- Dynamic grid layout that automatically reflows (1x, 2x, 3x, 4x, etc.) based on window width
- No horizontal scrollbars - content always fits the window width
-- Filename overlay at the bottom of each image for easy identification
+- Dynamic filename overlay at the bottom of each image that scales with the thumbnail width
- Opens images in your default image viewer on click
+- Menu bar with File options (Open, Open With, Settings, Quit)
- Minimal 2px spacing between images for a compact view
- Proper error handling for missing files and database issues
@@ -88,17 +98,76 @@ After building, run the application:
## Usage
-1. When the application starts, it will display all screenshots found in the database
-2. Type in the search bar to filter images by OCR text content
-3. Results update instantly as you type with optimized search performance
-4. When you clear the search bar, all images are immediately shown
+1. When the application starts, it will display the first batch of screenshots (20 by default)
+2. Click the large "Load More Images" button at the end of the gallery to load additional images with a single click
+3. Type in the search bar to filter images by OCR text content
+4. The app waits for you to stop typing (500ms pause) before performing the search
+5. Animated status indicators show when you're typing and when searching is in progress
+6. Search happens in the background - UI stays responsive even during complex searches
+7. Results update instantly as they become available, loading only what you can see
+8. When you clear the search bar, the first batch of images loads immediately
5. Resize the application window to see the grid automatically reflow:
- Wider windows show more columns (4x, 5x, etc.)
- Narrower windows reduce to fewer columns (3x, 2x)
- Very narrow windows show a single centered column (1x)
- No horizontal scrolling - content always fits the available width
-6. Each image displays its filename at the bottom for easy identification
+6. Each image displays its filename at the bottom with a dark overlay that resizes with the thumbnail
7. Click on any image to open it in your default image viewer
+8. Use the File menu for additional options:
+ - Open: Select and open an image file
+ - Open With: Choose a program to open an image file
+ - Settings: Configure your database path and screenshots directory
+ - Quit: Exit the application
+
+9. Configure the application through the Settings dialog:
+ - Database File Path: Change where your OCR database is stored
+ - Screenshots Directory: Set the default location for your screenshots
+ - Image count to pre-load: Adjust how many images are loaded at once (default: 20)
+ - The settings are automatically saved to ~/.config/ScreenshotOCRGallery/settings.ini
+ - Changes are applied immediately without requiring a restart
+
+## Search Technology
+
+This application combines multiple performance-enhancing technologies:
+
+### 1. Customizable Configuration
+- **Settings Dialog:** Easily configure database location, screenshots directory, and preload count
+- **File Path Selection:** Browse for locations using native file pickers
+- **Persistent Settings:** Your configuration is saved in ~/.config/ScreenshotOCRGallery/settings.ini
+- **Performance Tuning:** Adjust how many images are preloaded (20 by default)
+- **Dynamic Updates:** Changes are applied immediately without requiring a restart
+
+### 2. Intelligent Input Handling
+- **Typing Inactivity Detection:** Search only triggers after 500ms of no typing
+- **Visual Feedback:** Animated status indicators show typing and searching states
+- **Immediate Response:** UI instantly acknowledges your input
+- **Efficient Processing:** Prevents wasteful searches while you're still typing
+
+### 3. Lazy Loading & Pagination
+- **Initial Fast Load:** Only loads a configurable batch of images (default: 20) for immediate display
+- **Prominent Load More Button:** Large, clearly visible button at the end of the image list for loading more images
+- **Customizable Batch Size:** Adjust the number of images loaded at once via settings
+- **Optimized Memory Usage:** Only keeps necessary images in memory
+- **Built-in Progress Tracking:** Button dynamically updates to show current progress (e.g., "Load More Images (20 of 157)")
+- **Simple Interaction:** One-click loading of additional images without needing to scroll
+
+### 4. Background Threading
+- **Non-Blocking UI:** All search operations happen in background threads
+- **Responsive Interface:** The application remains fully responsive while searching
+- **Parallel Processing:** Search operations don't block the main UI thread
+- **Live Updates:** Results appear as they become available
+
+### 5. Full-Text Search
+- **What is FTS5?** A powerful full-text search engine built into SQLite that uses specialized indexing
+- **Performance Benefits:** Up to 100× faster than standard LIKE queries for text searches
+- **Smart Search:** Supports word stemming, prefix matches, and phrase queries
+- **Automatic Fallback:** If FTS5 is not available, the app automatically falls back to standard search
+
+### 6. Search Result Caching
+- **Paginated Cache:** Results are cached by page for efficient retrieval
+- **Smart Invalidation:** Cache expires after 5 minutes to ensure fresh results
+- **Thread-Safe:** The cache is protected for concurrent access
+- **Memory Efficient:** Only stores what's needed, with automatic cleanup
## Troubleshooting
diff --git a/icons/orcs-gallery-128.png b/icons/orcs-gallery-128.png
new file mode 100644
index 0000000..af790c5
Binary files /dev/null and b/icons/orcs-gallery-128.png differ
diff --git a/icons/orcs-gallery-256.png b/icons/orcs-gallery-256.png
new file mode 100644
index 0000000..5d75422
Binary files /dev/null and b/icons/orcs-gallery-256.png differ
diff --git a/icons/orcs-gallery-512.png b/icons/orcs-gallery-512.png
new file mode 100644
index 0000000..7b48be5
Binary files /dev/null and b/icons/orcs-gallery-512.png differ
diff --git a/icons/orcs-gallery-64.png b/icons/orcs-gallery-64.png
new file mode 100644
index 0000000..3a3beaa
Binary files /dev/null and b/icons/orcs-gallery-64.png differ
diff --git a/resources.qrc b/resources.qrc
new file mode 100644
index 0000000..66302ac
--- /dev/null
+++ b/resources.qrc
@@ -0,0 +1,8 @@
+
+
+ icons/orcs-gallery-64.png
+ icons/orcs-gallery-128.png
+ icons/orcs-gallery-256.png
+ icons/orcs-gallery-512.png
+
+
diff --git a/screenshot-gallery.desktop b/screenshot-gallery.desktop
index 69e844d..014d186 100644
--- a/screenshot-gallery.desktop
+++ b/screenshot-gallery.desktop
@@ -7,5 +7,5 @@ Exec=/home/master/screenshot-gallery/build/screenshot-gallery
Terminal=false
Categories=Graphics;Utility;Viewer;
Keywords=screenshots;ocr;gallery;search;images;
-Icon=image-viewer
+Icon=orcs-gallery
StartupNotify=true
diff --git a/src/databasemanager.cpp b/src/databasemanager.cpp
index 3daae32..aff951c 100644
--- a/src/databasemanager.cpp
+++ b/src/databasemanager.cpp
@@ -4,23 +4,68 @@
#include
#include
#include
+#include
DatabaseManager::DatabaseManager(QObject *parent)
: QObject(parent)
, m_initialized(false)
+ , m_ftsEnabled(false)
+ , m_searchCancelled(false)
+ , m_cachedImageCount(-1) // Initialize to invalid value
+ , m_currentOffset(0)
+ , m_currentLimit(0)
{
// Initialize search cache
m_searchCache.clear();
+ m_allImagesCache.clear();
+ m_lastCacheUpdate = QDateTime::currentDateTime();
- // Create index on ocr_text if it doesn't exist
- // This will be executed once the database is initialized
+ // Connect future watcher to handle search results
+ connect(&m_searchWatcher, &QFutureWatcher::finished,
+ this, [this]() {
+ if (!m_searchCancelled) {
+ // Emit signal with the results only if not cancelled
+ QMutexLocker locker(&m_searchMutex);
+ QString searchText = m_currentSearchText;
+ int offset = m_currentOffset;
+ int limit = m_currentLimit;
+
+ if (!searchText.isEmpty()) {
+ QMutexLocker cacheLocker(&m_cacheMutex);
+ if (m_searchCache.contains(searchText) &&
+ m_searchCache[searchText].contains(qMakePair(offset, limit))) {
+
+ SearchCacheItem cacheItem = m_searchCache[searchText][qMakePair(offset, limit)];
+ emit searchResultsReady(cacheItem.results, searchText,
+ offset, limit, cacheItem.totalCount);
+ }
+ }
+ }
+ });
+
+ // Clean cache periodically
+ QTimer *cleanupTimer = new QTimer(this);
+ connect(cleanupTimer, &QTimer::timeout, this, &DatabaseManager::cleanupCache);
+ cleanupTimer->start(60000); // Clean cache every minute
}
DatabaseManager::~DatabaseManager()
{
+ // Cancel any ongoing search and wait for it to finish
+ cancelSearch();
+
if (m_db.isOpen()) {
m_db.close();
}
+
+ // Close any thread-specific database connections
+ QStringList connectionNames = QSqlDatabase::connectionNames();
+ for (const QString &connName : connectionNames) {
+ // Remove thread-specific database connections that start with "tdb_"
+ if (connName.startsWith("tdb_") && connName != QString("tdb_%1").arg((quintptr)QThread::currentThread())) {
+ QSqlDatabase::removeDatabase(connName);
+ }
+ }
}
bool DatabaseManager::initialize(const QString &dbPath)
@@ -68,31 +113,40 @@ bool DatabaseManager::initialize(const QString &dbPath)
return false;
}
+ bool hasId = false;
bool hasFullPath = false;
bool hasOcrText = false;
while (query.next()) {
QString columnName = query.value(1).toString();
+ if (columnName == "id") hasId = true;
if (columnName == "full_path") hasFullPath = true;
if (columnName == "ocr_text") hasOcrText = true;
}
- if (!hasFullPath || !hasOcrText) {
- qDebug() << "Missing required columns in ocr_results table. Need 'full_path' and 'ocr_text'";
+ if (!hasId || !hasFullPath || !hasOcrText) {
+ qDebug() << "Missing required columns in ocr_results table. Need 'id', 'full_path', and 'ocr_text'";
m_db.close();
return false;
}
- // Create an index on the ocr_text column if it doesn't exist
- // This will speed up text searches dramatically
- query.exec("CREATE INDEX IF NOT EXISTS idx_ocr_text ON ocr_results(ocr_text)");
+ // Initialize FTS5 if available
+ if (initializeFTS()) {
+ qDebug() << "FTS5 initialized successfully.";
+ m_ftsEnabled = true;
+ } else {
+ // Fallback to regular index if FTS5 is unavailable
+ qDebug() << "FTS5 not available, using standard index instead.";
+ query.exec("CREATE INDEX IF NOT EXISTS idx_ocr_text ON ocr_results(ocr_text)");
+ m_ftsEnabled = false;
+ }
m_initialized = true;
qDebug() << "Database initialized successfully.";
return true;
}
-QList DatabaseManager::getAllImages()
+QList DatabaseManager::getAllImages(int offset, int limit)
{
QList images;
@@ -100,7 +154,18 @@ QList DatabaseManager::getAllImages()
qDebug() << "Database not initialized.";
return images;
}
-
+
+ // Check cache first
+ QPair cacheKey(offset, limit);
+ QMutexLocker cacheLocker(&m_cacheMutex);
+ if (m_allImagesCache.contains(cacheKey)) {
+ // Use cached results if available and not expired
+ if (m_lastCacheUpdate.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) {
+ return m_allImagesCache[cacheKey];
+ }
+ }
+ cacheLocker.unlock();
+
// Verify database is still connected
if (!m_db.isOpen() && !m_db.open()) {
qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text();
@@ -112,91 +177,30 @@ QList DatabaseManager::getAllImages()
m_db.transaction();
QSqlQuery query;
- query.prepare("SELECT full_path, ocr_text FROM ocr_results");
+ if (limit > 0) {
+ // Use pagination
+ query.prepare("SELECT id, full_path, ocr_text FROM ocr_results ORDER BY id LIMIT :limit OFFSET :offset");
+ query.bindValue(":limit", limit);
+ query.bindValue(":offset", offset);
+ } else {
+ // Get all results
+ query.prepare("SELECT id, full_path, ocr_text FROM ocr_results ORDER BY id");
+ }
if (!query.exec()) {
qDebug() << "Failed to fetch images:" << query.lastError().text();
- return images;
- }
-
- // Check if files exist as we add them
- // Reserve space for results to avoid reallocations
- images.reserve(query.size() > 0 ? query.size() : 100);
-
- while (query.next()) {
- ImageItem item;
- item.filePath = query.value(0).toString();
- item.ocrText = query.value(1).toString();
-
- // Only add images that have a non-empty path
- if (!item.filePath.isEmpty()) {
- images.append(item);
- }
- }
-
- m_db.commit();
- return images;
-}
-
-QList DatabaseManager::searchImages(const QString &searchText)
-{
- QList images;
-
- if (!m_initialized) {
- qDebug() << "Database not initialized.";
- return images;
- }
-
- // Verify database is still connected
- if (!m_db.isOpen() && !m_db.open()) {
- qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text();
- m_initialized = false;
- return images;
- }
-
- // If search text is empty, return all images
- if (searchText.isEmpty()) {
- // Clear the search cache when empty search is performed
- m_searchCache.clear();
- return getAllImages();
- }
-
- // Check if we have a cached result for this search query
- if (m_searchCache.contains(searchText)) {
- return m_searchCache[searchText];
- }
-
- // Start transaction to speed up queries
- m_db.transaction();
-
- QSqlQuery query;
-
- // Optimize the query based on length of search text
- if (searchText.length() <= 3) {
- // For short search terms, use a more targeted approach
- query.prepare("SELECT full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search");
- query.bindValue(":search", "%" + searchText + "%");
- } else {
- // For longer search terms, use LIKE with a more specific pattern at start
- // which can utilize indexes better if they exist
- query.prepare("SELECT full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search OR ocr_text LIKE :wordstart");
- query.bindValue(":search", "%" + searchText + "%");
- query.bindValue(":wordstart", "% " + searchText + "%");
- }
-
- if (!query.exec()) {
- qDebug() << "Failed to search images:" << query.lastError().text();
m_db.rollback();
return images;
}
// Reserve space for results to avoid reallocations
images.reserve(query.size() > 0 ? query.size() : 100);
-
+
while (query.next()) {
ImageItem item;
- item.filePath = query.value(0).toString();
- item.ocrText = query.value(1).toString();
+ item.id = query.value(0).toInt();
+ item.filePath = query.value(1).toString();
+ item.ocrText = query.value(2).toString();
// Only add images that have a non-empty path
if (!item.filePath.isEmpty()) {
@@ -206,18 +210,525 @@ QList DatabaseManager::searchImages(const QString &s
m_db.commit();
- // Cache the result for future queries
- if (images.size() > 0) {
- m_searchCache.insert(searchText, images);
+ // Update cache
+ cacheLocker.relock();
+ m_allImagesCache[cacheKey] = images;
+ m_lastCacheUpdate = QDateTime::currentDateTime();
+ cacheLocker.unlock();
+
+ return images;
+}
+
+int DatabaseManager::getImageCount()
+{
+ // Return cached count if available
+ QMutexLocker cacheLocker(&m_cacheMutex);
+ if (m_cachedImageCount >= 0 &&
+ m_lastCacheUpdate.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) {
+ return m_cachedImageCount;
+ }
+ cacheLocker.unlock();
+
+ if (!m_initialized) {
+ qDebug() << "Database not initialized.";
+ return 0;
+ }
+
+ // Verify database is still connected
+ if (!m_db.isOpen() && !m_db.open()) {
+ qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text();
+ m_initialized = false;
+ return 0;
+ }
+
+ QSqlQuery query;
+ query.prepare("SELECT COUNT(*) FROM ocr_results");
+
+ if (!query.exec() || !query.next()) {
+ qDebug() << "Failed to get image count:" << query.lastError().text();
+ return 0;
+ }
+
+ int count = query.value(0).toInt();
+
+ // Update cache
+ cacheLocker.relock();
+ m_cachedImageCount = count;
+ cacheLocker.unlock();
+
+ return count;
+}
+
+QSqlDatabase DatabaseManager::getDatabaseConnection()
+{
+ // Get current thread ID to create unique connection name
+ QThread* currentThread = QThread::currentThread();
+ QString connectionName = QString("tdb_%1").arg((quintptr)currentThread);
+
+ // Check if connection already exists for this thread
+ if (QSqlDatabase::contains(connectionName)) {
+ return QSqlDatabase::database(connectionName);
+ }
+
+ // Create new connection for this thread
+ QSqlDatabase threadDb = QSqlDatabase::addDatabase("QSQLITE", connectionName);
+ threadDb.setDatabaseName(m_db.databaseName());
+
+ if (!threadDb.open()) {
+ qDebug() << "Failed to open database in thread:" << threadDb.lastError().text();
+ } else {
+ // Enable foreign keys in this connection
+ QSqlQuery query(threadDb);
+ query.exec("PRAGMA foreign_keys = ON");
+ }
+
+ return threadDb;
+}
+
+void DatabaseManager::searchImages(const QString &searchText, int offset, int limit)
+{
+ if (!m_initialized) {
+ qDebug() << "Database not initialized.";
+ emit searchResultsReady(QList(), searchText, offset, limit, 0);
+ return;
+ }
+
+ // Verify database is still connected
+ if (!m_db.isOpen() && !m_db.open()) {
+ qDebug() << "Database connection lost and cannot be reopened:" << m_db.lastError().text();
+ m_initialized = false;
+ emit searchResultsReady(QList(), searchText, offset, limit, 0);
+ return;
+ }
+
+ // If search text is empty, return all images
+ if (searchText.isEmpty()) {
+ // For empty search, return all images with pagination
+ QList allImages = getAllImages(offset, limit);
+ int totalCount = getImageCount();
- // Limit cache size to avoid memory issues
- if (m_searchCache.size() > MAX_CACHE_SIZE) {
- // Remove the first key (oldest entry)
- if (!m_searchCache.isEmpty()) {
- m_searchCache.remove(m_searchCache.firstKey());
+ emit searchResultsReady(allImages, searchText, offset, limit, totalCount);
+ return;
+ }
+
+ // Check if we have a cached result for this search query
+ {
+ QMutexLocker locker(&m_cacheMutex);
+ QPair cacheKey(offset, limit);
+ if (m_searchCache.contains(searchText) &&
+ m_searchCache[searchText].contains(cacheKey)) {
+
+ SearchCacheItem cacheItem = m_searchCache[searchText][cacheKey];
+
+ // Check if cache is still valid
+ if (cacheItem.timestamp.secsTo(QDateTime::currentDateTime()) < CACHE_LIFETIME_SECS) {
+ emit searchResultsReady(cacheItem.results, searchText, offset, limit, cacheItem.totalCount);
+ return;
}
}
}
- return images;
+ // No delay needed since we're using typing inactivity timer in MainWindow
+
+ // Cancel any ongoing search before starting a new one
+ cancelSearch();
+
+ // Store the current search parameters safely
+ {
+ QMutexLocker locker(&m_searchMutex);
+ m_currentSearchText = searchText;
+ m_currentOffset = offset;
+ m_currentLimit = limit;
+ m_searchCancelled = false;
+ }
+ // The signal is now emitted before starting the thread to ensure UI responsiveness
+
+ // Start the search operation in a background thread
+ m_searchFuture = QtConcurrent::run([this, searchText, offset, limit]() {
+ performSearchInBackground(searchText, offset, limit);
+ });
+
+ // Show immediate feedback that search is starting
+ emit searchStarted(searchText);
+
+ m_searchWatcher.setFuture(m_searchFuture);
+}
+
+void DatabaseManager::cancelSearch()
+{
+ // Set cancelled flag
+ QMutexLocker locker(&m_searchMutex);
+ m_searchCancelled = true;
+ m_currentSearchText.clear();
+ locker.unlock();
+
+ // Wait for any running search to complete
+ if (m_searchFuture.isRunning()) {
+ m_searchFuture.waitForFinished();
+ }
+}
+
+void DatabaseManager::performSearchInBackground(const QString &searchText, int offset, int limit)
+{
+ QList images;
+
+ // Get a thread-specific database connection
+ QSqlDatabase threadDb = getDatabaseConnection();
+
+ if (!threadDb.isOpen() && !threadDb.open()) {
+ qDebug() << "Thread database connection failed:" << threadDb.lastError().text();
+ return;
+ }
+
+ // Check if search was cancelled
+ {
+ QMutexLocker locker(&m_searchMutex);
+ if (m_searchCancelled || m_currentSearchText != searchText) {
+ return;
+ }
+ }
+ // First, get total count for pagination info
+ QSqlQuery countQuery(threadDb);
+ int totalCount = 0;
+
+ if (m_ftsEnabled) {
+ QString ftsQuery = prepareFTSQuery(searchText);
+ countQuery.prepare("SELECT COUNT(*) FROM ocr_results r "
+ "JOIN ocr_fts f ON r.id = f.rowid "
+ "WHERE ocr_fts MATCH :query");
+ countQuery.bindValue(":query", ftsQuery);
+ } else {
+ if (searchText.length() <= 3) {
+ countQuery.prepare("SELECT COUNT(*) FROM ocr_results WHERE ocr_text LIKE :search");
+ countQuery.bindValue(":search", "%" + searchText + "%");
+ } else {
+ countQuery.prepare("SELECT COUNT(*) FROM ocr_results WHERE ocr_text LIKE :search OR ocr_text LIKE :wordstart");
+ countQuery.bindValue(":search", "%" + searchText + "%");
+ countQuery.bindValue(":wordstart", "% " + searchText + "%");
+ }
+ }
+
+ if (countQuery.exec() && countQuery.next()) {
+ totalCount = countQuery.value(0).toInt();
+ } else {
+ qDebug() << "Failed to get count:" << countQuery.lastError().text();
+ totalCount = 0;
+ }
+
+ // Check if search was cancelled before main query
+ {
+ QMutexLocker locker(&m_searchMutex);
+ if (m_searchCancelled || m_currentSearchText != searchText) {
+ return;
+ }
+ }
+
+ // Start transaction to speed up query
+ threadDb.transaction();
+
+ QSqlQuery query(threadDb);
+
+ if (m_ftsEnabled) {
+ // Use FTS5 virtual table for much faster text search
+ QString ftsQuery = prepareFTSQuery(searchText);
+ QString queryStr = "SELECT r.id, r.full_path, r.ocr_text FROM ocr_results r "
+ "JOIN ocr_fts f ON r.id = f.rowid "
+ "WHERE ocr_fts MATCH :query "
+ "ORDER BY rank";
+
+ if (limit > 0) {
+ queryStr += " LIMIT :limit OFFSET :offset";
+ }
+
+ query.prepare(queryStr);
+ query.bindValue(":query", ftsQuery);
+ if (limit > 0) {
+ query.bindValue(":limit", limit);
+ query.bindValue(":offset", offset);
+ }
+ } else {
+ // Fallback to LIKE queries if FTS is not available
+ // Optimize the query based on length of search text
+ if (searchText.length() <= 3) {
+ // For short search terms, use a more targeted approach
+ QString queryStr = "SELECT id, full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search "
+ "ORDER BY id";
+
+ if (limit > 0) {
+ queryStr += " LIMIT :limit OFFSET :offset";
+ }
+
+ query.prepare(queryStr);
+ query.bindValue(":search", "%" + searchText + "%");
+ if (limit > 0) {
+ query.bindValue(":limit", limit);
+ query.bindValue(":offset", offset);
+ }
+ } else {
+ // For longer search terms, use LIKE with a more specific pattern at start
+ QString queryStr = "SELECT id, full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search OR ocr_text LIKE :wordstart "
+ "ORDER BY id";
+
+ if (limit > 0) {
+ queryStr += " LIMIT :limit OFFSET :offset";
+ }
+
+ query.prepare(queryStr);
+ query.bindValue(":search", "%" + searchText + "%");
+ query.bindValue(":wordstart", "% " + searchText + "%");
+ if (limit > 0) {
+ query.bindValue(":limit", limit);
+ query.bindValue(":offset", offset);
+ }
+ }
+ }
+
+ if (!query.exec()) {
+ qDebug() << "Failed to search images:" << query.lastError().text();
+ qDebug() << "Error details:" << query.lastError().databaseText();
+ threadDb.rollback();
+
+ // If FTS query failed, try fallback to LIKE
+ if (m_ftsEnabled) {
+ qDebug() << "Trying fallback to LIKE query...";
+ threadDb.transaction();
+
+ query.prepare("SELECT full_path, ocr_text FROM ocr_results WHERE ocr_text LIKE :search");
+ query.bindValue(":search", "%" + searchText + "%");
+
+ if (!query.exec()) {
+ qDebug() << "Fallback query also failed:" << query.lastError().text();
+ threadDb.rollback();
+ return;
+ }
+ } else {
+ return;
+ }
+ }
+
+ // Check if search was cancelled
+ {
+ QMutexLocker locker(&m_searchMutex);
+ if (m_searchCancelled || m_currentSearchText != searchText) {
+ threadDb.rollback();
+ return;
+ }
+ }
+
+ // Reserve space for results to avoid reallocations
+ images.reserve(query.size() > 0 ? query.size() : 100);
+
+ while (query.next()) {
+ // Periodically check if search was cancelled
+ if (query.at() % 20 == 0) {
+ QMutexLocker locker(&m_searchMutex);
+ if (m_searchCancelled || m_currentSearchText != searchText) {
+ threadDb.rollback();
+ return;
+ }
+ }
+
+ ImageItem item;
+ item.id = query.value(0).toInt();
+ item.filePath = query.value(1).toString();
+ item.ocrText = query.value(2).toString();
+
+ // Only add images that have a non-empty path
+ if (!item.filePath.isEmpty()) {
+ images.append(item);
+ }
+ }
+
+ threadDb.commit();
+
+ // Check if search was cancelled before storing results
+ {
+ QMutexLocker locker(&m_searchMutex);
+ if (m_searchCancelled || m_currentSearchText != searchText) {
+ return;
+ }
+ }
+
+ // Cache the result for future queries and emit signal with the results
+ QMutexLocker locker(&m_cacheMutex);
+
+ // Create cache item with results and metadata
+ SearchCacheItem cacheItem;
+ cacheItem.results = images;
+ cacheItem.totalCount = totalCount;
+ cacheItem.timestamp = QDateTime::currentDateTime();
+
+ // If this is the first query for this search text, create a new map
+ if (!m_searchCache.contains(searchText)) {
+ m_searchCache.insert(searchText, QMap, SearchCacheItem>());
+ }
+
+ // Store results for this specific offset/limit combination
+ QPair cacheKey(offset, limit);
+ m_searchCache[searchText].insert(cacheKey, cacheItem);
+
+ // Limit cache size to avoid memory issues
+ if (m_searchCache.size() > MAX_CACHE_SIZE) {
+ // Remove the oldest entry
+ if (!m_searchCache.isEmpty()) {
+ QString oldestKey = m_searchCache.firstKey();
+ m_searchCache.remove(oldestKey);
+ }
+ }
+
+ // Release mutex before emitting signal
+ locker.unlock();
+
+ // Emit signal with results, including pagination info
+ QMutexLocker searchLocker(&m_searchMutex);
+ if (!m_searchCancelled && m_currentSearchText == searchText) {
+ searchLocker.unlock();
+ emit searchResultsReady(images, searchText, offset, limit, totalCount);
+ }
+}
+
+void DatabaseManager::cleanupCache()
+{
+ QMutexLocker locker(&m_cacheMutex);
+
+ // Get current time
+ QDateTime now = QDateTime::currentDateTime();
+
+ // Expire old search cache items
+ QMutableMapIterator, SearchCacheItem>> i(m_searchCache);
+ while (i.hasNext()) {
+ i.next();
+ QMutableMapIterator, SearchCacheItem> j(i.value());
+ while (j.hasNext()) {
+ j.next();
+ if (j.value().timestamp.secsTo(now) > CACHE_LIFETIME_SECS) {
+ j.remove();
+ }
+ }
+
+ // If no more results for this search text, remove the entry
+ if (i.value().isEmpty()) {
+ i.remove();
+ }
+ }
+
+ // Expire old all-images cache
+ if (m_lastCacheUpdate.secsTo(now) > CACHE_LIFETIME_SECS) {
+ m_allImagesCache.clear();
+ m_cachedImageCount = -1; // Invalidate count cache
+ }
+}
+
+bool DatabaseManager::initializeFTS()
+{
+ // Check if SQLite has FTS5 support
+ QSqlQuery query;
+ query.exec("SELECT sqlite_compileoption_used('ENABLE_FTS5')");
+
+ if (!query.next() || !query.value(0).toBool()) {
+ qDebug() << "FTS5 not available in this SQLite installation";
+ return false;
+ }
+
+ // Check if our FTS table already exists
+ query.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='ocr_fts'");
+
+ if (!query.next()) {
+ // Create FTS5 virtual table
+ qDebug() << "Creating FTS5 virtual table...";
+ bool success = query.exec(
+ "CREATE VIRTUAL TABLE IF NOT EXISTS ocr_fts USING fts5("
+ "ocr_text, "
+ "content='ocr_results', "
+ "content_rowid='id', "
+ "tokenize='porter unicode61');"
+ );
+
+ if (!success) {
+ qDebug() << "Failed to create FTS5 table:" << query.lastError().text();
+ return false;
+ }
+
+ // Populate the FTS table from existing data
+ query.exec("BEGIN TRANSACTION;");
+ success = query.exec(
+ "INSERT INTO ocr_fts(rowid, ocr_text) "
+ "SELECT id, ocr_text FROM ocr_results;"
+ );
+ query.exec("COMMIT;");
+
+ if (!success) {
+ qDebug() << "Failed to populate FTS5 table:" << query.lastError().text();
+ return false;
+ }
+
+ // Create triggers to keep FTS table in sync with ocr_results
+ success = query.exec(
+ "CREATE TRIGGER IF NOT EXISTS ocr_fts_insert AFTER INSERT ON ocr_results BEGIN "
+ " INSERT INTO ocr_fts(rowid, ocr_text) VALUES (new.id, new.ocr_text); "
+ "END;"
+ );
+
+ if (!success) {
+ qDebug() << "Failed to create insert trigger:" << query.lastError().text();
+ return false;
+ }
+
+ success = query.exec(
+ "CREATE TRIGGER IF NOT EXISTS ocr_fts_delete AFTER DELETE ON ocr_results BEGIN "
+ " INSERT INTO ocr_fts(ocr_fts, rowid, ocr_text) VALUES('delete', old.id, old.ocr_text); "
+ "END;"
+ );
+
+ if (!success) {
+ qDebug() << "Failed to create delete trigger:" << query.lastError().text();
+ return false;
+ }
+
+ success = query.exec(
+ "CREATE TRIGGER IF NOT EXISTS ocr_fts_update AFTER UPDATE ON ocr_results BEGIN "
+ " INSERT INTO ocr_fts(ocr_fts, rowid, ocr_text) VALUES('delete', old.id, old.ocr_text); "
+ " INSERT INTO ocr_fts(rowid, ocr_text) VALUES (new.id, new.ocr_text); "
+ "END;"
+ );
+
+ if (!success) {
+ qDebug() << "Failed to create update trigger:" << query.lastError().text();
+ return false;
+ }
+ }
+
+ return true;
+}
+
+QString DatabaseManager::prepareFTSQuery(const QString &searchText)
+{
+ // Split the search text into tokens
+ QStringList tokens = searchText.simplified().split(' ', Qt::SkipEmptyParts);
+
+ // For single word searches, search for the word as-is and with a wildcard
+ if (tokens.size() == 1) {
+ QString token = tokens.first();
+ // Use prefix search (words starting with the term)
+ return QString("%1* OR %1").arg(token);
+ }
+ // For multi-word searches
+ else {
+ // Build both exact phrase search and individual term search
+ QStringList tokenQueries;
+
+ // Add phrase match (higher relevance)
+ tokenQueries << QString("\"%1\"").arg(searchText);
+
+ // Add individual token matches with wildcards
+ for (const QString &token : tokens) {
+ if (token.length() > 2) { // Only use wildcards for tokens with 3+ chars
+ tokenQueries << QString("%1*").arg(token);
+ } else {
+ tokenQueries << token;
+ }
+ }
+
+ return tokenQueries.join(" OR ");
+ }
}
\ No newline at end of file
diff --git a/src/databasemanager.h b/src/databasemanager.h
index 83d5d97..7e43690 100644
--- a/src/databasemanager.h
+++ b/src/databasemanager.h
@@ -11,6 +11,13 @@
#include
#include
#include
+#include
+#include
+
+#include
+#include
+#include
+#include
/**
* @brief The DatabaseManager class handles all database operations
@@ -27,6 +34,7 @@ public:
struct ImageItem {
QString filePath;
QString ocrText;
+ int id; // Add id to support pagination
};
/**
@@ -48,28 +56,102 @@ public:
bool initialize(const QString &dbPath);
/**
- * @brief Get all images from the database
+ * @brief Get all images from the database with pagination
+ * @param offset Starting position (0-based) for pagination
+ * @param limit Maximum number of items to return, 0 means no limit
* @return List of ImageItem objects
*/
- QList getAllImages();
+ QList getAllImages(int offset = 0, int limit = 0);
+
+ /**
+ * @brief Get total count of all images in the database
+ * @return Total number of images
+ */
+ int getImageCount();
public slots:
/**
* @brief Search for images matching the search text
* @param searchText Text to search for in OCR results
- * @return List of ImageItem objects that match the search
+ * @param offset Starting position (0-based) for pagination
+ * @param limit Maximum number of items to return, 0 means no limit
*/
- QList searchImages(const QString &searchText);
+ void searchImages(const QString &searchText, int offset = 0, int limit = 0);
+
+ /**
+ * @brief Cancel any ongoing search operations
+ */
+ void cancelSearch();
+
+signals:
+ /**
+ * @brief Signal emitted when search results are available
+ * @param results The list of image items matching the search
+ * @param searchText The original search text that produced these results
+ * @param offset The offset used for this result set
+ * @param limit The limit used for this result set
+ * @param totalCount The total number of results matching the search (regardless of pagination)
+ */
+ void searchResultsReady(const QList &results, const QString &searchText,
+ int offset, int limit, int totalCount);
+
+ /**
+ * @brief Signal emitted when search operation starts
+ * @param searchText The search text being processed
+ */
+ void searchStarted(const QString &searchText);
private:
QSqlDatabase m_db;
bool m_initialized;
+ bool m_ftsEnabled; // Whether FTS5 is available and enabled
// Cache for search results to improve response time
- QMap> m_searchCache;
+ // Cache structure includes both results and total count
+ struct SearchCacheItem {
+ QList results;
+ int totalCount;
+ QDateTime timestamp;
+ };
- // Maximum number of cached search queries
- static const int MAX_CACHE_SIZE = 50;
+ QMap, SearchCacheItem>> m_searchCache; // searchText -> {(offset, limit) -> results}
+ QMutex m_cacheMutex; // Mutex to protect cache access from multiple threads
+
+ // All images cache with pagination
+ QMap, QList> m_allImagesCache; // (offset, limit) -> results
+ int m_cachedImageCount; // Total image count cache
+ QDateTime m_lastCacheUpdate; // When the cache was last updated
+
+ // Search thread management
+ QFuture m_searchFuture;
+ QFutureWatcher m_searchWatcher;
+ QString m_currentSearchText;
+ int m_currentOffset;
+ int m_currentLimit;
+ bool m_searchCancelled;
+ QMutex m_searchMutex;
+
+ // Cache settings
+ static const int MAX_CACHE_SIZE = 50; // Maximum number of cached search queries
+ static const int DEFAULT_PAGE_SIZE = 100; // Default number of items per page
+ static const int CACHE_LIFETIME_SECS = 300; // Cache lifetime in seconds (5 minutes)
+
+
+
+ // Initialize FTS5 full-text search
+ bool initializeFTS();
+
+ // Prepare FTS query with proper syntax
+ QString prepareFTSQuery(const QString &searchText);
+
+ // Perform search operation in background thread
+ void performSearchInBackground(const QString &searchText, int offset, int limit);
+
+ // Clear expired cache items
+ void cleanupCache();
+
+ // Get a database connection for the current thread
+ QSqlDatabase getDatabaseConnection();
};
#endif // DATABASEMANAGER_H
\ No newline at end of file
diff --git a/src/imagegallery.cpp b/src/imagegallery.cpp
index 02f8552..6e9c048 100644
--- a/src/imagegallery.cpp
+++ b/src/imagegallery.cpp
@@ -9,6 +9,9 @@
#include
#include
#include
+#include
+
+
// ImageThumbnail implementation
ImageThumbnail::ImageThumbnail(const QString &filePath, QWidget *parent)
@@ -43,9 +46,17 @@ void ImageThumbnail::mousePressEvent(QMouseEvent *event)
ImageGallery::ImageGallery(QWidget *parent)
: QWidget(parent)
, m_dbManager(nullptr)
+ , m_currentOffset(0)
+ , m_pageSize(DEFAULT_PAGE_SIZE)
+ , m_totalCount(0)
+ , m_isLoading(false)
+ , m_hasMoreImages(false)
+ , m_lastScrollPosition(0)
+ , m_loadingIndicator(nullptr)
{
// Create scroll area
m_scrollArea = new QScrollArea(this);
+ m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn);
m_scrollArea->setWidgetResizable(true);
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // Prevent horizontal scrollbar
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
@@ -83,6 +94,34 @@ ImageGallery::ImageGallery(QWidget *parent)
QTimer *resizeTimer = new QTimer(this);
connect(resizeTimer, &QTimer::timeout, this, &ImageGallery::handleContainerResized);
resizeTimer->start(300); // Check every 300ms
+
+ // Create scroll timer for detecting end of scroll events
+ m_scrollTimer = new QTimer(this);
+ m_scrollTimer->setSingleShot(true);
+ m_scrollTimer->setInterval(200);
+ connect(m_scrollTimer, &QTimer::timeout, this, &ImageGallery::handleScrolledToBottom);
+
+ // Create loading indicator
+ m_loadingIndicator = new QLabel(tr("Loading more images..."), this);
+ m_loadingIndicator->setAlignment(Qt::AlignCenter);
+ m_loadingIndicator->setStyleSheet("color: #666; font-size: 14px; padding: 10px;");
+ m_loadingIndicator->hide();
+
+ // Install event filter on scroll area viewport to catch scroll events
+ m_scrollArea->viewport()->installEventFilter(this);
+
+ // Create the loading indicator
+ m_loadingIndicator = new QLabel(tr("Loading more images..."), this);
+ m_loadingIndicator->setAlignment(Qt::AlignCenter);
+ m_loadingIndicator->setStyleSheet("color: #666; font-size: 14px; padding: 10px;");
+ m_loadingIndicator->hide();
+
+ // Create Load More button
+ m_loadMoreButton = new QPushButton(tr("Load More Images"), this);
+ m_loadMoreButton->setStyleSheet("font-size: 14px; padding: 10px; margin: 10px; background-color: #e0e0e0; border-radius: 5px; border: 1px solid #c0c0c0;");
+ m_loadMoreButton->setMinimumHeight(50);
+ m_loadMoreButton->setCursor(Qt::PointingHandCursor);
+ connect(m_loadMoreButton, &QPushButton::clicked, this, &ImageGallery::handleLoadMoreClicked);
}
ImageGallery::~ImageGallery()
@@ -93,19 +132,71 @@ ImageGallery::~ImageGallery()
void ImageGallery::setDatabaseManager(DatabaseManager *dbManager)
{
m_dbManager = dbManager;
+
+ // Connect to the DatabaseManager signals
+ if (m_dbManager) {
+ connect(m_dbManager, &DatabaseManager::searchResultsReady,
+ this, &ImageGallery::handleSearchResults);
+ connect(m_dbManager, &DatabaseManager::searchStarted,
+ this, &ImageGallery::handleSearchStarted);
+ }
+
+ // Initialize the last search query
+ m_lastSearchQuery = QString();
}
-void ImageGallery::displayImages(const QList &images)
+void ImageGallery::displayImages(const QList &images, bool clearExisting)
{
- // Clear existing thumbnails
- clearGallery();
+ // Clear existing thumbnails if requested
+ if (clearExisting) {
+ clearGallery();
+ m_currentOffset = 0;
+ // Don't reset m_hasMoreImages here - we need to know if there are more to show the button
+ // It will be set correctly in handleSearchResults
+ }
// Update grid layout to ensure correct column count before adding images
updateGridLayout();
+ // Remove any previous instances of the Load More button and loading indicator from the layout
+ if (m_loadMoreButton->parent()) {
+ m_gridLayout->removeWidget(m_loadMoreButton);
+ }
+ if (m_loadingIndicator->parent()) {
+ m_gridLayout->removeWidget(m_loadingIndicator);
+ }
+ m_loadMoreButton->hide();
+ m_loadingIndicator->hide();
+
const int numImages = images.size();
int row = 0, col = 0;
+ // If we have existing thumbnails, start from the last position
+ if (!clearExisting && !m_thumbnails.isEmpty()) {
+ // Remove any "no images" or loading indicators first
+ for (int i = m_thumbnails.size() - 1; i >= 0; i--) {
+ if (m_thumbnails[i]->text().contains("No images found") ||
+ m_thumbnails[i]->text().contains("Loading")) {
+ m_gridLayout->removeWidget(m_thumbnails[i]);
+ delete m_thumbnails[i];
+ m_thumbnails.removeAt(i);
+ }
+ }
+
+ if (!m_thumbnails.isEmpty()) {
+ int lastIndex = m_thumbnails.size() - 1;
+ row = lastIndex / m_columnsCount;
+ col = lastIndex % m_columnsCount;
+
+ // Move to the next position
+ col++;
+ if (col >= m_columnsCount) {
+ col = 0;
+ row++;
+ }
+ }
+ }
+
for (int i = 0; i < numImages; ++i) {
const auto &item = images[i];
@@ -125,17 +216,20 @@ void ImageGallery::displayImages(const QList &images
QFileInfo fileNameInfo(item.filePath);
QString fileName = fileNameInfo.fileName();
- // Create overlay container with dark background
+ // Create overlay container with dark background - store it as a property of the thumbnail
QFrame* overlay = new QFrame(thumbnailLabel);
+ overlay->setObjectName("filenameOverlay"); // Set object name for finding it later
overlay->setStyleSheet("background-color: rgba(0, 0, 0, 0.7);");
overlay->setFixedHeight(20);
- overlay->setFixedWidth(THUMBNAIL_WIDTH);
// Create label for the filename
QLabel* fileNameLabel = new QLabel(fileName, overlay);
fileNameLabel->setStyleSheet("color: white; background: transparent;");
fileNameLabel->setAlignment(Qt::AlignCenter);
- fileNameLabel->setFixedWidth(THUMBNAIL_WIDTH - 10);
+ // Truncate filename if too long (more than 30 chars)
+ if (fileName.length() > 30) {
+ fileNameLabel->setText(fileName.left(13) + "..." + fileName.right(13));
+ }
// Layout for the overlay
QHBoxLayout* overlayLayout = new QHBoxLayout(overlay);
@@ -143,6 +237,8 @@ void ImageGallery::displayImages(const QList &images
overlayLayout->addWidget(fileNameLabel);
// Position the overlay at the bottom of the thumbnail
+ // Width and position will be adjusted in updateGridLayout()
+ overlay->setFixedWidth(thumbnailLabel->width());
overlay->move(0, THUMBNAIL_HEIGHT - overlay->height());
// Connect the thumbnail click signal
@@ -169,10 +265,32 @@ void ImageGallery::displayImages(const QList &images
noImagesLabel->setAlignment(Qt::AlignCenter);
m_gridLayout->addWidget(noImagesLabel, 0, 0, 1, m_columnsCount);
m_thumbnails.append(noImagesLabel);
+ } else if (images.size() > 0) {
+ // This final row + 1 position is where we'll add the Load More button
+ // or nothing if we don't have more images
+
+ if (m_hasMoreImages) {
+ // Update Load More button text with current count info
+ m_loadMoreButton->setText(tr("Load More Images (%1 of %2)")
+ .arg(m_currentOffset)
+ .arg(m_totalCount));
+
+ // Add to layout and show
+ m_gridLayout->addWidget(m_loadMoreButton, row + 1, 0, 1, m_columnsCount, Qt::AlignCenter);
+ m_loadMoreButton->setVisible(true);
+ m_loadMoreButton->raise(); // Ensure it's on top
+
+ qDebug() << "Added Load More button at row" << row + 1 << "showing"
+ << m_currentOffset << "of" << m_totalCount;
+ } else {
+ m_loadMoreButton->setVisible(false);
+ }
+
+ m_loadingIndicator->setVisible(false);
}
- // Add stretch to the bottom of the grid
- m_gridLayout->setRowStretch(row + 1, 1);
+ // Add stretch to the bottom of the grid, but after the Load More button
+ m_gridLayout->setRowStretch(row + 3, 1);
}
void ImageGallery::clearGallery()
@@ -190,11 +308,219 @@ void ImageGallery::clearGallery()
void ImageGallery::handleSearchTextChanged(const QString &searchText)
{
if (m_dbManager) {
- QList images = m_dbManager->searchImages(searchText);
- displayImages(images);
+ // Clear existing search results
+ clearGallery();
+
+ // Store the search text for tracking
+ m_lastSearchQuery = searchText;
+
+ // Reset pagination for new search
+ m_currentOffset = 0;
+ m_totalCount = 0;
+ m_hasMoreImages = false;
+ m_isLoading = true;
+
+ // Show loading indicator
+ m_loadingIndicator->setText(tr("Searching..."));
+ m_loadingIndicator->show();
+ m_gridLayout->addWidget(m_loadingIndicator, 0, 0, 1, m_columnsCount);
+
+ // Initiate the threaded search with pagination
+ m_dbManager->searchImages(searchText, m_currentOffset, m_pageSize);
}
}
+void ImageGallery::handleSearchResults(const QList &results,
+ const QString &searchText, int offset, int limit,
+ int totalCount)
+{
+ // Only update display if this is the result for the most recent search
+ if (searchText == m_lastSearchQuery) {
+ m_isLoading = false;
+ m_totalCount = totalCount;
+
+ // If this is the first page or a different search
+ bool isFirstPage = (offset == 0);
+
+ // Display images, clearing existing ones only if this is first page
+ displayImages(results, isFirstPage);
+
+ // Update pagination state
+ m_currentOffset = offset + results.size();
+ m_totalCount = totalCount;
+ m_hasMoreImages = m_currentOffset < totalCount;
+
+ // Always hide the loading indicator when search completes
+ m_loadingIndicator->hide();
+
+ // Update Load More button if we have more images
+ // Update Load More button state based on whether there are more images
+ if (m_hasMoreImages) {
+ // Update button text with current counts
+ m_loadMoreButton->setText(tr("Load More Images (%1 of %2)")
+ .arg(m_currentOffset)
+ .arg(totalCount));
+
+ // Add to layout at bottom of grid and ensure it's visible
+ int row = (m_thumbnails.size() / m_columnsCount);
+ m_gridLayout->removeWidget(m_loadMoreButton); // Remove from any previous location
+ m_gridLayout->addWidget(m_loadMoreButton, row + 1, 0, 1, m_columnsCount, Qt::AlignCenter);
+ m_loadMoreButton->setVisible(true);
+ m_loadMoreButton->raise(); // Ensure it's on top
+
+ qDebug() << "Search results - Added Load More button at row" << row + 1
+ << "showing" << m_currentOffset << "of" << totalCount;
+ } else {
+ // Hide both indicators if no more images
+ m_loadingIndicator->setVisible(false);
+ m_loadMoreButton->setVisible(false);
+ }
+ }
+}
+
+// Method to append more images to the gallery
+void ImageGallery::appendImages(const QList &images)
+{
+ // Call displayImages with clearExisting=false
+ displayImages(images, false);
+}
+
+// Handle search start event
+void ImageGallery::handleSearchStarted(const QString &searchText)
+{
+ // Show loading indicator or status message
+ // This is called when a search operation begins
+
+ // Set loading state
+ m_isLoading = true;
+
+ // Only show "Searching" indicator when search actually begins (not during typing)
+ if (!m_thumbnails.isEmpty() && !m_lastSearchQuery.isEmpty() && searchText != m_lastSearchQuery) {
+ // New search, keep thumbnails but show loading overlay
+ m_loadingIndicator->setText(tr("Searching..."));
+ m_loadingIndicator->show();
+
+ // Move loading indicator to be visible
+ m_scrollArea->verticalScrollBar()->setValue(0);
+ }
+}
+
+// Load more images when scrolled to bottom
+void ImageGallery::loadMoreImages()
+{
+ if (!m_dbManager || m_isLoading || !m_hasMoreImages) {
+ return;
+ }
+
+ m_isLoading = true;
+
+ // Hide the Load More button while loading
+ m_loadMoreButton->hide();
+
+ // Show loading indicator
+ m_loadingIndicator->setText(tr("Loading more images (%1 of %2)...")
+ .arg(m_currentOffset)
+ .arg(m_totalCount));
+ m_loadingIndicator->show();
+
+ // Position loading indicator at bottom of grid
+ int row = (m_thumbnails.size() / m_columnsCount);
+ m_gridLayout->addWidget(m_loadingIndicator, row + 1, 0, 1, m_columnsCount);
+
+ // Request next page from database
+ m_dbManager->searchImages(m_lastSearchQuery, m_currentOffset, m_pageSize);
+}
+
+// Handle scroll to bottom event
+void ImageGallery::handleScrolledToBottom()
+{
+ if (!m_isLoading && m_hasMoreImages) {
+ qDebug() << "Loading more images from handleScrolledToBottom";
+ loadMoreImages();
+ }
+}
+
+// Handle Load More button click
+void ImageGallery::handleLoadMoreClicked()
+{
+ if (!m_isLoading && m_hasMoreImages) {
+ qDebug() << "Loading more images from Load More button";
+
+ // Hide the button and show loading indicator during loading
+ m_loadMoreButton->hide();
+ m_loadingIndicator->setText(tr("Loading images..."));
+ m_loadingIndicator->show();
+ int row = (m_thumbnails.size() / m_columnsCount);
+ m_gridLayout->addWidget(m_loadingIndicator, row + 1, 0, 1, m_columnsCount);
+
+ // Request more images
+ loadMoreImages();
+
+ // Debug output
+ qDebug() << "Total thumbnails:" << m_thumbnails.size()
+ << "Grid size:" << m_gridLayout->rowCount() << "x" << m_gridLayout->columnCount()
+ << "Has more:" << m_hasMoreImages;
+ }
+}
+
+// Set page size for lazy loading
+void ImageGallery::setPageSize(int pageSize)
+{
+ if (pageSize > 0) {
+ m_pageSize = pageSize;
+ }
+}
+
+// Reset pagination state to initial values
+void ImageGallery::resetPagination()
+{
+ // Reset pagination state variables
+ m_currentOffset = 0;
+ m_isLoading = false;
+ m_hasMoreImages = false;
+ m_totalCount = 0;
+
+ // Hide loading indicators
+ if (m_loadingIndicator) {
+ m_loadingIndicator->hide();
+ }
+
+ // Hide "Load More" button until we know there are more images
+ if (m_loadMoreButton) {
+ m_loadMoreButton->hide();
+ }
+}
+
+// Override wheel event to detect end of scrolling
+void ImageGallery::wheelEvent(QWheelEvent *event)
+{
+ QWidget::wheelEvent(event);
+
+ // Don't check scroll position during the wheel event
+ // This will be handled by the eventFilter
+}
+
+void ImageGallery::updateSearchTextDisplay(const QString &searchText)
+{
+ // Update the UI to reflect the current search text without performing a search
+ // This gives immediate feedback while typing, before the actual search happens
+
+ // Store the search text for tracking
+ m_lastSearchQuery = searchText;
+
+ // Keep existing thumbnails visible without distracting visual changes
+
+ // If we have a "no results" message showing, update it quietly
+ for (auto thumbnail : m_thumbnails) {
+ if (thumbnail->text().contains("No images found")) {
+ thumbnail->setText(tr("Search in progress..."));
+ }
+ }
+
+ // No typing indicator or popups while typing
+ // Just wait for the actual search to complete
+}
+
void ImageGallery::handleThumbnailClicked(const QString &filePath)
{
// Show a wait cursor while attempting to open the file
@@ -260,7 +586,7 @@ void ImageGallery::resizeEvent(QResizeEvent *event)
}
}
-// Event filter to catch viewport resize events
+// Event filter to catch viewport resize events and scroll events
bool ImageGallery::eventFilter(QObject *watched, QEvent *event)
{
// Check if this is a resize event on the viewport
@@ -270,6 +596,33 @@ bool ImageGallery::eventFilter(QObject *watched, QEvent *event)
return false; // Allow event to propagate
}
+ // Check for various scroll-related events
+ if (watched == m_scrollArea->viewport() &&
+ (event->type() == QEvent::Wheel ||
+ event->type() == QEvent::MouseMove ||
+ event->type() == QEvent::MouseButtonRelease)) {
+
+ QScrollBar* vScrollBar = m_scrollArea->verticalScrollBar();
+ if (vScrollBar) {
+ int max = vScrollBar->maximum();
+ int current = vScrollBar->value();
+ int viewportHeight = m_scrollArea->viewport()->height();
+
+ // If we're near the bottom and not already loading, prepare to load more
+ // Use a percentage of viewport height as threshold for very tall viewports
+ int dynamicThreshold = qMin(LOAD_THRESHOLD_PX, viewportHeight / 4);
+
+ if ((max - current) <= dynamicThreshold && max > 0 && !m_isLoading && m_hasMoreImages) {
+ // Cancel any pending timer and start a new one for debouncing
+ m_scrollTimer->stop();
+ m_scrollTimer->start();
+
+ // We'll rely on the Load More button for explicit user action
+ // rather than showing a loading indicator during scroll
+ }
+ }
+ }
+
// Pass unhandled events to parent
return QWidget::eventFilter(watched, event);
}
@@ -345,6 +698,13 @@ void ImageGallery::updateGridLayout()
for (auto thumbnail : m_thumbnails) {
thumbnail->setMaximumWidth(THUMBNAIL_WIDTH);
thumbnail->setAlignment(Qt::AlignCenter);
+
+ // Update the overlay to match thumbnail width
+ QFrame* overlay = thumbnail->findChild("filenameOverlay");
+ if (overlay) {
+ overlay->setFixedWidth(THUMBNAIL_WIDTH);
+ overlay->move(0, THUMBNAIL_HEIGHT - overlay->height());
+ }
}
} else {
// For multi-column, use minimal margins
@@ -353,12 +713,31 @@ void ImageGallery::updateGridLayout()
// Reset thumbnail constraints
for (auto thumbnail : m_thumbnails) {
thumbnail->setMaximumWidth(QWIDGETSIZE_MAX);
+
+ // Update the overlay to match thumbnail width in multi-column mode
+ QFrame* overlay = thumbnail->findChild("filenameOverlay");
+ if (overlay) {
+ overlay->setFixedWidth(thumbnail->width());
+ overlay->move(0, THUMBNAIL_HEIGHT - overlay->height());
+ }
}
// For multi-column, let the container fill the viewport
m_containerWidget->setMinimumWidth(viewportWidth);
}
+ // Force update of overlay position and size after a short delay
+ // This ensures the overlays are sized correctly after all layout changes
+ QTimer::singleShot(50, this, [this]() {
+ for (auto thumbnail : m_thumbnails) {
+ QFrame* overlay = thumbnail->findChild("filenameOverlay");
+ if (overlay) {
+ overlay->setFixedWidth(thumbnail->width());
+ overlay->move(0, THUMBNAIL_HEIGHT - overlay->height());
+ }
+ }
+ });
+
// Update the layout again after a short delay to handle edge cases
QTimer::singleShot(10, this, [this](){
m_gridLayout->update();
diff --git a/src/imagegallery.h b/src/imagegallery.h
index 9912c2b..57982d0 100644
--- a/src/imagegallery.h
+++ b/src/imagegallery.h
@@ -11,6 +11,7 @@
#include
#include
#include
+#include
#include "databasemanager.h"
// Global constants
@@ -46,18 +47,29 @@ public:
~ImageGallery();
void setDatabaseManager(DatabaseManager *dbManager);
- void displayImages(const QList &images);
+ void displayImages(const QList &images, bool clearExisting = true);
+ void appendImages(const QList &images);
void clearGallery();
+ void loadMoreImages();
+ void setPageSize(int pageSize); // Set the number of images to load per page
+ void resetPagination(); // Reset pagination state to initial values
public slots:
void handleSearchTextChanged(const QString &searchText);
void handleThumbnailClicked(const QString &filePath);
void handleContainerResized(); // New slot to handle resize events
void updateGridLayout(); // Adjusts grid based on current window size
+ void handleSearchResults(const QList &results, const QString &searchText,
+ int offset, int limit, int totalCount);
+ void handleSearchStarted(const QString &searchText);
+ void updateSearchTextDisplay(const QString &searchText); // Update UI with search text without performing search
+ void handleScrolledToBottom();
+ void handleLoadMoreClicked();
protected:
void resizeEvent(QResizeEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
+ void wheelEvent(QWheelEvent *event) override;
private:
QGridLayout *m_gridLayout;
@@ -67,8 +79,26 @@ private:
QList m_thumbnails;
int m_columnsCount; // Dynamic column count based on window size
+ // Pagination variables
+ int m_currentOffset;
+ int m_pageSize;
+ int m_totalCount;
+ bool m_isLoading;
+ bool m_hasMoreImages;
+ QLabel *m_loadingIndicator;
+ QPushButton *m_loadMoreButton;
+
QPixmap createThumbnail(const QString &filePath, int width, int height);
QPixmap createPlaceholderThumbnail(int width, int height, const QString &message);
+ QString m_lastSearchQuery; // Track the last search text
+
+ // Track scroll position
+ int m_lastScrollPosition;
+ QTimer *m_scrollTimer;
+
+ // Lazy loading constants
+ static constexpr int DEFAULT_PAGE_SIZE = 20;
+ static constexpr int LOAD_THRESHOLD_PX = 100; // Pixels from bottom to trigger load
};
#endif // IMAGEGALLERY_H
\ No newline at end of file
diff --git a/src/main.cpp b/src/main.cpp
index e563d39..7e03d74 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,4 +1,5 @@
#include
+#include
#include "mainwindow.h"
int main(int argc, char *argv[])
@@ -9,6 +10,14 @@ int main(int argc, char *argv[])
app.setApplicationName("Screenshot Gallery");
app.setApplicationVersion("1.0.0");
+ // Set application icon
+ QIcon appIcon;
+ appIcon.addFile(":/icons/orcs-gallery-64.png", QSize(64, 64));
+ appIcon.addFile(":/icons/orcs-gallery-128.png", QSize(128, 128));
+ appIcon.addFile(":/icons/orcs-gallery-256.png", QSize(256, 256));
+ appIcon.addFile(":/icons/orcs-gallery-512.png", QSize(512, 512));
+ app.setWindowIcon(appIcon);
+
// Create and show main window
MainWindow mainWindow;
mainWindow.show();
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 7e8b307..7e6bb6f 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1,24 +1,47 @@
#include "mainwindow.h"
+#include "settingsdialog.h"
+#include
#include
#include
#include
#include
#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
#include
-// Define the static constant for the default database path
-const QString MainWindow::DEFAULT_DB_PATH = "/home/master/screenshot_ocr.db";
+// Define settings file path
+const QString MainWindow::CONFIG_FILE_PATH = QDir::homePath() + "/.config/ScreenshotOCRGallery/settings.ini";
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, m_dbManager(new DatabaseManager(this))
- , m_searchDelayTimer(new QTimer(this))
+ , m_typingTimer(new QTimer(this))
+ , m_searchingAnimationTimer(new QTimer(this))
+ , m_searchingDots(1)
+ , m_isTyping(false)
, m_hasValidDatabase(false)
+ , m_databasePath("")
+ , m_screenshotsDir("")
+ , m_imagePreloadCount(DEFAULT_PRELOAD_COUNT)
{
// Set window title and size
setWindowTitle(tr("Screenshot OCR Gallery"));
resize(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT);
+ // Set window icon
+ QIcon appIcon;
+ appIcon.addFile(":/icons/orcs-gallery-64.png", QSize(64, 64));
+ appIcon.addFile(":/icons/orcs-gallery-128.png", QSize(128, 128));
+ appIcon.addFile(":/icons/orcs-gallery-256.png", QSize(256, 256));
+ appIcon.addFile(":/icons/orcs-gallery-512.png", QSize(512, 512));
+ setWindowIcon(appIcon);
+
// Remove fixed minimum size to allow for single column layout at any width
// setMinimumSize(640, 480);
@@ -31,20 +54,61 @@ MainWindow::MainWindow(QWidget *parent)
(availableGeometry.height() - size.height()) / 2);
}
+ // Load settings first
+ loadSettings();
+
// Set up UI
createLayout();
- // Initialize timer for delayed search
- m_searchDelayTimer->setSingleShot(true);
- m_searchDelayTimer->setInterval(SEARCH_DELAY_MS);
- connect(m_searchDelayTimer, &QTimer::timeout, this, &MainWindow::performSearch);
+ // Initialize typing inactivity timer
+ m_typingTimer->setSingleShot(true);
+ m_typingTimer->setInterval(500); // 500ms of no typing before searching
+ connect(m_typingTimer, &QTimer::timeout, this, &MainWindow::handleTypingInactivityTimeout);
+
+ // Only create animation timer for searching visual feedback
+
+ m_searchingAnimationTimer = new QTimer(this);
+ m_searchingAnimationTimer->setInterval(200); // Animation speed for searching dots
+ m_searchingDots = 1;
+ connect(m_searchingAnimationTimer, &QTimer::timeout, this, [this]() {
+ m_searchingDots = (m_searchingDots % 3) + 1; // Cycle between 1, 2, and 3 dots
+ QString dots;
+ for(int i = 0; i < m_searchingDots; ++i) dots += ".";
+ QString searchingIndicator = QString("%1 %2").arg(tr("Searching")).arg(dots);
+ statusBar()->showMessage(searchingIndicator);
+ });
// Initialize database and display images
initializeDatabase();
+
+ // Connect database signals for status updates
+ if (m_dbManager) {
+ connect(m_dbManager, &DatabaseManager::searchStarted,
+ this, [this](const QString &text) {
+ statusBar()->showMessage(tr("Searching..."), 1000);
+ });
+
+ connect(m_dbManager, &DatabaseManager::searchResultsReady,
+ this, [this](const QList &results, const QString &text,
+ int offset, int limit, int totalCount) {
+ if (text.isEmpty()) {
+ statusBar()->showMessage(tr("Showing all images (paginated) - %1 total").arg(totalCount));
+ } else {
+ statusBar()->showMessage(tr("Found %1 results for \"%2\" (showing %3 to %4)")
+ .arg(totalCount)
+ .arg(text)
+ .arg(offset - results.size() + 1)
+ .arg(offset), 3000);
+ }
+ });
+ }
+
displayAllImages();
- // Set focus to search bar
+ // Set focus to search bar and disable autocomplete which can cause UI lag
m_searchBar->setFocus();
+ m_searchBar->setAutoFillBackground(true);
+ m_searchBar->setAttribute(Qt::WA_MacShowFocusRect, false); // Reduce drawing overhead
// Set status bar
statusBar()->showMessage(tr("Ready"));
@@ -67,13 +131,42 @@ void MainWindow::createLayout()
m_mainLayout->setSpacing(5); // Reduce spacing between elements
m_mainLayout->setContentsMargins(5, 5, 5, 5); // Reduce margins
- // Create title label
- m_titleLabel = new QLabel(tr("Screenshot OCR Gallery"), this);
- QFont titleFont = m_titleLabel->font();
- titleFont.setPointSize(16);
- titleFont.setBold(true);
- m_titleLabel->setFont(titleFont);
- m_titleLabel->setAlignment(Qt::AlignCenter);
+ // Title removed - now shown only in the title bar
+
+ // Create menu bar
+ QMenuBar *menuBar = new QMenuBar(this);
+ setMenuBar(menuBar);
+
+ // Create File menu
+ QMenu *fileMenu = menuBar->addMenu(tr("&File"));
+
+ // Add Open action
+ QAction *openAction = new QAction(tr("&Open"), this);
+ openAction->setShortcut(QKeySequence::Open);
+ connect(openAction, &QAction::triggered, this, &MainWindow::handleOpenFile);
+ fileMenu->addAction(openAction);
+
+ // Add Open With action
+ QAction *openWithAction = new QAction(tr("Open &With..."), this);
+ connect(openWithAction, &QAction::triggered, this, &MainWindow::handleOpenFileWith);
+ fileMenu->addAction(openWithAction);
+
+ // Add separator
+ fileMenu->addSeparator();
+
+ // Add Settings action
+ QAction *settingsAction = new QAction(tr("&Settings"), this);
+ connect(settingsAction, &QAction::triggered, this, &MainWindow::handleSettings);
+ fileMenu->addAction(settingsAction);
+
+ // Add separator
+ fileMenu->addSeparator();
+
+ // Add Quit action
+ QAction *quitAction = new QAction(tr("&Quit"), this);
+ quitAction->setShortcut(QKeySequence::Quit);
+ connect(quitAction, &QAction::triggered, qApp, &QApplication::quit);
+ fileMenu->addAction(quitAction);
// Create search bar
m_searchBar = new QLineEdit(this);
@@ -85,34 +178,33 @@ void MainWindow::createLayout()
m_imageGallery->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
// Add widgets to layout
- m_mainLayout->addWidget(m_titleLabel);
m_mainLayout->addWidget(m_searchBar);
m_mainLayout->addWidget(m_imageGallery, 1);
- // Connect signals
- connect(m_searchBar, &QLineEdit::textChanged, this, &MainWindow::handleSearchTextChanged);
+ // Connect signals - use textEdited instead of textChanged to handle only user input
+ connect(m_searchBar, &QLineEdit::textEdited, this, &MainWindow::handleSearchTextChanged);
}
void MainWindow::initializeDatabase()
{
// Check if database file exists
- QFileInfo dbFileInfo(DEFAULT_DB_PATH);
+ QFileInfo dbFileInfo(m_databasePath);
if (!dbFileInfo.exists() || !dbFileInfo.isFile()) {
- QString errorMsg = tr("Database file not found: %1").arg(DEFAULT_DB_PATH);
+ QString errorMsg = tr("Database file not found: %1").arg(m_databasePath);
statusBar()->showMessage(errorMsg, 10000);
QMessageBox::critical(this, tr("Database Error"), errorMsg);
- qDebug() << "Database file not found:" << DEFAULT_DB_PATH;
+ qDebug() << "Database file not found:" << m_databasePath;
m_hasValidDatabase = false;
return;
}
// Try to initialize database
- if (!m_dbManager->initialize(DEFAULT_DB_PATH)) {
+ if (!m_dbManager->initialize(m_databasePath)) {
QString errorMsg = tr("Failed to connect to database");
statusBar()->showMessage(errorMsg, 10000);
QMessageBox::warning(this, tr("Database Error"),
tr("Failed to connect to database: %1\nThe application will continue with limited functionality.")
- .arg(DEFAULT_DB_PATH));
+ .arg(m_databasePath));
qDebug() << "Failed to initialize database";
m_hasValidDatabase = false;
} else {
@@ -130,12 +222,25 @@ void MainWindow::displayAllImages()
return;
}
- QList allImages = m_dbManager->getAllImages();
+ // Get total image count first
+ int totalImageCount = m_dbManager->getImageCount();
+
+ // Get the first page of images with pagination
+ QList allImages = m_dbManager->getAllImages(0, m_imagePreloadCount);
if (allImages.isEmpty()) {
statusBar()->showMessage(tr("No images found in database"), 5000);
+ } else {
+ statusBar()->showMessage(tr("Loaded %1 of %2 images").arg(allImages.size()).arg(totalImageCount), 3000);
}
- m_imageGallery->displayImages(allImages);
+ // Clear existing images and display first page
+ bool hasMoreImages = (totalImageCount > allImages.size());
+ m_imageGallery->displayImages(allImages, true);
+
+ // ALWAYS force update to ensure Load More button appears correctly
+ // This is critical for when we reset the search
+ m_imageGallery->handleSearchResults(allImages, "", 0, m_imagePreloadCount, totalImageCount);
+
updateStatusBar();
}
@@ -146,42 +251,193 @@ void MainWindow::handleSearchTextChanged()
return;
}
+ // Capture current text
+ QString currentText = m_searchBar->text();
+
// If search bar is cleared, immediately show all images
- if (m_searchBar->text().isEmpty()) {
+ if (currentText.isEmpty()) {
m_lastSearchText.clear();
- m_searchDelayTimer->stop();
+ m_typingTimer->stop();
+ // No typing animation to stop
+ m_searchingAnimationTimer->stop();
+ m_isTyping = false;
+ // No typing dots to reset
+ m_searchingDots = 1;
+
+ // Show loading status
+ statusBar()->showMessage(tr("Loading gallery..."));
+
+ // First, explicitly reset current offset in gallery
+ m_imageGallery->resetPagination();
+
+ // Clear gallery and load first page
displayAllImages(); // Show all images immediately
return; // Skip the timer since we've already updated
}
- // For non-empty searches, restart the timer each time the user types
- m_searchDelayTimer->start();
+ // Update last search text
+ m_lastSearchText = currentText;
+
+ // Indicate user is typing and reset the inactivity timer
+ m_isTyping = true;
+ m_typingTimer->start();
+
+ // Don't show any typing indicator in the status bar
+ // Only update the search text display without animation
+
+ // Update the search text display but don't search yet
+ m_imageGallery->updateSearchTextDisplay(m_lastSearchText);
+}
+
+void MainWindow::handleTypingInactivityTimeout()
+{
+ // User has stopped typing for 500ms, perform the search
+ m_isTyping = false;
+
+ // Start search animation directly without typing animation
+
+ // Update the status bar with search indicator
+ QString dots;
+ for(int i = 0; i < m_searchingDots; ++i) dots += ".";
+ QString searchingIndicator = QString("%1 %2").arg(tr("Searching")).arg(dots);
+ statusBar()->showMessage(searchingIndicator);
+
+ // Start the searching animation
+ if (!m_searchingAnimationTimer->isActive()) {
+ m_searchingAnimationTimer->start();
+ }
+
+ // Actually perform the search
+ performSearch();
}
void MainWindow::performSearch()
{
- if (!m_hasValidDatabase) {
- statusBar()->showMessage(tr("Cannot perform search: No database connection"), 3000);
+ if (!m_hasValidDatabase || m_isTyping) {
+ // Don't search if database is not available or if user is still typing
return;
}
- QString currentText = m_searchBar->text();
-
- // Only perform search if text has changed
- if (currentText != m_lastSearchText) {
- m_lastSearchText = currentText;
+ try {
+ // Clear the gallery and prepare for new search with pagination
+ m_imageGallery->clearGallery();
- try {
- m_imageGallery->handleSearchTextChanged(currentText);
- updateStatusBar();
- } catch (const std::exception &e) {
- QString errorMsg = tr("Search error: %1").arg(e.what());
- statusBar()->showMessage(errorMsg, 5000);
- qDebug() << errorMsg;
+ // Directly pass the search to the image gallery
+ // The gallery will handle the threaded search operation with pagination
+ m_imageGallery->handleSearchTextChanged(m_lastSearchText);
+ updateStatusBar();
+
+ // Show searching status
+ statusBar()->showMessage(tr("Searching..."), 1000);
+ } catch (const std::exception &e) {
+ QString errorMsg = tr("Search error: %1").arg(e.what());
+ statusBar()->showMessage(errorMsg, 5000);
+ qDebug() << errorMsg;
+ }
+}
+
+// File menu action handlers
+void MainWindow::handleOpenFile()
+{
+ QString filePath = QFileDialog::getOpenFileName(this,
+ tr("Open Image File"), m_screenshotsDir,
+ tr("Image Files (*.png *.jpg *.jpeg *.bmp *.gif)"));
+
+ if (!filePath.isEmpty()) {
+ QDesktopServices::openUrl(QUrl::fromLocalFile(filePath));
+ }
+}
+
+void MainWindow::handleOpenFileWith()
+{
+ QString filePath = QFileDialog::getOpenFileName(this,
+ tr("Open Image File"), m_screenshotsDir,
+ tr("Image Files (*.png *.jpg *.jpeg *.bmp *.gif)"));
+
+ if (!filePath.isEmpty()) {
+ // Get the program to open the file with
+ bool ok;
+ QString program = QInputDialog::getText(this, tr("Open With"),
+ tr("Enter program name:"), QLineEdit::Normal,
+ "", &ok);
+ if (ok && !program.isEmpty()) {
+ QProcess *process = new QProcess(this);
+ connect(process, QOverload::of(&QProcess::finished),
+ process, &QProcess::deleteLater);
+
+ process->start(program, QStringList() << filePath);
}
}
}
+void MainWindow::handleSettings()
+{
+ SettingsDialog settingsDialog(this);
+
+ // Show the settings dialog
+ if (settingsDialog.exec() == QDialog::Accepted) {
+ // Save and apply new settings
+ m_databasePath = settingsDialog.getDatabasePath();
+ m_screenshotsDir = settingsDialog.getScreenshotsDir();
+ m_imagePreloadCount = settingsDialog.getImagePreloadCount();
+
+ // Apply the new settings
+ applySettings();
+ }
+}
+
+void MainWindow::loadSettings()
+{
+ // Ensure config directory exists
+ QFileInfo configFile(CONFIG_FILE_PATH);
+ QDir configDir = configFile.absoluteDir();
+ if (!configDir.exists()) {
+ configDir.mkpath(".");
+ }
+
+ QSettings settings(CONFIG_FILE_PATH, QSettings::IniFormat);
+
+ // Load database path with fallback to default
+ m_databasePath = settings.value("databasePath", "/home/master/screenshot_ocr.db").toString();
+
+ // Load screenshots directory with fallback to home/Screenshots
+ m_screenshotsDir = settings.value("screenshotsDir", QDir::homePath() + "/Screenshots").toString();
+
+ // Load preload count
+ m_imagePreloadCount = settings.value("imagePreloadCount", DEFAULT_PRELOAD_COUNT).toInt();
+}
+
+void MainWindow::applySettings()
+{
+ // Re-initialize database with new path
+ bool wasValid = m_hasValidDatabase;
+ m_hasValidDatabase = false;
+
+ // Try to initialize the database with the new path
+ initializeDatabase();
+
+ // Show appropriate message
+ if (!wasValid && m_hasValidDatabase) {
+ statusBar()->showMessage(tr("Successfully connected to database"), 3000);
+ } else if (wasValid && !m_hasValidDatabase) {
+ statusBar()->showMessage(tr("Lost connection to database"), 5000);
+ } else if (!wasValid && !m_hasValidDatabase) {
+ statusBar()->showMessage(tr("Could not connect to database"), 5000);
+ } else {
+ // Update page size in ImageGallery
+ if (m_imageGallery) {
+ m_imageGallery->setPageSize(m_imagePreloadCount);
+ }
+
+ // Refresh gallery with new database
+ displayAllImages();
+
+ // Ensure the status is updated with correct count
+ int totalImageCount = m_dbManager->getImageCount();
+ statusBar()->showMessage(tr("Database loaded with %1 total images").arg(totalImageCount), 3000);
+ }
+}
+
void MainWindow::updateStatusBar()
{
if (!m_hasValidDatabase) {
@@ -190,16 +446,15 @@ void MainWindow::updateStatusBar()
}
try {
- int imageCount = m_dbManager->searchImages(m_lastSearchText).count();
- int totalImages = m_dbManager->getAllImages().count();
+ // Get the total count of images in the database
+ int totalImages = m_dbManager->getImageCount();
if (m_lastSearchText.isEmpty()) {
- statusBar()->showMessage(tr("Displaying all %1 images").arg(totalImages));
+ statusBar()->showMessage(tr("Displaying images (paginated) - %1 total").arg(totalImages));
} else {
- statusBar()->showMessage(tr("Found %1 of %2 images matching \"%3\"")
- .arg(imageCount)
- .arg(totalImages)
- .arg(m_lastSearchText));
+ // Status message will be updated when search results arrive
+ // via the searchResultsReady signal
+ statusBar()->showMessage(tr("Searching..."));
}
} catch (const std::exception &e) {
statusBar()->showMessage(tr("Error updating status: %1").arg(e.what()), 5000);
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 760e718..ad827a2 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -26,6 +26,12 @@ private slots:
void handleSearchTextChanged();
void performSearch();
void updateStatusBar();
+ void handleTypingInactivityTimeout();
+ void handleOpenFile();
+ void handleOpenFileWith();
+ void handleSettings();
+ void applySettings();
+ void loadSettings();
private:
void createLayout();
@@ -36,20 +42,30 @@ private:
QWidget *m_centralWidget;
QVBoxLayout *m_mainLayout;
QLineEdit *m_searchBar;
- QLabel *m_titleLabel;
ImageGallery *m_imageGallery;
// Data
DatabaseManager *m_dbManager;
QString m_lastSearchText;
- QTimer *m_searchDelayTimer;
+ QTimer *m_typingTimer;
+ QTimer *m_searchingAnimationTimer;
+ int m_searchingDots;
+ bool m_isTyping;
bool m_hasValidDatabase;
+ // Settings
+ QString m_databasePath;
+ QString m_screenshotsDir;
+ int m_imagePreloadCount;
+
+ // Settings file path
+ static const QString CONFIG_FILE_PATH;
+
// Constants
- static constexpr int SEARCH_DELAY_MS = 50; // Reduced delay for more responsive typing
+ static constexpr int SEARCH_DELAY_MS = 300; // Delay for search typing
static constexpr int DEFAULT_WINDOW_WIDTH = 1200;
static constexpr int DEFAULT_WINDOW_HEIGHT = 800;
- static const QString DEFAULT_DB_PATH;
+ static constexpr int DEFAULT_PRELOAD_COUNT = 20;
};
#endif // MAINWINDOW_H
\ No newline at end of file
diff --git a/src/settingsdialog.cpp b/src/settingsdialog.cpp
new file mode 100644
index 0000000..9c6e5f1
--- /dev/null
+++ b/src/settingsdialog.cpp
@@ -0,0 +1,260 @@
+#include "settingsdialog.h"
+#include
+#include
+#include
+#include
+#include
+
+// Define static constants
+const QString SettingsDialog::DEFAULT_SCREENSHOTS_DIR = QDir::homePath() + "/Screenshots";
+const QString SettingsDialog::DEFAULT_DATABASE_FILENAME = "screenshot_ocr.db";
+const QString SettingsDialog::CONFIG_FILE_PATH = QDir::homePath() + "/.config/ScreenshotOCRGallery/settings.ini";
+
+SettingsDialog::SettingsDialog(QWidget *parent)
+ : QDialog(parent)
+{
+ setWindowTitle(tr("Settings"));
+ setMinimumWidth(500);
+
+ // Create UI elements
+ createLayout();
+
+ // Load saved settings
+ loadSettings();
+
+ // Connect signals and slots
+ connect(m_browseScreenshotsDirBtn, &QPushButton::clicked, this, &SettingsDialog::handleBrowseScreenshotsDir);
+ connect(m_browseDatabaseBtn, &QPushButton::clicked, this, &SettingsDialog::handleBrowseDatabase);
+ connect(m_buttonBox, &QDialogButtonBox::accepted, this, &SettingsDialog::handleAccepted);
+ connect(m_buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
+}
+
+SettingsDialog::~SettingsDialog()
+{
+ // Qt will handle cleanup of UI elements through parent-child relationships
+}
+
+void SettingsDialog::createLayout()
+{
+ QVBoxLayout *mainLayout = new QVBoxLayout(this);
+ QGridLayout *formLayout = new QGridLayout();
+
+ // Ensure config directory exists
+ QFileInfo configFile(CONFIG_FILE_PATH);
+ QDir configDir = configFile.absoluteDir();
+ if (!configDir.exists()) {
+ configDir.mkpath(".");
+ }
+
+ // Screenshots directory row
+ QLabel *screenshotsDirLabel = new QLabel(tr("Screenshots Directory:"), this);
+ m_screenshotsDirEdit = new QLineEdit(this);
+ m_browseScreenshotsDirBtn = new QPushButton(tr("..."), this);
+ m_browseScreenshotsDirBtn->setMaximumWidth(40);
+
+ formLayout->addWidget(screenshotsDirLabel, 0, 0);
+ formLayout->addWidget(m_screenshotsDirEdit, 0, 1);
+ formLayout->addWidget(m_browseScreenshotsDirBtn, 0, 2);
+
+ // Database path row
+ QLabel *databasePathLabel = new QLabel(tr("Database File Path:"), this);
+ m_databasePathEdit = new QLineEdit(this);
+ m_browseDatabaseBtn = new QPushButton(tr("..."), this);
+ m_browseDatabaseBtn->setMaximumWidth(40);
+
+ formLayout->addWidget(databasePathLabel, 1, 0);
+ formLayout->addWidget(m_databasePathEdit, 1, 1);
+ formLayout->addWidget(m_browseDatabaseBtn, 1, 2);
+
+ // Preload count row
+ QLabel *preloadCountLabel = new QLabel(tr("Images to pre-load:"), this);
+ m_imagePreloadCountEdit = new QLineEdit(this);
+ m_imagePreloadCountEdit->setValidator(new QIntValidator(1, 100, this));
+
+ formLayout->addWidget(preloadCountLabel, 2, 0);
+ formLayout->addWidget(m_imagePreloadCountEdit, 2, 1);
+
+ // Help text
+ QLabel *helpText = new QLabel(tr("Note: If no filename is provided for the database path, "
+ "'screenshot_ocr.db' will be used automatically."), this);
+ helpText->setWordWrap(true);
+ helpText->setStyleSheet("color: #666; font-size: 11px;");
+
+ // Button box
+ m_buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+
+ // Add to main layout
+ mainLayout->addLayout(formLayout);
+ mainLayout->addWidget(helpText);
+ mainLayout->addSpacing(10);
+ mainLayout->addWidget(m_buttonBox);
+
+ setLayout(mainLayout);
+}
+
+void SettingsDialog::handleBrowseScreenshotsDir()
+{
+ QString dir = QFileDialog::getExistingDirectory(
+ this, tr("Select Screenshots Directory"),
+ m_screenshotsDirEdit->text().isEmpty() ? QDir::homePath() : m_screenshotsDirEdit->text(),
+ QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
+
+ if (!dir.isEmpty()) {
+ m_screenshotsDirEdit->setText(dir);
+ }
+}
+
+void SettingsDialog::handleBrowseDatabase()
+{
+ QString currentPath = m_databasePathEdit->text();
+ if (currentPath.isEmpty()) {
+ currentPath = QDir::homePath();
+ } else {
+ QFileInfo fileInfo(currentPath);
+ if (fileInfo.isFile()) {
+ currentPath = fileInfo.absolutePath();
+ }
+ }
+
+ QString filePath = QFileDialog::getSaveFileName(
+ this, tr("Select Database File"),
+ currentPath,
+ tr("SQLite Database (*.db);;All Files (*)"));
+
+ if (!filePath.isEmpty()) {
+ m_databasePathEdit->setText(filePath);
+ }
+}
+
+void SettingsDialog::handleAccepted()
+{
+ // Validate settings
+ QDir screenshotsDir(m_screenshotsDirEdit->text());
+ if (!screenshotsDir.exists()) {
+ // Ask if we should create the directory
+ QMessageBox::StandardButton reply = QMessageBox::question(
+ this, tr("Directory Not Found"),
+ tr("The screenshots directory does not exist. Create it?"),
+ QMessageBox::Yes | QMessageBox::No);
+
+ if (reply == QMessageBox::Yes) {
+ if (!screenshotsDir.mkpath(".")) {
+ QMessageBox::warning(this, tr("Error"),
+ tr("Failed to create directory: %1").arg(screenshotsDir.path()));
+ return;
+ }
+ } else {
+ // User chose not to create directory
+ return;
+ }
+ }
+
+ // Ensure database path has a filename
+ m_databasePathEdit->setText(ensureDatabaseFilename(m_databasePathEdit->text()));
+
+ // Check if database directory exists
+ QFileInfo dbFileInfo(m_databasePathEdit->text());
+ QDir dbDir = dbFileInfo.absoluteDir();
+
+ if (!dbDir.exists()) {
+ QMessageBox::StandardButton reply = QMessageBox::question(
+ this, tr("Directory Not Found"),
+ tr("The database directory does not exist. Create it?"),
+ QMessageBox::Yes | QMessageBox::No);
+
+ if (reply == QMessageBox::Yes) {
+ if (!dbDir.mkpath(".")) {
+ QMessageBox::warning(this, tr("Error"),
+ tr("Failed to create directory: %1").arg(dbDir.path()));
+ return;
+ }
+ } else {
+ // User chose not to create directory
+ return;
+ }
+ }
+
+ // Save settings
+ saveSettings();
+
+ // Accept dialog
+ accept();
+}
+
+QString SettingsDialog::getScreenshotsDir() const
+{
+ return m_screenshotsDirEdit->text();
+}
+
+QString SettingsDialog::getDatabasePath() const
+{
+ return m_databasePathEdit->text();
+}
+
+void SettingsDialog::loadSettings()
+{
+ QSettings settings(CONFIG_FILE_PATH, QSettings::IniFormat);
+
+ // Load screenshots directory
+ QString screenshotsDir = settings.value("screenshotsDir", DEFAULT_SCREENSHOTS_DIR).toString();
+ m_screenshotsDirEdit->setText(screenshotsDir);
+
+ // Load database path
+ QString databasePath = settings.value("databasePath", QDir::homePath() + "/" + DEFAULT_DATABASE_FILENAME).toString();
+ m_databasePathEdit->setText(databasePath);
+
+ // Load preload count
+ int preloadCount = settings.value("imagePreloadCount", DEFAULT_PRELOAD_COUNT).toInt();
+ m_imagePreloadCountEdit->setText(QString::number(preloadCount));
+}
+
+void SettingsDialog::saveSettings()
+{
+ QSettings settings(CONFIG_FILE_PATH, QSettings::IniFormat);
+
+ // Save screenshots directory
+ settings.setValue("screenshotsDir", getScreenshotsDir());
+
+ // Save database path
+ settings.setValue("databasePath", getDatabasePath());
+
+ // Save preload count
+ settings.setValue("imagePreloadCount", getImagePreloadCount());
+
+ settings.sync();
+}
+
+int SettingsDialog::getImagePreloadCount() const
+{
+ bool ok;
+ int count = m_imagePreloadCountEdit->text().toInt(&ok);
+
+ // Return the default if conversion fails or value is invalid
+ if (!ok || count < 1) {
+ return DEFAULT_PRELOAD_COUNT;
+ }
+
+ return count;
+}
+
+QString SettingsDialog::ensureDatabaseFilename(const QString &path)
+{
+ if (path.isEmpty()) {
+ // If path is empty, use default in home directory
+ return QDir::homePath() + "/" + DEFAULT_DATABASE_FILENAME;
+ }
+
+ QFileInfo fileInfo(path);
+ if (fileInfo.isDir()) {
+ // If path is a directory, append default filename
+ QString dirPath = path;
+ // Ensure path ends with a separator
+ if (!dirPath.endsWith('/') && !dirPath.endsWith('\\')) {
+ dirPath += '/';
+ }
+ return dirPath + DEFAULT_DATABASE_FILENAME;
+ }
+
+ // If path already has a filename component, use it as is
+ return path;
+}
\ No newline at end of file
diff --git a/src/settingsdialog.h b/src/settingsdialog.h
new file mode 100644
index 0000000..da01d61
--- /dev/null
+++ b/src/settingsdialog.h
@@ -0,0 +1,60 @@
+#ifndef SETTINGSDIALOG_H
+#define SETTINGSDIALOG_H
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+class SettingsDialog : public QDialog
+{
+ Q_OBJECT
+
+public:
+ explicit SettingsDialog(QWidget *parent = nullptr);
+ ~SettingsDialog();
+
+ // Get settings values
+ QString getScreenshotsDir() const;
+ QString getDatabasePath() const;
+ int getImagePreloadCount() const;
+
+ // Load settings from persistent storage
+ void loadSettings();
+
+ // Save settings to persistent storage
+ void saveSettings();
+
+private slots:
+ // Button click handlers
+ void handleBrowseScreenshotsDir();
+ void handleBrowseDatabase();
+ void handleAccepted();
+
+private:
+ // UI Elements
+ QLineEdit *m_screenshotsDirEdit;
+ QLineEdit *m_databasePathEdit;
+ QLineEdit *m_imagePreloadCountEdit;
+ QPushButton *m_browseScreenshotsDirBtn;
+ QPushButton *m_browseDatabaseBtn;
+ QDialogButtonBox *m_buttonBox;
+
+ // Helper methods
+ void createLayout();
+ QString ensureDatabaseFilename(const QString &path);
+
+ // Default settings
+ static const QString DEFAULT_SCREENSHOTS_DIR;
+ static const QString DEFAULT_DATABASE_FILENAME;
+ static const QString CONFIG_FILE_PATH;
+ static const int DEFAULT_PRELOAD_COUNT = 20;
+};
+
+#endif // SETTINGSDIALOG_H
\ No newline at end of file