Files
ocr-screenshot-gallery/plan.md
T
yzinchuk 850864dc58 add thumbnail disk cache, sort order, and date range filter
- Thumbnail disk cache: scaled PNGs persisted under ~/.cache/orc-gallery/thumbs/
  keyed by MD5(path+filesize); avoids re-decode/re-scale on gallery reload
- SortOrder enum (DateNewest/Oldest, NameAsc/Desc) threaded through
  DatabaseManager, ImageGallery, and MainWindow; SQL ORDER BY generated
  by sortClause() helper
- Date range filter: QDateEdit pair in filter toolbar with special-value
  "Any" sentinel; dateWhereFragment() omits WHERE clause when dates are
  invalid (no-filter); QSignalBlocker used in handleClearDates for atomic
  reset without double-refresh
- Cache keys updated to encode sort+date+text via makeSearchKey()
- plan.md updated with new features, corrected API signatures, and GUI
  layout diagram

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 04:19:04 -04:00

18 KiB
Raw Blame History

ORC Gallery — Project Reference

A Qt6 desktop application for Linux that OCRs screenshots and provides a searchable, paginated gallery interface. Users run a Python OCR script to populate a SQLite database, then browse and search all recognized text visually.


Directory Structure

orc-gallery/
├── CMakeLists.txt                  # CMake build configuration (Qt6)
├── build.sh                        # One-shot build automation script
├── README.md                       # User-facing documentation
├── resources.qrc                   # Qt resource file bundling icons
├── orc-gallery.desktop      # Linux XDG desktop entry
├── .gitignore
│
├── src/                            # C++ application source
│   ├── main.cpp
│   ├── mainwindow.h / mainwindow.cpp
│   ├── imagegallery.h / imagegallery.cpp
│   ├── databasemanager.h / databasemanager.cpp
│   ├── settingsdialog.h / settingsdialog.cpp
│   └── updatedatabasedialog.h / updatedatabasedialog.cpp
│
├── OCR-scripts/                    # Python OCR utilities + shell helpers
│   ├── ocr_screenshots.py          # Main OCR runner
│   ├── ocr_rofi.sh                 # Rofi launcher integration
│   ├── direct_rofi_ocr.sh
│   ├── simple_rofi_ocr.sh
│   ├── rofi_ocr_search.sh
│   ├── ocr_search_rofi.py
│   ├── search_ocr.py
│   └── screenshot_ocr.db           # Example/seed database
│
├── icons/                          # Application icon assets
│   ├── orc-gallery-512.png
│   ├── orc-gallery-256.png
│   ├── orc-gallery-128.png
│   └── orc-gallery-64.png
│
└── build/                          # CMake build output (git-ignored)
    └── orc-gallery          # Compiled executable

Technology Stack

Layer Technology
GUI framework Qt 6 (Core, Gui, Widgets, Sql, Concurrent)
Build system CMake 3.16+, C++17
Database SQLite 3 via Qt6::Sql
Full-text search SQLite FTS5 with Porter stemmer
OCR engine Tesseract (invoked via Python subprocess)
OCR scripting Python 3
Linux integration XDG Base Directory Specification
Launcher integration Rofi (optional, via shell scripts)

Qt modules used: Qt6::Core, Qt6::Gui, Qt6::Widgets, Qt6::Sql, Qt6::Concurrent

Qt features relied upon: MOC (signals/slots), QSettings (INI persistence), QProcess (subprocess management), QtConcurrent (thread pool), QScrollArea, QGridLayout, QSqlDatabase.


Building

chmod +x build.sh
./build.sh

The script checks for cmake and Qt6 packages, creates the build directory, runs cmake, then make -j$(nproc).

Manual

mkdir -p build && cd build
cmake ..
make -j$(nproc)

Run

./build/orc-gallery

Install system-wide

cd build && sudo make install
orc-gallery   # from anywhere

Installation puts:

  • Binary → ${CMAKE_INSTALL_BINDIR} (typically /usr/local/bin/)
  • Icons → /share/icons/hicolor/{64x64,128x128,256x256,512x512}/apps/orc-gallery.png
  • Desktop entry → /share/applications/orc-gallery.desktop

Database

Location

Default (XDG-compliant): ~/.local/share/orc-gallery/screenshot_ocr.db

Configurable in Settings dialog; stored in ~/.config/OrcGallery/settings.ini.

Schema

CREATE TABLE 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
);
  • filename — bare filename, uniqueness-constrained (prevents duplicate OCR runs)
  • full_path — absolute path used to load the thumbnail image
  • ocr_text — raw Tesseract output; this is what gets searched
  • file_size, created_date, ocr_date — metadata/audit fields

FTS5 Virtual Table (auto-created if SQLite FTS5 is available)

CREATE VIRTUAL TABLE ocr_fts USING fts5(
    ocr_text,
    content='ocr_results',
    content_rowid='id',
    tokenize='porter unicode61'
);

Three triggers keep ocr_fts in sync with ocr_results (INSERT, UPDATE, DELETE). The Porter stemmer enables morphological matching (e.g. "running" matches "run"). Unicode61 tokenizer handles non-ASCII text.

When FTS5 is unavailable, the app falls back to a LIKE '%...%' query on a standard index on ocr_text.


Application Architecture

Source Files

main.cpp

Entry point. Constructs QApplication, sets app metadata and icon, instantiates MainWindow, calls app.exec().

mainwindow.h/.cpp

Central controller. Inherits QMainWindow.

Responsibilities:

  • Menu bar: File → Open, Settings, Update Database, Quit
  • Search bar (QLineEdit) with a 150 ms typing-inactivity timer (m_typingTimer) — search only fires after the user stops typing
  • Sort/filter toolbar row: sort combo (QComboBox) + From/To date pickers (QDateEdit) + Clear button
  • Animated "Searching…" status bar indicator (m_searchingAnimationTimer)
  • Instantiates and owns DatabaseManager and ImageGallery
  • Reads/writes settings via QSettings (INI at ~/.config/OrcGallery/settings.ini)
  • On search complete: updates status bar with result count
  • On clear search: reloads all images (first page)

Sort options: Newest First (default), Oldest First, Name A-Z, Name Z-A.
Date filter: "Any" (special value = minimum date, treated as no-filter) to any specific date. QSignalBlocker used in handleClearDates to reset both date edits atomically without double-refresh.

Settings keys:

  • databasePath — path to .db file
  • screenshotsDir — directory scanned by OCR script
  • imagePreloadCount — initial gallery page size (default: 20)

imagegallery.h/.cpp

Custom QWidget displaying thumbnails in a responsive grid.

ImageThumbnail (inner QLabel subclass):

  • Fixed 256×256 px display
  • mousePressEvent opens file in default viewer (QDesktopServices::openUrl)
  • Filename label overlaid at bottom with semi-transparent dark background
  • Cursor changes to Qt::PointingHandCursor on hover

ImageGallery:

  • QScrollArea wrapping a QWidget with QGridLayout
  • Column count recalculated on resize: columns = max(1, availableWidth / (256 + spacing))
  • Pagination: default 20 images per page; "Load More Images (X of Y)" button for explicit load
  • loadImages(offset, limit) — populates grid from cached results
  • setSearchResults(paths) — accepts async search output, resets pagination
  • showLoadingIndicator() / hideLoadingIndicator() — spinners during async ops
  • Scroll area horizontal scrollbar always off; vertical scrollbar auto
  • 300 ms periodic resize check to handle dynamic reflows

databasemanager.h/.cpp

All SQLite operations. Thread-safe via QMutex.

Key public API:

Method Description
initialize(path) Opens/validates an existing database
createDatabase(path) Creates schema from scratch
getAllImages(offset, limit, sort, from, to) Paginated list with optional sort order and date range
getImageCount() Total row count (unfiltered)
searchImages(text, offset, limit, sort, from, to) FTS5 or LIKE search, runs async; SortOrder enum + QDate range
cancelSearch() Sets cancellation flag; background thread checks it
initializeFTS() Creates virtual table + triggers if FTS5 available
prepareFTSQuery(text) Converts user text to FTS5 query syntax

SortOrder enum: DateNewest (default), DateOldest, NameAsc, NameDesc.

Static helpers (internal): sortClause(sort) → SQL ORDER BY fragment; dateWhereFragment(from, to) → SQL WHERE fragment (empty string when dates are invalid/unset); bindDateParams(query, from, to) → binds :from/:to; makeSearchKey(text, sort, from, to) → composite string cache key.

Caching:

  • m_searchCache: QMap<QString, QMap<QPair<int,int>, SearchCacheItem>> — outer key is makeSearchKey(...) encoding text+sort+date, inner key is (offset, limit), 5-minute TTL
  • m_allImagesCache: QMap<QString, QList<ImageItem>> — key encodes sort+date+offset+limit
  • m_cachedImageCount: row count integer
  • Cache eviction when m_searchCache exceeds 50 entries
  • All cache access under m_cacheMutex

Threading: each background thread gets its own QSqlDatabase connection (Qt requirement). Connections named by QThread::currentThread() pointer.

settingsdialog.h/.cpp

Modal QDialog for user configuration.

Fields:

  • Database file path — QLineEdit + QPushButton (opens QFileDialog::getSaveFileName)
  • Screenshots directory — QLineEdit + QPushButton (opens QFileDialog::getExistingDirectory)
  • Image preload count — QLineEdit with QIntValidator(1, 100)

On Accept:

  • Creates missing directories (prompts user)
  • Auto-appends /screenshot_ocr.db if a directory path is given for the DB field
  • Saves to QSettings

updatedatabasedialog.h/.cpp

Non-modal QDialog that manages the OCR subprocess.

Behavior:

  • Launches python3 OCR-scripts/ocr_screenshots.py --db <path> --screenshots-dir <dir>
  • Streams stdout/stderr into a QPlainTextEdit log (errors highlighted red)
  • Indeterminate QProgressBar while running
  • Cancel → sends SIGTERM; force-kills after 3 s if process doesn't exit
  • Close button disabled until process finishes
  • Emits updateFinished() on success → MainWindow reloads gallery

GUI Layout

┌───────────────────────────────────────────────────────────┐
│  File                                                      │  ← Menu bar
├───────────────────────────────────────────────────────────┤
│  [ Search OCR text...                           ] [×]      │  ← Search bar
├───────────────────────────────────────────────────────────┤
│  Sort: [Newest First v]  From: [  Any  ] To: [  Any  ]    │  ← Filter bar
│                                         [Clear Dates]      │
├───────────────────────────────────────────────────────────┤
│                                                            │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐  │
│  │          │  │          │  │          │  │          │  │  ← Thumbnails
│  │  image   │  │  image   │  │  image   │  │  image   │  │    256×256 px
│  │          │  │          │  │          │  │          │  │    dynamic grid
│  │ name.png │  │ name.png │  │ name.png │  │ name.png │  │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘  │
│                        …                                   │
│          [ Load More Images (20 of 157) ]                  │  ← Pagination
│                                                            │
├───────────────────────────────────────────────────────────┤
│  Ready                              Showing 20 of 157      │  ← Status bar
└───────────────────────────────────────────────────────────┘

Grid columns reflow automatically:

  • < ~280 px wide: 1 column
  • ~280560 px: 2 columns
  • ~560840 px: 3 columns
  • ~8401120 px: 4 columns
  • 1120 px+: 5+ columns

No horizontal scrollbar ever appears.


OCR Scripts

OCR-scripts/ocr_screenshots.py

Main OCR runner. Scans a screenshots directory, runs Tesseract on each image not already in the database, and writes results to SQLite.

python3 OCR-scripts/ocr_screenshots.py \
    --db ~/.local/share/orc-gallery/screenshot_ocr.db \
    --screenshots-dir ~/Screenshots
  • Handles SIGTERM gracefully (used by the Update Database dialog cancel button)
  • Skips files already present in ocr_results.filename
  • Outputs progress to stdout and errors to stderr

Rofi Integration Scripts

These scripts hook OCR-screenshot workflows into the Rofi application launcher for keyboard-driven use:

Script Purpose
ocr_rofi.sh Take a screenshot, OCR it immediately, copy text to clipboard
direct_rofi_ocr.sh Same, direct variant
simple_rofi_ocr.sh Minimal version
rofi_ocr_search.sh Launch Rofi with searchable OCR text list
ocr_search_rofi.py Python backend for Rofi search
search_ocr.py Standalone CLI search against the database

Settings & Configuration

Settings file: ~/.config/OrcGallery/settings.ini

[General]
databasePath=/home/<user>/.local/share/orc-gallery/screenshot_ocr.db
screenshotsDir=/home/<user>/Screenshots
imagePreloadCount=20

All three keys have fallbacks defined in MainWindow::loadSettings() and SettingsDialog.


Search Flow

  1. User types in search bar.
  2. Each keystroke resets the 500 ms m_typingTimer.
  3. Timer fires → MainWindow::performSearch() called.
  4. DatabaseManager::searchImages() dispatched via QtConcurrent::run().
  5. Inside the thread: check m_searchCache; if miss, build FTS5 or LIKE query and execute.
  6. Result (list of full_path strings) returned to main thread via signal.
  7. ImageGallery::setSearchResults() resets pagination, populates grid with first page.
  8. Status bar updated: "Found N results for 'query'".
  9. User clicks "Load More" → getAllImages(offset, limit) or subsequent search page fetched.
  10. Clearing the search bar → searchImages("") → gallery reverts to full unfiltered list.

Performance Design

Concern Solution
Full-text search speed FTS5 virtual table (≈100× faster than LIKE on large datasets)
UI responsiveness All DB queries on background threads via QtConcurrent
Repeated searches 5-minute LRU cache in DatabaseManager
Large galleries Explicit pagination (default 20, user-controlled via Load More)
Column reflow cost 300 ms debounce on resize events
Typing lag 150 ms inactivity timer before issuing query
Thread-safe DB access Per-thread QSqlDatabase connections + QMutex guards
Missing files Placeholder thumbnail rendered inline; no crash
Cancel stall Non-blocking cancel: flag set immediately, old thread drains in background
COUNT round-trip COUNT(*) OVER() window function folds count into the main SELECT
SQLite I/O WAL journal + 20 MB page cache + memory-mapped I/O via PRAGMA on init
Thumbnail load stall QImage loaded/scaled on QThreadPool, marshalled to main thread via invokeMethod
Overlay widget cost Filename drawn in ImageThumbnail::paintEvent — no child QFrame/QLabel/QHBoxLayout
Disk thumbnail cache Scaled PNGs cached under ~/.cache/orc-gallery/thumbs/ keyed by MD5(path+size); avoids repeated disk I/O and scaling on gallery reload

Linux Desktop Integration

orc-gallery.desktop

[Desktop Entry]
Type=Application
Name=ORC Gallery
Icon=orc-gallery
Exec=orc-gallery
Categories=Graphics;Utility;Viewer;
Keywords=screenshots;ocr;gallery;search;images;

XDG paths used by the application:

  • Config: QStandardPaths::AppConfigLocation~/.config/OrcGallery/
  • Data: QStandardPaths::AppLocalDataLocation~/.local/share/orc-gallery/

Development Notes

  • No .ui files — all widgets constructed programmatically in C++.
  • CMAKE_AUTOMOC ON and CMAKE_AUTORCC ON are set; CMAKE_AUTOUIC OFF.
  • Qt resource system (resources.qrc) embeds icons at compile time; no runtime asset paths needed.
  • QSettings uses IniFormat explicitly so the file is human-readable.
  • The UpdateDatabaseDialog is instantiated fresh each time (not reused) to avoid stale process state.
  • ImageGallery does not use a QAbstractItemModel; it builds ImageThumbnail widgets directly and manages them in a flat list (m_thumbnails).
  • Database connections must not be shared across threads — each QtConcurrent task opens its own named connection and closes it on exit.
  • cancelSearch() is non-blocking: it sets m_searchCancelled and returns immediately. The background thread checks the flag at multiple points and exits early. The destructor sets the flag then calls waitForFinished() for clean shutdown.
  • The watcher's finished signal is not used for emitting results (was causing double-emit). Results are emitted directly from performSearchInBackground once and only if m_currentSearchKey == makeSearchKey(searchText, sort, from, to).
  • ImageThumbnail::paintEvent draws the filename overlay; no child widgets needed. The m_displayName field is pre-truncated in the constructor.
  • Async thumbnail loading: loadThumbnailAsync posts to QThreadPool::globalInstance(). QImage is loaded and .scaled() off the main thread; QPixmap::fromImage() is called inside a Qt::QueuedConnection invokeMethod lambda. A QPointer<ImageThumbnail> guard prevents use-after-free if the gallery clears before the load completes.
  • Thumbnail disk cache stored at QStandardPaths::CacheLocation + "/thumbs/". Cache key = QCryptographicHash::Md5(path + "|" + fileSize) as hex string + .png. Avoids reloading/rescaling images that haven't changed.
  • Sort/date state is held in ImageGallery (m_sortOrder, m_fromDate, m_toDate). MainWindow pushes state to the gallery via setSortOrder()/setDateFilter() before triggering any refresh.
  • Date filter uses QDateEdit::setSpecialValueText(" Any ") with minimumDate() as the sentinel; the gallery receives QDate() (invalid) when the date edit is at minimum, which dateWhereFragment() treats as "no filter".