diff --git a/build.sh b/build.sh index 78deeb2..50730b5 100755 --- a/build.sh +++ b/build.sh @@ -72,6 +72,15 @@ install -m 644 wdx/mediainfo/luajit/*.lua release/wdx/mediainfo/ install -m 644 wdx/translitwdx/translitwdx.lua release/wdx/translitwdx/ install -m 644 wdx/translitwdx/readme.txt release/wdx/translitwdx/ +# csvview +mkdir -p release/wlx/csvview +mkdir -p wlx/csvview/build +(cd wlx/csvview/build && cmake .. && make) +install -m 644 wlx/csvview/build/csvview_qt6.wlx release/wlx/csvview/ +cp -r wlx/csvview/langs release/wlx/csvview/ +install -m 644 wlx/csvview/*.md release/wlx/csvview/ +install -m 644 wlx/csvview/*.png release/wlx/csvview/ + # logview mkdir -p release/wlx/logview mkdir -p wlx/logview/build diff --git a/sdk/wlxplugin.h b/sdk/wlxplugin.h index 8f33fb2..eebce8e 100644 --- a/sdk/wlxplugin.h +++ b/sdk/wlxplugin.h @@ -9,6 +9,7 @@ #define lc_newparams 2 #define lc_selectall 3 #define lc_setpercent 4 +#define lc_focus 5 #define lcp_wraptext 1 #define lcp_fittowindow 2 diff --git a/wlx/csvview/.gitignore b/wlx/csvview/.gitignore new file mode 100644 index 0000000..56af60b --- /dev/null +++ b/wlx/csvview/.gitignore @@ -0,0 +1,2 @@ +build/ +csvview_qt6.wlx diff --git a/wlx/csvview/CMakeLists.txt b/wlx/csvview/CMakeLists.txt new file mode 100644 index 0000000..2150c89 --- /dev/null +++ b/wlx/csvview/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.16) +project(csvview_qt6 CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets PrintSupport) + +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +# Pull in the base library (builds libwlxbase_wlqt.a) +add_subdirectory( + ${CMAKE_CURRENT_SOURCE_DIR}/../wlxbase_wlqt + ${CMAKE_CURRENT_BINARY_DIR}/wlxbase_wlqt +) + +add_library(csvview_qt6 SHARED + src/plugin.cpp +) + +set_target_properties(csvview_qt6 PROPERTIES + PREFIX "" + SUFFIX ".wlx" +) + +target_include_directories(csvview_qt6 PRIVATE + src + ${CMAKE_CURRENT_SOURCE_DIR}/../../sdk +) + +target_link_libraries(csvview_qt6 PRIVATE + wlxbase_wlqt + Qt6::Widgets + Qt6::Gui + Qt6::Core + Qt6::PrintSupport +) diff --git a/wlx/csvview/README.md b/wlx/csvview/README.md new file mode 100644 index 0000000..5342a38 --- /dev/null +++ b/wlx/csvview/README.md @@ -0,0 +1,167 @@ +# CSV/TSV Table Grid Lister Plugin for Double Commander (Linux/Wayland) + +A WLX (Lister) plugin for Double Commander built with Qt6 to visualize, navigate, edit, and export **CSV** and **TSV** files in a clean, interactive spreadsheet-like grid (`QTableWidget`). + +This plugin is a Qt port of the original work by **j2969719**. You can find the original author's repository at [https://github.com/j2969719/doublecmd-plugins](https://github.com/j2969719/doublecmd-plugins). + +--- + +## Screenshots + +### Toolbar and Header Row Toggle +![Toolbar and Header Row Toggle](csvview1.png) + +### Custom Right-Click Context Menu +![Custom Right-Click Context Menu](csvview2.png) + +--- + +## Features + +- **Spreadsheet Grid View**: Displays CSV/TSV data in an organized grid table (`QTableWidget`) with adjustable row and column headers. +- **Double-Quote Parsing**: Correctly handles double-quoted fields containing commas, tabs, or newlines, conforming to standard CSV RFC behaviors. +- **Encoding Auto-Detection**: Automatically detects file character set encodings (such as Cyrillic, UTF-8, Latin, etc.) using an embedded encoding engine. +- **Inline Editing**: Modify cell contents directly inside Lister by double-clicking any cell. +- **Undo / Redo Support**: Full edit history (`Ctrl+Z` / `Ctrl+Y`) for cell editing, row/column operations, reordering, and sorting. +- **Interactive Column Drag-and-Drop**: Rearrange columns by dragging and dropping headers, with full support for undoing and redoing the rearrangement. +- **Column Context Menu**: Right-click headers to copy, paste, insert empty columns, or delete column selections. +- **Row Insertion Options**: Insert empty rows or clipboard content above or below the current selection. +- **Text / Source View Mode**: Switch between the spreadsheet grid and a raw text preview with word wrap. +- **Open Externally**: Launch the file in the system's default external application directly from the toolbar. +- **Smart Focus Management**: Seamlessly yields keyboard and mouse focus to Double Commander when clicking outside the plugin, ensuring file selection changes and arrow-key pane navigation work flawlessly. + +--- + +### Header Row Toggle + +A checkable **Header Row** button is shown in the toolbar (enabled by default). + +- **On (default)**: The first line of the file is treated as column headers. It is displayed in the table header row (not as a data row). Sort arrows appear on the header. Copy operations include the header line. +- **Off**: The first line is treated as a regular data row and appears at index 0. Columns display default numeric labels. Copy operations do not include a header line. + +Toggling this button automatically reloads and re-parses the file. + +--- + +### Copying + +- Press **`Ctrl+C`** to copy the currently selected cells as **TSV** (Tab Separated Values) to the clipboard. +- Right-click to open the context menu and choose **Copy Selection as TSV** or **Copy Selection as CSV**. + +**Header inclusion rules:** +- If **Header Row** is **on**: the column headers of the selected columns are prepended as the first line of the copied text. +- If **Header Row** is **off**: only the selected cell values are copied, with no header line. + +--- + +### Pasting & Row Insertion + +- **Insert Empty Row**: Right-click → **Insert Empty Row Above** or **Insert Empty Row Below** to add a blank row. +- **Insert Clipboard Rows**: Press **`Ctrl+V`** (or right-click → **Insert Row from Clipboard Above** or **Insert Row from Clipboard Below**) to insert rows from the clipboard. +- The clipboard content must be tab-separated (TSV) or match the file's separator, and the **number of columns must match** exactly — otherwise the paste is silently ignored. +- **Header deduplication**: If **Header Row** is **on** and the first line of the clipboard exactly matches the current column headers, that line is automatically skipped — only the data rows below it are inserted. + +--- + +### Deleting Rows + +- Press **`Delete`** (or right-click → **Delete Selected Rows**) to remove all selected rows from the grid. +- Multiple non-contiguous rows can be selected and deleted in one operation. + +--- + +### Undo & Redo + +- Press **`Ctrl+Z`** to undo the last edit, insertion, deletion, sorting, or column move. +- Press **`Ctrl+Y`** (or **`Ctrl+Shift+Z`**) to redo an undone action. +- A **dirty indicator** (`✓` / `●`) on the toolbar shows whether there are unsaved edits in the undo history. + +--- + +### Column Manipulation & Sorting + +- **Drag-and-Drop Reordering**: Drag any column header horizontally to reorder columns in the grid. +- **Sorting**: Click any column header to sort the table data by that column. Click again to toggle between ascending and descending order. +- **Column Context Menu**: Right-click a column header to access column-specific options: + - Copy column selection. + - Paste column selection. + - Insert empty columns. + - Delete selected columns. + +--- + +### Source Text Mode & External Apps + +- **Toggle Text Mode**: Click the **Text Mode** button to view the raw, unparsed text content of the file. Toggle **Word Wrap** to wrap lines. +- **Open Externally**: Click the **Open Externally** button to open the file in the default system editor/application. + +--- + +### Save & Reload + +- **`Ctrl+S`** or click **Save** to save all changes back to the original file. Works correctly whether or not a cell is being edited — if a cell editor is active it is committed first; otherwise the file is saved directly without disturbing Double Commander's focus. +- **Save As...** to export to a different file path or format. +- **Reload** to discard unsaved changes and re-read the file from disk. + +--- + +### Find & Replace + +Press **`Ctrl+F`** (to find), **`Ctrl+R`** (to replace), or click the **`🔍 Find/Replace`** toolbar button to open the inline Find/Replace panel at the bottom of the table grid view. Hitting **`Escape`** closes it. + +* **Search Options**: + - **Match Case**: Performs a case-sensitive search. + - **Match Entire Cell**: Only matches cells that are an exact match for the query. + - **Regular Expression**: Uses standard regex patterns for searching and replacing. +* **Scope Options**: + - **All Cells**: Searches and replaces across the entire spreadsheet grid. + - **Selected Cells**: Limits the search/replace to the currently highlighted cells. + - **Current Column**: Restricts operations to the column of the active cell. + - **Current Row**: Restricts operations to the row of the active cell. +* **Action Buttons**: + - **Find Next** (or hitting **`Enter`** in the Find input): Highlights and scrolls to the next matching cell. + - **Find Prev**: Highlights and scrolls to the previous matching cell. + - **Replace** (or hitting **`Enter`** in the Replace input): Replaces the text of the current match and automatically advances to the next. + - **Replace All**: Evaluates all cells inside the selected scope and applies replacements atomically in a single undo macro. +* **Automatic Quoting Safeguard**: + - If a replacement introduces the separator character (e.g. inserting `,` in a CSV or `\t` in a TSV), the plugin automatically wraps the cell value in double quotes and escapes existing quotes correctly in the raw text mode and on file save. This metadata change is fully integrated into the Undo/Redo stack. + +--- + +### Classic Lister Search + +Press **`F7`** (or use Double Commander's built-in search) to search for substrings across all cells using the classic dialog. + +--- + +### TSV Support + +Works with both `.csv` and `.tsv` files. The separator is auto-detected from content (trying `,`, `;`, `\t` in order). If auto-detection is ambiguous, the file extension is used as a fallback: `.tsv` → tab, `.csv` → comma. + +--- + +## Installation + +1. Switch to the `csvview` branch and run `./build.sh` to compile the plugin. +2. The binary `csvview_qt6.wlx` will be built under `release/wlx/csvview/`. +3. In Double Commander, open **Options** → **Plugins** → **WLX**. +4. Click **Add** and select `/path/to/csvview_qt6.wlx`. +5. Ensure the detect string is configured as: + ``` + (EXT="CSV" | EXT="TSV") & SIZE<30000000 + ``` + +--- + +## Configuration + +The plugin configuration is stored in `j2969719.ini` inside the Double Commander settings directory. Edit settings under the `[csvview_qt6.wlx]` section: + +| Key | Type | Description | +|---|---|---| +| `enca` | bool | Enable Enca character encoding auto-detection | +| `resize_columns` | bool | Auto-resize column widths to fit contents | +| `enca_readall` | bool | Read the entire file for encoding detection (slower but more accurate) | +| `doublequoted` | bool | Handle RFC-compliant double-quoted CSV fields | +| `draw_grid` | bool | Draw grid lines between cells | +| `enca_lang` | string | Locale hint for Enca (e.g. `ru`, `cs`) | diff --git a/wlx/csvview/csvview1.png b/wlx/csvview/csvview1.png new file mode 100644 index 0000000..28d3c97 Binary files /dev/null and b/wlx/csvview/csvview1.png differ diff --git a/wlx/csvview/csvview2.png b/wlx/csvview/csvview2.png new file mode 100644 index 0000000..09ec923 Binary files /dev/null and b/wlx/csvview/csvview2.png differ diff --git a/wlx/csvview/langs/ru/LC_MESSAGES/plugins.mo b/wlx/csvview/langs/ru/LC_MESSAGES/plugins.mo new file mode 100644 index 0000000..e3ea054 Binary files /dev/null and b/wlx/csvview/langs/ru/LC_MESSAGES/plugins.mo differ diff --git a/wlx/csvview/langs/ru/LC_MESSAGES/plugins.po b/wlx/csvview/langs/ru/LC_MESSAGES/plugins.po new file mode 100644 index 0000000..80889d4 --- /dev/null +++ b/wlx/csvview/langs/ru/LC_MESSAGES/plugins.po @@ -0,0 +1,565 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-02-07 23:08+0300\n" +"PO-Revision-Date: 2026-02-07 23:13+0300\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" +"X-Generator: Poedit 3.8\n" + +#: ../wlx/libarchive_cat_crap/src/plugin.c:65 +#: ../wlx/libarchive_cat_qt_crap/src/plugin.cpp:56 +#, c-format +msgid "libarchive: failed to read %s" +msgstr "libarchive: не удалось прочитать %s" + +#: ../wlx/libarchive_cat_crap/src/plugin.c:196 +#: ../wlx/libarchive_cat_qt_crap/src/plugin.cpp:147 +#: ../wlx/md4c_qt/src/plugin.cpp:97 ../wlx/fileinfo_qt/src/plugin.cpp:97 +#: ../wlx/htmlconv_qt_crap/src/plugin.cpp:118 +#: ../wlx/wlxwebkit/src/wlxwebkit.c:128 +#: ../wlx/yet_another_vte_plugin/src/plugin.c:288 +#: ../wlx/gtksourceview/src/gtksourceview.c:720 +#: ../wlx/sqlview_qt/src/plugin.cpp:273 +#: ../wlx/syntax-highlighting_qt/src/plugin.cpp:153 +#: ../wlx/md4c_webkit_qt/src/plugin.cpp:90 ../wlx/csvview_qt/src/plugin.cpp:307 +#: ../wlx/hx_qt_crap/src/plugin.cpp:81 ../wlx/fileinfo/src/fileinfo.c:403 +#: ../wlx/jsonview_qt/src/plugin.cpp:262 +#: ../wlx/mimescript/src/mimescriptwlx.c:309 +#: ../wlx/htmlview_qt_crap/src/plugin.cpp:55 +#: ../wlx/wlxwebkit_qt/src/wlxwebkit.cpp:167 +#: ../wlx/wlxwebkit_qt_crap/src/wlxwebkit.cpp:111 +#, c-format +msgid "\"%s\" not found!" +msgstr "\"%s\" не найден!" + +#: ../wlx/imagemagick/src/wlximagemagick.c:215 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:371 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:161 +#: ../wlx/gtkimgview/src/gtkimgview.c:222 +msgid "Zoom In" +msgstr "Увеличить" + +#: ../wlx/imagemagick/src/wlximagemagick.c:220 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:376 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:172 +#: ../wlx/gtkimgview/src/gtkimgview.c:227 +msgid "Zoom Out" +msgstr "Уменьшить" + +#: ../wlx/imagemagick/src/wlximagemagick.c:225 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:381 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:183 +#: ../wlx/gtkimgview/src/gtkimgview.c:232 +msgid "Original Size" +msgstr "Исходный размер" + +#: ../wlx/imagemagick/src/wlximagemagick.c:230 +#: ../wlx/wlxpview/src/wlxPView.c:469 ../wlx/wlxpview/src/wlxPView.c:470 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:386 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:196 +#: ../wlx/gtkimgview/src/gtkimgview.c:237 +msgid "Fit" +msgstr "Вместить" + +#: ../wlx/imagemagick/src/wlximagemagick.c:237 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:393 +#: ../wlx/gtkimgview/src/gtkimgview.c:244 +msgid "Copy to Clipboard" +msgstr "Копировать в буфер" + +#: ../wlx/imagemagick/src/wlximagemagick.c:240 +#: ../wlx/imagemagick/src/wlximagemagick.c:243 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:396 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:399 +#: ../wlx/gtkimgview/src/gtkimgview.c:247 +#: ../wlx/gtkimgview/src/gtkimgview.c:250 +msgid "Rotate" +msgstr "Повернуть" + +#: ../wlx/imagemagick/src/wlximagemagick.c:246 +#: ../wlx/imagemagick/src/wlximagemagick.c:249 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:402 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:405 +#: ../wlx/gtkimgview/src/gtkimgview.c:253 +#: ../wlx/gtkimgview/src/gtkimgview.c:256 +msgid "Rotate Clockwise" +msgstr "Повернуть по часовой стрелке" + +#: ../wlx/imagemagick/src/wlximagemagick.c:252 +#: ../wlx/imagemagick/src/wlximagemagick.c:255 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:408 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:411 +#: ../wlx/gtkimgview/src/gtkimgview.c:259 +#: ../wlx/gtkimgview/src/gtkimgview.c:262 +msgid "Flip Horizontally" +msgstr "Отразить по горизонтали" + +#: ../wlx/imagemagick/src/wlximagemagick.c:258 +#: ../wlx/imagemagick/src/wlximagemagick.c:261 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:414 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:417 +#: ../wlx/gtkimgview/src/gtkimgview.c:265 +#: ../wlx/gtkimgview/src/gtkimgview.c:268 +msgid "Flip Vertically" +msgstr "Отразить по вертикали" + +#: ../wlx/dirsize_crap/src/plugin.c:245 +msgid "Name" +msgstr "Название" + +#: ../wlx/dirsize_crap/src/plugin.c:258 +msgid "Size" +msgstr "Размер" + +#: ../wlx/wlxpview/src/wlxPView.c:139 +msgid "Scale ~x" +msgstr "Масштаб ~x" + +#: ../wlx/wlxpview/src/wlxPView.c:183 ../wlx/wlxpview/src/wlxPView.c:438 +#: ../wlx/wlxpview/src/wlxPView.c:439 ../wlx/qtpdfview_qt/src/plugin.cpp:140 +msgid "Go to..." +msgstr "Перейти..." + +#: ../wlx/wlxpview/src/wlxPView.c:193 ../wlx/qtpdfview_qt/src/plugin.cpp:148 +msgid "Page number to go to:" +msgstr "Страница на которую перейти:" + +#: ../wlx/wlxpview/src/wlxPView.c:296 ../wlx/wlxpview/src/wlxPView.c:477 +#: ../wlx/wlxpview/src/wlxPView.c:478 +msgid "Text" +msgstr "Текст" + +#: ../wlx/wlxpview/src/wlxPView.c:324 ../wlx/wlxpview/src/wlxPView.c:483 +#: ../wlx/wlxpview/src/wlxPView.c:484 ../wlx/qtpdfview_qt/src/plugin.cpp:222 +msgid "Info" +msgstr "Информация" + +#: ../wlx/wlxpview/src/wlxPView.c:328 +#, c-format +msgid "" +"Title: %s\n" +"Author: %s\n" +"Subject: %s\n" +"Creator: %s\n" +"Producer: %s\n" +"Keywords: %s\n" +"Version: %s" +msgstr "" +"Название: %s\n" +"Автор: %s\n" +"Тема: %s\n" +"Создатель: %s\n" +"Сегнерировано: %s\n" +"Ключевые слова: %s\n" +"Версия: %s" + +#: ../wlx/wlxpview/src/wlxPView.c:346 +msgid "metadata not found" +msgstr "Метаданные не найдены" + +#: ../wlx/wlxpview/src/wlxPView.c:414 ../wlx/wlxpview/src/wlxPView.c:415 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:60 +msgid "First page" +msgstr "Первая страница" + +#: ../wlx/wlxpview/src/wlxPView.c:420 ../wlx/wlxpview/src/wlxPView.c:421 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:72 +msgid "Previous page" +msgstr "Предыдущая страница" + +#: ../wlx/wlxpview/src/wlxPView.c:426 ../wlx/wlxpview/src/wlxPView.c:427 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:87 +msgid "Next page" +msgstr "Следующая страница" + +#: ../wlx/wlxpview/src/wlxPView.c:432 ../wlx/wlxpview/src/wlxPView.c:433 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:102 +msgid "Last page" +msgstr "Последняя страница" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:208 +msgid "Page Mode" +msgstr "Режим страницы" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:228 +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:54 +msgid "Author" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:229 +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:50 +msgid "Title" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:230 +msgid "Subject" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:231 +msgid "Producer" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:232 +msgid "Creator" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:233 +msgid "Keywords" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:234 +msgid "Creation Date" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:235 +msgid "Modification Date" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:255 +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:468 +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:524 +msgid "no suitable info available" +msgstr "" + +#: ../wlx/sqlview_gtk2/src/plugin.c:124 ../wlx/sqlview_gtk2/src/plugin.c:222 +#: ../wlx/sqlview_qt/src/plugin.cpp:97 +msgid "Query" +msgstr "" + +#: ../wlx/sqlview_gtk2/src/plugin.c:132 ../wlx/sqlview_qt/src/plugin.cpp:131 +msgid "Please enter a new query and press OK to execute it" +msgstr "" + +#: ../wlx/sqlview_gtk2/src/plugin.c:227 ../wlx/sqlview_qt/src/plugin.cpp:89 +msgid "Table:" +msgstr "" + +#: ../wlx/gtksourceview/src/gtksourceview.c:52 +msgid "Failed to load file" +msgstr "Не удалось открыть файл" + +#: ../wlx/gtksourceview/src/gtksourceview.c:53 +#: ../wlx/gtksourceview/src/gtksourceview.c:360 +#: ../wlx/gtksourceview/src/gtksourceview.c:563 +msgid "Default" +msgstr "По умолчанию" + +#: ../wlx/gtksourceview/src/gtksourceview.c:54 +#: ../wlx/gtksourceview/src/gtksourceview.c:380 +msgid "Encoding:" +msgstr "Кодировка:" + +#: ../wlx/gtksourceview/src/gtksourceview.c:200 +#: ../wlx/gtksourceview/src/gtksourceview.c:590 +msgid "Options" +msgstr "Настройки" + +#: ../wlx/gtksourceview/src/gtksourceview.c:207 +msgid "Font" +msgstr "Шрифт" + +#: ../wlx/gtksourceview/src/gtksourceview.c:215 +msgid "Style" +msgstr "Тема" + +#: ../wlx/gtksourceview/src/gtksourceview.c:232 +msgid "Blank space" +msgstr "Отсупы" + +#: ../wlx/gtksourceview/src/gtksourceview.c:238 +msgid "Above paragraphs" +msgstr "Над текстом" + +#: ../wlx/gtksourceview/src/gtksourceview.c:239 +msgid "Below paragraphs" +msgstr "Под текстом" + +#: ../wlx/gtksourceview/src/gtksourceview.c:240 +msgid "Tab width" +msgstr "Ширина табуляции" + +#: ../wlx/gtksourceview/src/gtksourceview.c:260 +msgid "Enca Lang" +msgstr "Локаль Enca" + +#: ../wlx/gtksourceview/src/gtksourceview.c:336 +msgid "Language:" +msgstr "Язык:" + +#: ../wlx/gtksourceview/src/gtksourceview.c:356 +msgid "file seems empty" +msgstr "походу файл пустой" + +#: ../wlx/gtksourceview/src/gtksourceview.c:573 +msgid "Custom encoding" +msgstr "Другая кодировка" + +#: ../wlx/gtksourceview/src/gtksourceview.c:597 +msgid "Draw Spaces" +msgstr "Пробелы" + +#: ../wlx/gtksourceview/src/gtksourceview.c:598 +msgid "Text Cursor" +msgstr "Курсор" + +#: ../wlx/gtksourceview/src/gtksourceview.c:599 +msgid "Line Numbers" +msgstr "Номера строк" + +#: ../wlx/gtksourceview/src/gtksourceview.c:600 +msgid "Highlight Line" +msgstr "Подсветка строки" + +#: ../wlx/gtksourceview/src/gtksourceview.c:601 +msgid "Wrap Line" +msgstr "Разрывы" + +#: ../wlx/sqlview_qt/src/plugin.cpp:25 +msgid "base not valid!" +msgstr "" + +#: ../wlx/sqlview_qt/src/plugin.cpp:143 +msgid "Failed to fetch list of tables. Maybe DB is locked?" +msgstr "" + +#: ../wlx/gtkimgview/src/gtkimgview.c:273 +msgid "Play Animation" +msgstr "Воспроизвести анимацию" + +#: ../wlx/gtkimgview/src/gtkimgview.c:278 +msgid "Stop Animation" +msgstr "Остановить анимацию" + +#: ../wlx/jsonview_gtk2/src/plugin.c:64 ../wlx/jsonview_qt/src/plugin.cpp:57 +#: ../wlx/jsonview_qt/src/plugin.cpp:160 +msgid "Object" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:70 ../wlx/jsonview_qt/src/plugin.cpp:64 +#: ../wlx/jsonview_qt/src/plugin.cpp:165 +msgid "Array" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:78 ../wlx/jsonview_qt/src/plugin.cpp:71 +msgid "String" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:85 ../wlx/jsonview_qt/src/plugin.cpp:83 +msgid "Integer" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:92 ../wlx/jsonview_qt/src/plugin.cpp:88 +msgid "Double" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:96 ../wlx/jsonview_qt/src/plugin.cpp:101 +msgid "True" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:96 ../wlx/jsonview_qt/src/plugin.cpp:103 +msgid "False" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:99 ../wlx/jsonview_qt/src/plugin.cpp:98 +msgid "Boolean" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:102 ../wlx/jsonview_qt/src/plugin.cpp:116 +msgid "Undefined" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:107 ../wlx/jsonview_qt/src/plugin.cpp:110 +msgid "Null" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:164 ../wlx/jsonview_gtk2/src/plugin.c:261 +#: ../wlx/jsonview_qt/src/plugin.cpp:156 +msgid "Root" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:183 ../wlx/jsonview_qt/src/plugin.cpp:183 +msgid "Node" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:195 ../wlx/jsonview_qt/src/plugin.cpp:183 +msgid "Value" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:207 ../wlx/jsonview_qt/src/plugin.cpp:183 +msgid "Type" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:49 +msgid "Artist" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:51 +msgid "Album" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:52 +msgid "Track Number" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:53 +msgid "Album Artist" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:55 +msgid "Composer" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:56 +msgid "Lead Performer" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:57 +msgid "Publisher" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:58 +msgid "Genre" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:59 +msgid "Duration" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:60 +msgid "Description" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:61 +msgid "Copyright" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:62 +msgid "Comment" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:63 +msgid "Resolution" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:64 +msgid "Media Type" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:65 +msgid "Video Codec" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:66 +msgid "Video FrameRate" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:67 +msgid "Video BitRate" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:68 +msgid "Audio Codec" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:69 +msgid "Audio BitRate" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:70 +msgid "Date" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:448 +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:508 +#, c-format +msgid "%'d bps" +msgstr "" + +#: ../wlx/fontview_qt/src/plugin.cpp:16 +msgid "the quick brown fox jumps over the lazy dog" +msgstr "съешь ещё этих мягких французских булок да выпей чаю, belzebub" + +#: ../wlx/fontview_qt/src/plugin.cpp:36 +msgid "Bold" +msgstr "Жирный" + +#: ../wlx/fontview_qt/src/plugin.cpp:37 +msgid "Italic" +msgstr "Курсив" + +#: ../wdx/fewfiles/src/plugin.c:44 +#, c-format +msgid "Permission denied" +msgstr "Доступ запрещен" + +#: ../wdx/fewfiles/src/plugin.c:74 +#, c-format +msgid "Empty directory" +msgstr "Пустой каталог" + +#: ../wfx/trash_crap/src/plugin.c:120 +#, c-format +msgid "Restore %s from trash?" +msgstr "Восстановить %s из корзины?" + +#: ../wfx/trash_crap/src/plugin.c:135 +msgid "Already exists, overwrite?" +msgstr "Уже существует. Перезаписать?" + +#: ../wfx/trash_crap/src/plugin.c:153 ../wfx/trash_crap/src/plugin.c:185 +msgid "Failed to get information about the removed object." +msgstr "Не удалось получить информацию об удаленном объекте." + +#: ../wfx/trash_crap/src/plugin.c:168 +msgid "Original Path:" +msgstr "Оригинальный путь:" + +#: ../wfx/trash_crap/src/plugin.c:169 +msgid "Deletion Date:" +msgstr "Дата удаления:" + +#: ../wfx/trash_crap/src/plugin.c:177 ../wfx/trash_crap/src/plugin.c:178 +#: ../wfx/trash_crap/src/plugin.c:179 ../wfx/trash_crap/src/plugin.c:180 +#: ../wfx/trash_crap/src/plugin.c:181 ../wfx/trash_crap/src/plugin.c:182 +#: ../wfx/trash_crap/src/plugin.c:183 ../wfx/trash_crap/src/plugin.c:184 +msgid "Unknown" +msgstr "Неизвесто" + +#: ../wfx/trash_crap/src/plugin.c:560 +msgid "Path" +msgstr "Путь" + +#: ../wfx/trash_crap/src/plugin.c:568 +msgid "Trash (WFX)" +msgstr "Корзина (WFX)" + +#: ../dsx/git_untracked/src/plugin.c:57 ../dsx/lslocks/src/plugin.c:58 +#: ../dsx/git_ignored/src/plugin.c:57 ../dsx/git_modified/src/plugin.c:58 +msgid "failed to launch command" +msgstr "не удалось выполнить команду" + +#: ../dsx/git_untracked/src/plugin.c:60 +#: ../dsx/tracker_textsearch/src/plugin.c:84 +#: ../dsx/recollq_crap/src/plugin.c:108 ../dsx/locate_crap/src/plugin.c:134 +#: ../dsx/gtkrecent/src/plugin.c:56 ../dsx/lslocks/src/plugin.c:61 +#: ../dsx/tracker3_crap/src/plugin.c:104 ../dsx/git_ignored/src/plugin.c:60 +#: ../dsx/git_modified/src/plugin.c:61 +msgid "not found" +msgstr "не найдено" + +#: ../dsx/recollq_crap/src/plugin.c:79 ../dsx/tracker3_crap/src/plugin.c:78 +msgid "the search text was not specified" +msgstr "искомый текст не указан" diff --git a/wlx/csvview/src/Makefile b/wlx/csvview/src/Makefile new file mode 100644 index 0000000..f65023f --- /dev/null +++ b/wlx/csvview/src/Makefile @@ -0,0 +1,42 @@ +CXX = g++ +CXXFLAGS = -shared -fPIC -Wl,--no-as-needed + +ENCA_VERSION = 1.19 +ENCA_DIR = enca-$(ENCA_VERSION) +ENCA_TAR = $(ENCA_DIR).tar.gz +ENCA_LIB = $(ENCA_DIR)/lib/.libs/libenca.a + +$(ENCA_LIB): + curl -sL https://dl.cihar.com/enca/$(ENCA_TAR) -o $(ENCA_TAR) + tar xzf $(ENCA_TAR) + cd $(ENCA_DIR) && ./configure --disable-shared --enable-static --with-pic && $(MAKE) + +libs := $(ENCA_LIB) +includes := -I../../../sdk -I$(ENCA_DIR)/lib + +qt5_libs := `pkg-config --cflags --libs Qt5Widgets glib-2.0` +qt6_libs := `pkg-config --cflags --libs Qt6Widgets Qt6Core Qt6Gui Qt6PrintSupport glib-2.0` + +plugdir := $(shell basename '$(realpath ..)') +plugtype := $(shell basename '$(realpath ../..)') +plugname := $(plugdir).$(plugtype) +plugdescr := `grep '$(plugtype)/$(plugdir))' ../../../../plugins.md -1 | tail -1 | sed 's/[[]//' | sed 's/[]][\(][^\)]\+.//' | sed 's/\s[\(].\+[\)]//'` +plugfiles := $(filter-out $(wildcard ../*.$(plugtype)), $(wildcard ../*)) + +detectstring := + +all: qt6 + +qt6: $(ENCA_LIB) + $(CXX) $(CXXFLAGS) -o '../$(plugdir)_$@.$(plugtype)' plugin.cpp $(libs) $($@_libs) $(includes) -D'PLUGNAME="'$(plugdir)_$@.$(plugtype)'"' -D'DETECT_STRING="$(detectstring)"' -D'PLUGTARGET="$@"' || echo '$(plugdir)_$@.$(plugtype)' >> ../../../dist/.build_fail.lst + +dist: qt6 + test -f '../$(plugdir)_qt6.$(plugtype)' && \ + echo -e "[plugininstall]\ndescription=$(plugdescr)\ntype=$(plugtype)\nfile=$(plugdir)_qt6.$(plugtype)\ndefaultdir=$(plugdir)" > ../pluginst.inf && \ + tar --exclude=../src -h -cvzf '../../../dist/$(plugtype)_$(plugdir)_qt6_$(shell date +%y.%m.%d).tar.gz' ../pluginst.inf '../$(plugdir)_qt6.$(plugtype)' $(plugfiles) && \ + rm ../pluginst.inf || echo $(plugdir)_qt6.$(plugtype) >> ../../../dist/.missing.log + +clean: + $(RM) $(wildcard ../*.$(plugtype)) + $(RM) -r $(ENCA_DIR) + $(RM) $(ENCA_TAR) diff --git a/wlx/csvview/src/plugin.cpp b/wlx/csvview/src/plugin.cpp new file mode 100644 index 0000000..900f824 --- /dev/null +++ b/wlx/csvview/src/plugin.cpp @@ -0,0 +1,1289 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "wlxplugin.h" + +// Pull in base library headers +#include +#include +#include +#include +#include +#include + +#define _(STRING) gettext(STRING) +#define GETTEXT_PACKAGE "plugins" +#define PLUGNAME "csvview_qt6.wlx" + +bool gQuoted = true; +bool gGrid = true; +bool gResize = true; +bool gEnca = true; +bool gReadAll = false; +QString gLang = "ru"; + +static const int WasQuotedRole = Qt::UserRole + 2; + +using namespace QtWlPlugin; + +QStringList parse_line(const QByteArray &line, const char *encoding, char separator, QList *wasQuotedOut = nullptr) +{ + QStringList list; + QByteArray utf8Line; + + if (encoding[0] != '\0') + { + utf8Line = EncodingUtils::toUtf8(line, QString::fromLatin1(encoding)); + } + else + utf8Line = line; + + QString text = QString::fromUtf8(utf8Line); + + if (text.endsWith("\r\n")) + text.chop(2); + else if (text.endsWith("\n")) + text.chop(1); + + QStringList rawlist = text.split(QLatin1Char(separator)); + QString temp; + + for (int c = 0; c < rawlist.size(); c++) + { + if (gQuoted) + { + if (rawlist.at(c).startsWith('"') && !rawlist.at(c).endsWith('"')) + { + temp = rawlist.at(c); + + if (c < rawlist.size() - 1) + { + for (int x = c + 1; x < rawlist.size(); x++) + { + const QString nitm = rawlist.at(x); + + if (!nitm.isEmpty() && nitm.back() == '"') + { + temp = rawlist.mid(c, x - c + 1).join(QLatin1Char(separator)).remove(0, 1).remove(-1, 1); + + if (temp.count(QLatin1Char('"')) % 2 == 0) + { + c = x; + break; + } + } + } + } + + list.append(temp); + if (wasQuotedOut) wasQuotedOut->append(true); + } + else + { + QString val = rawlist.at(c).trimmed(); + bool quoted = (val.size() >= 2 && val.startsWith('"') && val.endsWith('"')); + if (quoted) + val = val.mid(1, val.size() - 2); + list.append(val); + if (wasQuotedOut) wasQuotedOut->append(quoted); + } + + list.last().replace("\"\"", "\""); + } + } + + return list; +} + +class EditCellCommand : public QUndoCommand { +public: + EditCellCommand(QTableWidget *view, int row, int col, const QString &oldText, const QString &newText, bool oldQuoted, bool newQuoted, QUndoCommand *parent = nullptr) + : QUndoCommand(parent), m_view(view), m_row(row), m_col(col), m_oldText(oldText), m_newText(newText), m_oldQuoted(oldQuoted), m_newQuoted(newQuoted) { + setText(QString("Edit cell (%1, %2)").arg(row).arg(col)); + } + void undo() override { + m_view->blockSignals(true); + if (QTableWidgetItem *item = m_view->item(m_row, m_col)) { + item->setText(m_oldText); + item->setData(WasQuotedRole, m_oldQuoted); + } + m_view->blockSignals(false); + } + void redo() override { + m_view->blockSignals(true); + if (QTableWidgetItem *item = m_view->item(m_row, m_col)) { + item->setText(m_newText); + item->setData(WasQuotedRole, m_newQuoted); + } + m_view->blockSignals(false); + } +private: + QTableWidget *m_view; + int m_row, m_col; + QString m_oldText, m_newText; + bool m_oldQuoted, m_newQuoted; +}; + +class CsvViewerWidget : public QWidget +{ +public: + explicit CsvViewerWidget(QWidget *parent = nullptr); + ~CsvViewerWidget(); + + bool loadFile(const QString& filePath); + void saveFile(const QString& filePath); + + QTableWidget* view() const { return m_view; } + EditableGridWidget* grid() const { return m_grid; } + FocusManager* focusManager() const { return m_fm; } + + void setActive(bool active); + +private slots: + void onUndoStackCleanChanged(bool clean); + +private: + void setupToolbar(); + void setupFindReplace(); + void onSave(); + void onSaveAs(); + void onReload(); + void onToggleTextMode(bool checked); + void onToggleWordWrap(bool checked); + void updateTextView(); + + void doFind(bool forward); + void doReplace(); + void doReplaceAll(); + bool cellMatches(int row, int col, const QString &query, bool matchCase, bool entireCell, bool regexFlag); + + QTableWidget *m_view; + EditableGridWidget *m_grid; + FocusManager *m_fm; + PluginToolBar *m_toolbar; + ScopedFindReplacePanel *m_findReplace; + + QString m_currentFile; + char m_separator; + char m_encoding[256]; + bool m_firstLineAsHeader; + + QLabel *m_dirtyIndicator; + + QStackedWidget *m_stackedWidget; + QTextBrowser *m_textBrowser; + QAction *m_actFindReplace; + QAction *m_actTextMode; + QAction *m_actWordWrap; + bool m_isActive; + bool m_isProgrammaticChange; +}; + +CsvViewerWidget::CsvViewerWidget(QWidget *parent) + : QWidget(parent) + , m_view(nullptr) + , m_grid(nullptr) + , m_fm(nullptr) + , m_toolbar(nullptr) + , m_findReplace(nullptr) + , m_separator(',') + , m_firstLineAsHeader(true) + , m_dirtyIndicator(nullptr) + , m_stackedWidget(nullptr) + , m_textBrowser(nullptr) + , m_actFindReplace(nullptr) + , m_actTextMode(nullptr) + , m_actWordWrap(nullptr) + , m_isActive(false) + , m_isProgrammaticChange(false) +{ + memset(m_encoding, 0, sizeof(m_encoding)); + + setFocusPolicy(Qt::NoFocus); + + QVBoxLayout *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + + m_view = new QTableWidget(this); + m_view->setFocusPolicy(Qt::ClickFocus); + + m_fm = new FocusManager(this, m_view, this); + + // Create the EditableGridWidget around m_view + m_grid = new EditableGridWidget(m_view, GridMode::MemoryDocument, m_fm, this); + m_fm->setFocusProxy(m_grid->view()); + + setupToolbar(); + layout->addWidget(m_toolbar); + + m_stackedWidget = new QStackedWidget(this); + m_stackedWidget->addWidget(m_grid); + + m_textBrowser = new QTextBrowser(this); + m_textBrowser->setOpenLinks(false); + m_textBrowser->setReadOnly(true); + m_stackedWidget->addWidget(m_textBrowser); + + layout->addWidget(m_stackedWidget); + + setupFindReplace(); + layout->addWidget(m_findReplace); + + connect(m_grid->undoStack(), &QUndoStack::cleanChanged, this, &CsvViewerWidget::onUndoStackCleanChanged); +} + +CsvViewerWidget::~CsvViewerWidget() +{ + // Neutralize FocusManager FIRST — it has a global event filter on qApp + // and a connection to qApp::focusChanged. If these fire during child + // destruction (when focus shifts as widgets die), they access + // half-destroyed objects and crash. + if (m_fm) { + if (qApp) { + qApp->removeEventFilter(m_fm); + disconnect(qApp, nullptr, m_fm, nullptr); + } + } + + // Block signals on the view and undo stack so that Qt's arbitrary + // child destruction order cannot trigger dataChanged/cleanChanged + // callbacks on dead objects. This mirrors the working original csvview. + if (m_view) + m_view->blockSignals(true); + if (m_grid && m_grid->undoStack()) + m_grid->undoStack()->blockSignals(true); +} + +void CsvViewerWidget::setupToolbar() +{ + m_toolbar = new PluginToolBar(m_fm, this); + + m_dirtyIndicator = new QLabel("✓", this); + m_dirtyIndicator->setContentsMargins(4, 0, 4, 0); + m_toolbar->addWidget(m_dirtyIndicator); + + // Save + QAction *actSave = m_toolbar->addToolAction( + "Save", + QKeySequence(Qt::CTRL | Qt::Key_S), + FocusManager::Always, + "document-save"); + connect(actSave, &QAction::triggered, this, &CsvViewerWidget::onSave); + + // Save As + QAction *actSaveAs = m_toolbar->addToolAction( + "Save As...", + QKeySequence(), + 0, + "document-save-as"); + connect(actSaveAs, &QAction::triggered, this, &CsvViewerWidget::onSaveAs); + + // Undo + QAction *actUndo = m_toolbar->addToolAction( + "Undo", + QKeySequence::Undo, + FocusManager::Always, + "edit-undo"); + connect(actUndo, &QAction::triggered, m_grid->undoStack(), &QUndoStack::undo); + connect(m_grid->undoStack(), &QUndoStack::canUndoChanged, actUndo, &QAction::setEnabled); + actUndo->setEnabled(false); + + // Redo + QAction *actRedo = m_toolbar->addToolAction( + "Redo", + QKeySequence::Redo, + FocusManager::Always, + "edit-redo"); + actRedo->setShortcuts({QKeySequence::Redo, QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Z)}); + connect(actRedo, &QAction::triggered, m_grid->undoStack(), &QUndoStack::redo); + connect(m_grid->undoStack(), &QUndoStack::canRedoChanged, actRedo, &QAction::setEnabled); + actRedo->setEnabled(false); + + // Print + QAction *actPrint = m_toolbar->addToolAction( + "Print", + QKeySequence::Print, + FocusManager::Always, + "document-print"); + connect(actPrint, &QAction::triggered, this, [this]() { + QPrinter printer(QPrinter::HighResolution); + QPrintDialog dlg(&printer, this); + if (dlg.exec() != QDialog::Accepted) return; + + int rows = m_view->rowCount(); + int cols = m_view->columnCount(); + QString html = ""; + if (m_firstLineAsHeader) { + html += ""; + for (int vc = 0; vc < cols; ++vc) { + int c = m_view->horizontalHeader()->logicalIndex(vc); + QString text = m_view->horizontalHeaderItem(c) ? m_view->horizontalHeaderItem(c)->text().toHtmlEscaped() : ""; + html += QString("").arg(text); + } + html += ""; + } + for (int vr = 0; vr < rows; ++vr) { + int r = m_view->verticalHeader()->logicalIndex(vr); + html += ""; + for (int vc = 0; vc < cols; ++vc) { + int c = m_view->horizontalHeader()->logicalIndex(vc); + QString text = m_view->item(r, c) ? m_view->item(r, c)->text().toHtmlEscaped() : ""; + html += QString("").arg(text); + } + html += ""; + } + html += "
%1
%1
"; + + QTextDocument doc; + doc.setHtml(html); + doc.print(&printer); + }); + + // Reload + QAction *actReload = m_toolbar->addToolAction( + "Reload", + QKeySequence(Qt::Key_F5), + FocusManager::Always, + "view-refresh"); + connect(actReload, &QAction::triggered, this, &CsvViewerWidget::onReload); + + // Header Row + QAction *actHeader = m_toolbar->addToolAction( + "Header Row", + QKeySequence(), + 0, + "format-justify-fill"); + actHeader->setCheckable(true); + actHeader->setChecked(true); + connect(actHeader, &QAction::toggled, this, [this](bool checked) { + m_firstLineAsHeader = checked; + onReload(); + }); + + // Find/Replace + m_actFindReplace = m_toolbar->addToolAction( + "Find/Replace", + QKeySequence(), + 0, + "edit-find"); + m_actFindReplace->setCheckable(true); + + // Show Text + m_actTextMode = m_toolbar->addToolAction( + "Show Text", + QKeySequence(), + 0, + "visibility"); + m_actTextMode->setCheckable(true); + connect(m_actTextMode, &QAction::toggled, this, &CsvViewerWidget::onToggleTextMode); + + // Line Wrap + m_actWordWrap = m_toolbar->addToolAction( + "Line Wrap", + QKeySequence(), + 0, + "format-text-direction-ltr"); + m_actWordWrap->setCheckable(true); + connect(m_actWordWrap, &QAction::toggled, this, &CsvViewerWidget::onToggleWordWrap); + + // Open Externally + QAction *actEditor = m_toolbar->addToolAction( + "Open Externally", + QKeySequence(Qt::CTRL | Qt::Key_O), + FocusManager::Always, + "document-open"); + connect(actEditor, &QAction::triggered, this, [this]() { + QDesktopServices::openUrl(QUrl::fromLocalFile(m_currentFile)); + }); +} + +void CsvViewerWidget::setupFindReplace() +{ + m_findReplace = new ScopedFindReplacePanel(m_fm, this); + m_findReplace->setScopes({"All Cells", "Selected Cells", "Current Column", "Current Row"}); + connect(m_actFindReplace, &QAction::toggled, m_findReplace, &ScopedFindReplacePanel::showPanel); + connect(m_findReplace, &ScopedFindReplacePanel::findRequested, this, &CsvViewerWidget::doFind); + connect(m_findReplace, &ScopedFindReplacePanel::replaceRequested, this, &CsvViewerWidget::doReplace); + connect(m_findReplace, &ScopedFindReplacePanel::replaceAllRequested, this, &CsvViewerWidget::doReplaceAll); + connect(m_findReplace, &ScopedFindReplacePanel::panelClosed, this, [this]() { + m_actFindReplace->setChecked(false); + }); + + // Register shortcuts via FocusManager + m_fm->registerShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), FocusManager::WhenNoInput, [this]() { + m_actFindReplace->setChecked(!m_findReplace->isPanelVisible()); + return true; + }); + m_fm->registerShortcut(QKeySequence(Qt::CTRL | Qt::Key_R), FocusManager::WhenNoInput, [this]() { + m_actFindReplace->setChecked(!m_findReplace->isPanelVisible()); + return true; + }); +} + +void CsvViewerWidget::setActive(bool active) +{ + m_isActive = active; + m_fm->setActive(active); +} + +void CsvViewerWidget::onUndoStackCleanChanged(bool clean) { + m_dirtyIndicator->setText(clean ? "✓" : "✱"); +} + +void CsvViewerWidget::onToggleTextMode(bool checked) { + if (checked) { + // Commit editor + if (m_fm->activeInput()) { + QModelIndex current = m_view->currentIndex(); + QAbstractItemDelegate *delegate = m_view->itemDelegateForIndex(current); + if (delegate) + delegate->setModelData(m_fm->activeInput(), m_view->model(), current); + m_view->closePersistentEditor(m_view->currentItem()); + } + m_findReplace->showPanel(false); + updateTextView(); + m_stackedWidget->setCurrentWidget(m_textBrowser); + } else { + m_stackedWidget->setCurrentWidget(m_grid); + } +} + +void CsvViewerWidget::onToggleWordWrap(bool checked) { + m_grid->setWordWrap(checked); + + // For text mode + QTextOption opt; + opt.setWrapMode(checked ? QTextOption::WrapAnywhere : QTextOption::NoWrap); + m_textBrowser->document()->setDefaultTextOption(opt); + m_textBrowser->setLineWrapMode(checked ? QTextEdit::WidgetWidth : QTextEdit::NoWrap); +} + +void CsvViewerWidget::updateTextView() { + static const char *colors[] = { + "#9CA3AF", "#60A5FA", "#4ADE80", "#FBBF24", + "#CE9178", "#F87171", "#F44747", "#C084FC" + }; + static const int numColors = 8; + + int rows = m_view->rowCount(); + int cols = m_view->columnCount(); + bool useColors = (rows <= 10000); + QString sepStr = useColors ? QString(QChar(m_separator)).toHtmlEscaped() : QString(QChar(m_separator)); + + if (!useColors) { + QString plain; + if (m_firstLineAsHeader) { + for (int vc = 0; vc < cols; ++vc) { + int c = m_view->horizontalHeader()->logicalIndex(vc); + if (vc > 0) plain += sepStr; + QTableWidgetItem *hItem = m_view->horizontalHeaderItem(c); + QString text = hItem ? hItem->text() : ""; + if (hItem && hItem->data(WasQuotedRole).toBool()) { + text.replace("\"", "\"\""); + text = "\"" + text + "\""; + } + plain += text; + } + plain += "\n"; + } + for (int vr = 0; vr < rows; ++vr) { + int r = m_view->verticalHeader()->logicalIndex(vr); + for (int vc = 0; vc < cols; ++vc) { + int c = m_view->horizontalHeader()->logicalIndex(vc); + if (vc > 0) plain += sepStr; + QTableWidgetItem *item = m_view->item(r, c); + QString text = item ? item->text() : ""; + if (item && item->data(WasQuotedRole).toBool()) { + text.replace("\"", "\"\""); + text = "\"" + text + "\""; + } + plain += text; + } + plain += "\n"; + } + m_textBrowser->setPlainText(plain); + return; + } + + QString html = "
";
+
+	if (m_firstLineAsHeader) {
+		for (int vc = 0; vc < cols; ++vc) {
+			int c = m_view->horizontalHeader()->logicalIndex(vc);
+			if (vc > 0) html += QString("%2").arg(colors[vc % numColors]).arg(sepStr);
+			QTableWidgetItem *hItem = m_view->horizontalHeaderItem(c);
+			QString text = hItem ? hItem->text() : "";
+			if (hItem && hItem->data(WasQuotedRole).toBool()) {
+				text.replace("\"", "\"\"");
+				text = "\"" + text + "\"";
+			}
+			html += QString("%2").arg(colors[vc % numColors]).arg(text.toHtmlEscaped());
+		}
+		html += "\n";
+	}
+
+	for (int vr = 0; vr < rows; ++vr) {
+		int r = m_view->verticalHeader()->logicalIndex(vr);
+		for (int vc = 0; vc < cols; ++vc) {
+			int c = m_view->horizontalHeader()->logicalIndex(vc);
+			if (vc > 0) html += QString("%2").arg(colors[vc % numColors]).arg(sepStr);
+			QTableWidgetItem *item = m_view->item(r, c);
+			QString text = item ? item->text() : "";
+			if (item && item->data(WasQuotedRole).toBool()) {
+				text.replace("\"", "\"\"");
+				text = "\"" + text + "\"";
+			}
+			html += QString("%2").arg(colors[vc % numColors]).arg(text.toHtmlEscaped());
+		}
+		html += "\n";
+	}
+	html += "
"; + m_textBrowser->setHtml(html); +} + +bool CsvViewerWidget::loadFile(const QString& filePath) +{ + m_isProgrammaticChange = true; + QWidget *fw = QApplication::focusWidget(); + if (fw && fw != this && !this->isAncestorOf(fw)) { + m_fm->saveFocusWidget(fw); + } + m_currentFile = filePath; + + m_view->blockSignals(true); + m_view->clear(); + m_view->setRowCount(0); + m_view->setColumnCount(0); + + int columns = 0, row = 0; + QStringList header, list; + QFile file(filePath); + QByteArray line; + + if (!file.open(QFile::ReadOnly | QFile::Text)) { + m_view->blockSignals(false); + return false; + } + + if (gEnca) + { + QString enc = EncodingUtils::detectFileEncoding(filePath, gLang, 4096, gReadAll); + if (!enc.isEmpty()) { + snprintf(m_encoding, sizeof(m_encoding), "%s", enc.toStdString().c_str()); + } + } + + line = file.readLine(); + QByteArray seps(",;\t"); + bool detected = false; + + for (int i = 0; i < seps.size(); ++i) + { + m_separator = seps.at(i); + + QList headerQuoted; + header = parse_line(line, m_encoding, m_separator, &headerQuoted); + columns = header.size(); + + if (columns > 1) + { + m_view->setColumnCount(columns); + if (m_firstLineAsHeader) + { + for (int c = 0; c < columns; ++c) { + QTableWidgetItem *hItem = new QTableWidgetItem(header.at(c).trimmed()); + hItem->setData(WasQuotedRole, c < headerQuoted.size() && headerQuoted[c]); + m_view->setHorizontalHeaderItem(c, hItem); + } + } + detected = true; + break; + } + } + + if (!detected) + { + if (filePath.endsWith(".tsv", Qt::CaseInsensitive)) + m_separator = '\t'; + else + m_separator = ','; + + QList headerQuoted; + header = parse_line(line, m_encoding, m_separator, &headerQuoted); + columns = header.size(); + m_view->setColumnCount(columns); + if (m_firstLineAsHeader) + { + for (int c = 0; c < columns; ++c) { + QTableWidgetItem *hItem = new QTableWidgetItem(header.at(c).trimmed()); + hItem->setData(WasQuotedRole, c < headerQuoted.size() && headerQuoted[c]); + m_view->setHorizontalHeaderItem(c, hItem); + } + } + } + + if (columns < 1) + { + m_view->blockSignals(false); + m_isProgrammaticChange = false; + return false; + } + + // Check for extension/separator mismatch + bool isTsvExt = filePath.endsWith(".tsv", Qt::CaseInsensitive); + bool isCsvExt = filePath.endsWith(".csv", Qt::CaseInsensitive); + if ((isCsvExt && m_separator == '\t') || (isTsvExt && m_separator == ',')) { + QString msg = isCsvExt + ? "This .csv file appears to use tab separators instead of commas." + : "This .tsv file appears to use comma separators instead of tabs."; + QMessageBox box(QMessageBox::Warning, "Separator Mismatch", msg, QMessageBox::NoButton, nullptr); + QPushButton *btnIgnore = box.addButton("Ignore", QMessageBox::RejectRole); + QPushButton *btnFixSep = box.addButton("Fix Separator", QMessageBox::AcceptRole); + QPushButton *btnRename = box.addButton("Rename Extension", QMessageBox::AcceptRole); + box.exec(); + + if (box.clickedButton() == btnFixSep) { + char oldSep = m_separator; + char newSep = isCsvExt ? ',' : '\t'; + m_separator = newSep; + + file.seek(0); + QByteArray rawData = file.readAll(); + file.close(); + + bool inQuote = false; + for (int i = 0; i < rawData.size(); ++i) { + char ch = rawData[i]; + if (ch == '"') { + inQuote = !inQuote; + } else if (!inQuote && ch == oldSep) { + rawData[i] = newSep; + } + } + + QFile outFile(m_currentFile); + if (outFile.open(QFile::WriteOnly | QFile::Truncate)) { + outFile.write(rawData); + outFile.close(); + } + } else if (box.clickedButton() == btnRename) { + QString newExt = isCsvExt ? ".tsv" : ".csv"; + QString newPath = filePath; + int dotPos = newPath.lastIndexOf('.'); + if (dotPos >= 0) newPath = newPath.left(dotPos) + newExt; + QFile::rename(filePath, newPath); + m_currentFile = newPath; + } + (void)btnIgnore; + } + + if (!m_firstLineAsHeader) + { + QList headerQuoted; + header = parse_line(line, m_encoding, m_separator, &headerQuoted); + m_view->insertRow(row); + for (int c = 0; c < header.size(); ++c) + { + QTableWidgetItem *item = new QTableWidgetItem(header.at(c).trimmed()); + item->setToolTip(header.at(c).trimmed()); + item->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsEditable); + item->setData(WasQuotedRole, c < headerQuoted.size() && headerQuoted[c]); + m_view->setItem(row, c, item); + } + row++; + } + + while (!file.atEnd()) + { + m_view->insertRow(row); + QList rowQuoted; + list = parse_line(file.readLine(), m_encoding, m_separator, &rowQuoted); + + if (list.size() > columns) + { + columns = list.size(); + m_view->setColumnCount(columns); + } + + for (int c = 0; c < list.size(); ++c) + { + QTableWidgetItem *item = new QTableWidgetItem(list.at(c).trimmed()); + item->setToolTip(list.at(c).trimmed()); + item->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsEditable); + item->setData(WasQuotedRole, c < rowQuoted.size() && rowQuoted[c]); + m_view->setItem(row, c, item); + } + + row++; + } + + file.close(); + m_view->blockSignals(false); + + m_view->setShowGrid(gGrid); + + if (gResize) + m_view->resizeColumnsToContents(); + + m_grid->undoStack()->clear(); + m_isProgrammaticChange = false; + + m_findReplace->setStatusText(QString()); + return true; +} + +void CsvViewerWidget::onSave() +{ + FocusManager::expectReloadFocus(); + saveFile(m_currentFile); + m_grid->undoStack()->setClean(); +} + +void CsvViewerWidget::onSaveAs() +{ + QString csvFilter = "CSV - Comma Separated (*.csv)"; + QString tsvFilter = "TSV - Tab Separated (*.tsv)"; + QString selectedFilter; + QString filter = (m_separator == '\t') ? (tsvFilter + ";;" + csvFilter) : (csvFilter + ";;" + tsvFilter); + + QString path = QFileDialog::getSaveFileName(this, "Save As", m_currentFile, filter, &selectedFilter); + + if (!path.isEmpty()) { + char oldSep = m_separator; + if (selectedFilter == csvFilter) + m_separator = ','; + else if (selectedFilter == tsvFilter) + m_separator = '\t'; + saveFile(path); + m_currentFile = path; + m_grid->undoStack()->setClean(); + if (m_separator != oldSep) + updateTextView(); + QTimer::singleShot(100, this, [this]() { + m_fm->setActive(true); + m_fm->restoreViewFocus(); + }); + } +} + +void CsvViewerWidget::onReload() +{ + if (m_currentFile.isEmpty()) return; + loadFile(m_currentFile); +} + +void CsvViewerWidget::saveFile(const QString& filePath) +{ + QFile file(filePath); + if (!file.open(QFile::WriteOnly | QFile::Text)) { + QMessageBox::warning(nullptr, "Error", "Could not open file for writing."); + return; + } + + QString outText; + int rows = m_view->rowCount(); + int cols = m_view->columnCount(); + + QStringList headerLine; + if (m_firstLineAsHeader) { + for (int vc = 0; vc < cols; ++vc) { + int c = m_view->horizontalHeader()->logicalIndex(vc); + QTableWidgetItem *hItem = m_view->horizontalHeaderItem(c); + QString text = hItem ? hItem->text() : ""; + bool wasQuoted = hItem && hItem->data(WasQuotedRole).toBool(); + if (wasQuoted || text.contains(m_separator)) { + text.replace("\"", "\"\""); + text = "\"" + text + "\""; + } + headerLine << text; + } + outText += headerLine.join(m_separator) + "\n"; + } + + for (int vr = 0; vr < rows; ++vr) { + int r = m_view->verticalHeader()->logicalIndex(vr); + QStringList rowLine; + for (int vc = 0; vc < cols; ++vc) { + int c = m_view->horizontalHeader()->logicalIndex(vc); + QTableWidgetItem *item = m_view->item(r, c); + QString text = item ? item->text() : ""; + bool wasQuoted = item && item->data(WasQuotedRole).toBool(); + if (wasQuoted || text.contains(m_separator)) { + text.replace("\"", "\"\""); + text = "\"" + text + "\""; + } + rowLine << text; + } + outText += rowLine.join(m_separator) + "\n"; + } + + QByteArray outBytes; + if (m_encoding[0] != '\0') { + outBytes = EncodingUtils::fromUtf8(outText, m_encoding); + } else { + outBytes = outText.toUtf8(); + } + + file.write(outBytes); + file.close(); + + m_currentFile = filePath; +} + +bool CsvViewerWidget::cellMatches(int row, int col, const QString &query, bool matchCase, bool entireCell, bool regexFlag) +{ + if (query.isEmpty()) return false; + + QTableWidgetItem *item = m_view->item(row, col); + QString text = item ? item->text() : ""; + + if (regexFlag) { + QRegularExpression::PatternOptions options = QRegularExpression::NoPatternOption; + if (!matchCase) { + options |= QRegularExpression::CaseInsensitiveOption; + } + QRegularExpression re(entireCell ? "^(" + query + ")$" : query, options); + if (!re.isValid()) { + return false; + } + return re.match(text).hasMatch(); + } else { + Qt::CaseSensitivity cs = matchCase ? Qt::CaseSensitive : Qt::CaseInsensitive; + if (entireCell) { + return text.compare(query, cs) == 0; + } else { + return text.contains(query, cs); + } + } +} + +void CsvViewerWidget::doFind(bool forward) +{ + QString query = m_findReplace->findText(); + if (query.isEmpty()) { + m_findReplace->setStatusText("Search query is empty."); + return; + } + + bool matchCase = m_findReplace->matchCase(); + bool entireCell = m_findReplace->matchEntireCell(); + bool regexFlag = m_findReplace->useRegex(); + QString scope = m_findReplace->currentScope(); + + int rows = m_view->rowCount(); + int cols = m_view->columnCount(); + if (rows == 0 || cols == 0) { + m_findReplace->setStatusText("Grid is empty."); + return; + } + + QModelIndex current = m_view->currentIndex(); + int currRow = current.isValid() ? current.row() : (forward ? 0 : rows - 1); + int currCol = current.isValid() ? current.column() : (forward ? 0 : cols - 1); + + // Build list of cells in scope + QList> cells; + if (scope == "All Cells") { + int N = rows * cols; + int startIdx = currRow * cols + currCol; + for (int i = 1; i <= N; ++i) { + int idx = forward ? (startIdx + i) % N : (startIdx - i + N) % N; + cells.append({idx / cols, idx % cols}); + } + } else if (scope == "Current Column") { + int col = currCol; + for (int i = 1; i <= rows; ++i) { + int r = forward ? (currRow + i) % rows : (currRow - i + rows) % rows; + cells.append({r, col}); + } + } else if (scope == "Current Row") { + int row = currRow; + for (int i = 1; i <= cols; ++i) { + int c = forward ? (currCol + i) % cols : (currCol - i + cols) % cols; + cells.append({row, c}); + } + } else if (scope == "Selected Cells") { + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (sel.isEmpty()) { + m_findReplace->setStatusText("No cells selected."); + return; + } + std::sort(sel.begin(), sel.end(), [](const QModelIndex &a, const QModelIndex &b) { + if (a.row() != b.row()) return a.row() < b.row(); + return a.column() < b.column(); + }); + + int selIdx = -1; + for (int i = 0; i < sel.size(); ++i) { + if (sel[i].row() == currRow && sel[i].column() == currCol) { + selIdx = i; + break; + } + } + + int count = sel.size(); + for (int i = 1; i <= count; ++i) { + int idx = forward ? (selIdx + i) % count : (selIdx - i + count) % count; + cells.append({sel[idx].row(), sel[idx].column()}); + } + } + + for (const auto &cell : cells) { + if (cellMatches(cell.first, cell.second, query, matchCase, entireCell, regexFlag)) { + m_view->setCurrentCell(cell.first, cell.second); + m_view->scrollToItem(m_view->item(cell.first, cell.second)); + m_findReplace->setStatusText(QString("Found match at (%1, %2)").arg(cell.first + 1).arg(cell.second + 1)); + return; + } + } + + m_findReplace->setStatusText("No match found."); +} + +void CsvViewerWidget::doReplace() +{ + QString query = m_findReplace->findText(); + QString replaceText = m_findReplace->replaceText(); + if (query.isEmpty()) { + m_findReplace->setStatusText("Search query is empty."); + return; + } + + QModelIndex current = m_view->currentIndex(); + if (!current.isValid()) { + doFind(true); + return; + } + + bool matchCase = m_findReplace->matchCase(); + bool entireCell = m_findReplace->matchEntireCell(); + bool regexFlag = m_findReplace->useRegex(); + + int row = current.row(); + int col = current.column(); + + if (cellMatches(row, col, query, matchCase, entireCell, regexFlag)) { + QTableWidgetItem *item = m_view->item(row, col); + QString oldText = item ? item->text() : ""; + QString newText = oldText; + + if (regexFlag) { + QRegularExpression::PatternOptions options = QRegularExpression::NoPatternOption; + if (!matchCase) { + options |= QRegularExpression::CaseInsensitiveOption; + } + QRegularExpression re(entireCell ? "^(" + query + ")$" : query, options); + newText.replace(re, replaceText); + } else { + Qt::CaseSensitivity cs = matchCase ? Qt::CaseSensitive : Qt::CaseInsensitive; + if (entireCell) { + newText = replaceText; + } else { + newText.replace(query, replaceText, cs); + } + } + + if (newText != oldText) { + bool oldQuoted = item ? item->data(WasQuotedRole).toBool() : false; + bool newQuoted = oldQuoted || newText.contains(m_separator); + m_grid->undoStack()->push(new EditCellCommand(m_view, row, col, oldText, newText, oldQuoted, newQuoted)); + m_findReplace->setStatusText(QString("Replaced match at (%1, %2)").arg(row + 1).arg(col + 1)); + } + doFind(true); + } else { + doFind(true); + } +} + +void CsvViewerWidget::doReplaceAll() +{ + QString query = m_findReplace->findText(); + QString replaceText = m_findReplace->replaceText(); + if (query.isEmpty()) { + m_findReplace->setStatusText("Search query is empty."); + return; + } + + bool matchCase = m_findReplace->matchCase(); + bool entireCell = m_findReplace->matchEntireCell(); + bool regexFlag = m_findReplace->useRegex(); + QString scope = m_findReplace->currentScope(); + + int rows = m_view->rowCount(); + int cols = m_view->columnCount(); + if (rows == 0 || cols == 0) { + m_findReplace->setStatusText("Grid is empty."); + return; + } + + QList> cells; + if (scope == "All Cells") { + for (int r = 0; r < rows; ++r) { + for (int c = 0; c < cols; ++c) { + cells.append({r, c}); + } + } + } else if (scope == "Current Column") { + QModelIndex current = m_view->currentIndex(); + int col = current.isValid() ? current.column() : 0; + for (int r = 0; r < rows; ++r) { + cells.append({r, col}); + } + } else if (scope == "Current Row") { + QModelIndex current = m_view->currentIndex(); + int row = current.isValid() ? current.row() : 0; + for (int c = 0; c < cols; ++c) { + cells.append({row, c}); + } + } else if (scope == "Selected Cells") { + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (sel.isEmpty()) { + m_findReplace->setStatusText("No cells selected."); + return; + } + std::sort(sel.begin(), sel.end(), [](const QModelIndex &a, const QModelIndex &b) { + if (a.row() != b.row()) return a.row() < b.row(); + return a.column() < b.column(); + }); + for (const auto &idx : sel) { + cells.append({idx.row(), idx.column()}); + } + } + + struct Replacement { + int row; + int col; + QString oldText; + QString newText; + bool oldQuoted; + bool newQuoted; + }; + QList replacements; + + QRegularExpression re; + if (regexFlag) { + QRegularExpression::PatternOptions options = QRegularExpression::NoPatternOption; + if (!matchCase) { + options |= QRegularExpression::CaseInsensitiveOption; + } + re.setPattern(entireCell ? "^(" + query + ")$" : query); + re.setPatternOptions(options); + if (!re.isValid()) { + m_findReplace->setStatusText("Invalid regular expression."); + return; + } + } + + for (const auto &cell : cells) { + int r = cell.first; + int c = cell.second; + if (cellMatches(r, c, query, matchCase, entireCell, regexFlag)) { + QTableWidgetItem *item = m_view->item(r, c); + QString oldText = item ? item->text() : ""; + QString newText = oldText; + + if (regexFlag) { + newText.replace(re, replaceText); + } else { + Qt::CaseSensitivity cs = matchCase ? Qt::CaseSensitive : Qt::CaseInsensitive; + if (entireCell) { + newText = replaceText; + } else { + newText.replace(query, replaceText, cs); + } + } + + if (newText != oldText) { + bool oldQuoted = item ? item->data(WasQuotedRole).toBool() : false; + bool newQuoted = oldQuoted || newText.contains(m_separator); + replacements.append({r, c, oldText, newText, oldQuoted, newQuoted}); + } + } + } + + if (!replacements.isEmpty()) { + m_grid->undoStack()->beginMacro(QString("Replace All: %1 -> %2").arg(query).arg(replaceText)); + for (const auto &rep : replacements) { + m_grid->undoStack()->push(new EditCellCommand(m_view, rep.row, rep.col, rep.oldText, rep.newText, rep.oldQuoted, rep.newQuoted)); + } + m_grid->undoStack()->endMacro(); + m_findReplace->setStatusText(QString("Replaced %1 occurrences.").arg(replacements.size())); + } else { + m_findReplace->setStatusText("No replacements made."); + } +} + +HANDLE DCPCALL ListLoad(HANDLE ParentWin, char* FileToLoad, int ShowFlags) +{ + Q_UNUSED(ShowFlags); + if (!QApplication::instance()) return nullptr; + CsvViewerWidget *widget = new CsvViewerWidget((QWidget*)ParentWin); + if (!widget->loadFile(FileToLoad)) { delete widget; return nullptr; } + widget->show(); + return widget; +} + +void DCPCALL ListCloseWindow(HANDLE ListWin) +{ + CsvViewerWidget *widget = (CsvViewerWidget*)ListWin; + delete widget; +} + +int DCPCALL ListSendCommand(HWND ListWin, int Command, int Parameter) +{ + CsvViewerWidget *widget = (CsvViewerWidget*)ListWin; + QTableWidget *view = widget->view(); + switch (Command) + { + case lc_copy : + { + QString text = widget->grid()->getSelectionAsText('\t'); + if (text.isEmpty()) return LISTPLUGIN_ERROR; + QApplication::clipboard()->setText(text); + break; + } + case lc_selectall : + view->selectAll(); + break; + case lc_focus : + if (Parameter) { + widget->focusManager()->setActive(true); + view->setFocus(Qt::OtherFocusReason); + } else { + widget->focusManager()->setActive(false); + if (QWidget *fw = QApplication::focusWidget()) { + if (fw == widget || widget->isAncestorOf(fw)) + fw->clearFocus(); + } + } + break; + default : + return LISTPLUGIN_ERROR; + } + return LISTPLUGIN_OK; +} + +int DCPCALL ListSearchText(HWND ListWin, char* SearchString, int SearchParameter) +{ + CsvViewerWidget *widget = (CsvViewerWidget*)ListWin; + QTableWidget *view = widget->view(); + QList list; + Qt::MatchFlags sflags = Qt::MatchContains; + if (SearchParameter & lcs_matchcase) sflags |= Qt::MatchCaseSensitive; + + QString needle(SearchString); + QString prev = view->property("needle").value(); + view->setProperty("needle", needle); + + list = view->findItems(QString(SearchString), sflags); + + if (!list.isEmpty()) + { + int i = view->property("findit").value(); + if (needle != prev || SearchParameter & lcs_findfirst) + { + if (SearchParameter & lcs_backwards) i = list.size() - 1; + else i = 0; + } + else if (SearchParameter & lcs_backwards) i--; + else i++; + + if (i >= 0 && i < list.size() && list.at(i)) + { + view->scrollToItem(list.at(i)); + view->setCurrentItem(list.at(i)); + view->setProperty("findit", i); + return LISTPLUGIN_OK; + } + } + QMessageBox::information(nullptr, "", QString::asprintf(_("\"%s\" not found!"), SearchString)); + return LISTPLUGIN_ERROR; +} + +void DCPCALL ListGetDetectString(char* DetectString, int maxlen) +{ + snprintf(DetectString, maxlen - 1, "(EXT=\"CSV\" | EXT=\"TSV\") & SIZE<30000000"); +} + +void DCPCALL ListSetDefaultParams(ListDefaultParamStruct* dps) +{ + QFileInfo defini(QString::fromStdString(dps->DefaultIniName)); + QString cfgpath = defini.absolutePath() + "/j2969719.ini"; + QSettings settings(cfgpath, QSettings::IniFormat); + + if (!settings.contains(PLUGNAME "/resize_columns")) settings.setValue(PLUGNAME "/resize_columns", gResize); + else gResize = settings.value(PLUGNAME "/resize_columns").toBool(); + + if (!settings.contains(PLUGNAME "/enca")) settings.setValue(PLUGNAME "/enca", gEnca); + else gEnca = settings.value(PLUGNAME "/enca").toBool(); + + if (!settings.contains(PLUGNAME "/enca_lang")) + { + char lang[3]; + snprintf(lang, 3, "%s", setlocale(LC_ALL, "")); + settings.setValue(PLUGNAME "/enca_lang", QString(lang)); + } + else gLang = settings.value(PLUGNAME "/enca_lang").toString(); + + if (!settings.contains(PLUGNAME "/enca_readall")) settings.setValue(PLUGNAME "/enca_readall", gReadAll); + else gReadAll = settings.value(PLUGNAME "/enca_readall").toBool(); + + if (!settings.contains(PLUGNAME "/doublequoted")) settings.setValue(PLUGNAME "/doublequoted", gQuoted); + else gQuoted = settings.value(PLUGNAME "/doublequoted").toBool(); + + if (!settings.contains(PLUGNAME "/draw_grid")) settings.setValue(PLUGNAME "/draw_grid", gGrid); + else gGrid = settings.value(PLUGNAME "/draw_grid").toBool(); + + Dl_info dlinfo; + static char plg_path[PATH_MAX]; + const char* loc_dir = "langs"; + + memset(&dlinfo, 0, sizeof(dlinfo)); + + if (dladdr(plg_path, &dlinfo) != 0) + { + strncpy(plg_path, dlinfo.dli_fname, PATH_MAX); + char *pos = strrchr(plg_path, '/'); + if (pos) strcpy(pos + 1, loc_dir); + setlocale(LC_ALL, ""); + bindtextdomain(GETTEXT_PACKAGE, plg_path); + textdomain(GETTEXT_PACKAGE); + } +} diff --git a/wlx/wlxbase_wlqt/CMakeLists.txt b/wlx/wlxbase_wlqt/CMakeLists.txt new file mode 100644 index 0000000..3b1255f --- /dev/null +++ b/wlx/wlxbase_wlqt/CMakeLists.txt @@ -0,0 +1,82 @@ +cmake_minimum_required(VERSION 3.16) +project(wlxbase_wlqt LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets) +find_package(PkgConfig REQUIRED) +pkg_check_modules(GLIB2 REQUIRED glib-2.0) + +# --------------------------------------------------------------------------- +# enca — downloaded and built as a static library +# --------------------------------------------------------------------------- +set(ENCA_VERSION "1.19") +set(ENCA_DIR "${CMAKE_CURRENT_BINARY_DIR}/enca-${ENCA_VERSION}") +set(ENCA_TAR "${CMAKE_CURRENT_BINARY_DIR}/enca-${ENCA_VERSION}.tar.gz") +set(ENCA_LIB "${ENCA_DIR}/lib/.libs/libenca.a") + +if(NOT EXISTS "${ENCA_LIB}") + if(NOT EXISTS "${ENCA_TAR}") + message(STATUS "Downloading enca ${ENCA_VERSION}...") + file(DOWNLOAD + "https://dl.cihar.com/enca/enca-${ENCA_VERSION}.tar.gz" + "${ENCA_TAR}" + SHOW_PROGRESS + ) + endif() + message(STATUS "Extracting and building enca...") + execute_process(COMMAND ${CMAKE_COMMAND} -E tar xzf "${ENCA_TAR}" + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}") + execute_process(COMMAND ./configure --disable-shared --enable-static --with-pic + WORKING_DIRECTORY "${ENCA_DIR}") + execute_process(COMMAND make -j4 + WORKING_DIRECTORY "${ENCA_DIR}") +endif() + +add_library(enca_static STATIC IMPORTED) +set_target_properties(enca_static PROPERTIES + IMPORTED_LOCATION "${ENCA_LIB}" +) + +# --------------------------------------------------------------------------- +# wlxbase_wlqt static library +# --------------------------------------------------------------------------- +file(GLOB BASE_HEADERS "include/wlxbase_wlqt/*.h") + +add_library(wlxbase_wlqt STATIC + ${BASE_HEADERS} + src/FocusManager.cpp + src/PluginToolBar.cpp + src/EditableGridWidget.cpp + src/FindReplacePanel.cpp + src/ScopedFindReplacePanel.cpp + src/EncodingUtils.cpp + src/FilterRowWidget.cpp + src/FilterableHeaderView.cpp + src/PluginStatusBar.cpp + src/PluginSplitView.cpp + src/ThemeManager.cpp + src/CrashLogger.cpp + src/SequentialRowProxyModel.cpp +) + +target_include_directories(wlxbase_wlqt + PUBLIC + include + PRIVATE + "${ENCA_DIR}/lib" + ${GLIB2_INCLUDE_DIRS} +) + +target_link_libraries(wlxbase_wlqt + PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets + PRIVATE + enca_static + ${GLIB2_LIBRARIES} + ${CMAKE_DL_LIBS} +) diff --git a/wlx/wlxbase_wlqt/README.md b/wlx/wlxbase_wlqt/README.md new file mode 100644 index 0000000..c7d87f7 --- /dev/null +++ b/wlx/wlxbase_wlqt/README.md @@ -0,0 +1,810 @@ +# wayland_qt_base + +A reusable component library for building Qt6 Wayland WLX plugins for [Double Commander](https://doublecmd.sourceforge.io/). + +The library codifies battle-tested patterns that evolved across `csvview`, `logview`, and `kate` — focus management, keyboard shortcut handling, toolbar integration, grid editing, find/replace, and encoding detection — into a clean, decoupled foundation for new plugins. + +> **Existing plugins are not modified by this library.** This is a new foundation for future plugin development. + +--- + +## Table of Contents + +- [Architecture](#architecture) +- [Requirements](#requirements) +- [Building](#building) +- [Integration](#integration) +- [Components](#components) + - [FocusManager](#focusmanager) + - [PluginToolBar](#plugintoolbar) + - [EditableGridWidget](#editablegridwidget) + - [FindReplacePanel](#findreplacepanel) + - [ScopedFindReplacePanel](#scopedfindreplacepanel) +- [Utilities](#utilities) + - [EncodingUtils](#encodingutils) +- [Examples](#examples) +- [Design Decisions](#design-decisions) +- [Directory Layout](#directory-layout) + +--- + +## Architecture + +Each component depends only on `FocusManager`. A plugin that needs just a toolbar doesn't pull in grid or find/replace. `EncodingUtils` is a standalone utility with no component dependencies. + +``` + ┌──────────────────────┐ + │ FocusManager │ + │ (core framework) │ + │ shortcut registry │ + │ optional undo/redo │ + └──────┬───┬───┬───────┘ + │ │ │ + ┌───────────┘ │ └───────────┐ + │ │ │ + ┌─────────▼──┐ ┌────────▼────────┐ ┌───▼──────────────┐ + │PluginToolBar│ │EditableGridWidget│ │ FindReplacePanel │ + │(focus-safe) │ │ (grid + undo) │ │ (base, no scope)│ + └────────────┘ └─────────────────┘ └───────┬──────────┘ + │ extends + ┌───────▼───────────────┐ + │ScopedFindReplacePanel │ + │(adds scope combo box) │ + └───────────────────────┘ + + ┌─────────────┐ + │EncodingUtils │ (standalone utility — no component deps) + └─────────────┘ +``` + +**Namespace:** `QtWlPlugin` + +--- + +## Requirements + +- **CMake** ≥ 3.16 +- **Qt 6** (Core, Gui, Widgets) +- **GLib 2.0** (for encoding conversion via `g_convert_with_fallback`) +- **C++20** compiler +- **Internet connection** on first build (to download enca 1.19) + +The [enca](https://cihar.com/software/enca/) library is downloaded and statically built automatically during the CMake configure step. + +--- + +## Building + +### Standalone + +```bash +cd wlx/wayland_qt_base +mkdir build && cd build +cmake .. +make -j$(nproc) +``` + +This produces `libwayland_qt_base.a` — a static library. Each consumer plugin links it in and produces a self-contained `.wlx`. + +### As part of a plugin build + +In your plugin's `CMakeLists.txt`: + +```cmake +add_subdirectory(../../wayland_qt_base wayland_qt_base) +target_link_libraries(my_plugin PRIVATE wayland_qt_base) +``` + +The public include directory (`include/`) is automatically added, so you can include headers as: + +```cpp +#include +#include +// etc. +``` + +--- + +## Integration + +A minimal plugin using this library: + +```cpp +#include +#include + +class MyPluginWidget : public QWidget { + Q_OBJECT +public: + MyPluginWidget(QWidget *parent = nullptr) : QWidget(parent) { + auto *view = new QTextEdit(this); + auto *layout = new QVBoxLayout(this); + + // 1. Create FocusManager (required for all components) + m_fm = new QtWlPlugin::FocusManager(this, view, this); + + // 2. Create a focus-safe toolbar + m_toolbar = new QtWlPlugin::PluginToolBar(m_fm, this); + m_toolbar->addToolAction("Save", QKeySequence(Qt::CTRL | Qt::Key_S)); + + // 3. Register plugin-specific shortcuts + m_fm->registerShortcut( + QKeySequence(Qt::CTRL | Qt::Key_W), + QtWlPlugin::FocusManager::Always, + [this]() { close(); return true; } + ); + + layout->addWidget(m_toolbar); + layout->addWidget(view); + } + +private: + QtWlPlugin::FocusManager *m_fm; + QtWlPlugin::PluginToolBar *m_toolbar; +}; +``` + +--- + +## Components + +### FocusManager + +**Header:** `` + +The core framework that every plugin using this library needs. It solves the fundamental problem of a Qt widget embedded in a non-Qt host application: focus activation, deactivation, bounce prevention, and keyboard shortcut dispatch. + +#### The Problem It Solves + +When a Qt plugin widget is embedded inside Double Commander (a non-Qt application), focus management becomes complex: + +- Clicking inside the plugin must activate it; clicking outside must deactivate it. +- Programmatic focus changes (e.g. tab switching in DC) must not accidentally activate the plugin. +- Newly added child widgets must not steal focus from the host. +- Keyboard shortcuts must only fire when the plugin is active, and some should be suppressed when the user is editing text in an input field. + +FocusManager handles all of this through a single `qApp` event filter. + +#### Constructor + +```cpp +QtWlPlugin::FocusManager(QWidget *pluginRoot, QWidget *primaryView, QObject *parent = nullptr); +``` + +- `pluginRoot` — the top-level widget of the plugin (typically `this` in the plugin's main widget). +- `primaryView` — the main content widget that should receive focus by default (e.g. a `QTableView`, `QTextEdit`, `QTreeView`). + +The constructor installs the event filter on `qApp` and sets `pluginRoot`'s focus proxy to `primaryView`. + +#### Activation + +| Method | Description | +|--------|-------------| +| `bool isActive() const` | Returns whether the plugin is currently active. | +| `void setActive(bool)` | Manually activate/deactivate. Emits `activated()` or `deactivated()`. When deactivating, clears focus and returns it to the host. | + +**Signals:** `activated()`, `deactivated()` + +Activation is normally handled automatically by the event filter (click inside → activate, click outside → deactivate), but `setActive()` is available for edge cases. + +#### Input Widget Tracking + +The FocusManager distinguishes between "structural" widgets (buttons, headers, etc.) and "input" widgets (text fields, cell editors) to determine when shortcuts should be suppressed. + +| Method | Description | +|--------|-------------| +| `void addInputWidget(QWidget *w)` | Register a widget as an input widget (e.g. a QLineEdit in a find panel). | +| `void removeInputWidget(QWidget *w)` | Unregister an input widget. | +| `bool isInputWidget(QWidget *w) const` | Returns `true` if `w` is registered or is a descendant of `primaryView` (but not `primaryView` itself — this catches dynamically created cell editors). | +| `QWidget *activeInput() const` | Returns the currently focused input widget, or `nullptr`. | + +**Signals:** `inputWidgetEntered(QWidget *w)`, `inputWidgetExited()` + +#### Focus Proxy + +When showing a secondary panel (e.g. find/replace), the plugin's focus proxy should be redirected so keyboard events reach the panel's input fields. + +| Method | Description | +|--------|-------------| +| `void setFocusProxy(QWidget *proxy)` | Redirect `pluginRoot`'s focus proxy to `proxy`. | +| `void resetFocusProxy()` | Reset focus proxy back to `primaryView`. | +| `void restoreViewFocus()` | Explicitly set focus to `primaryView`. | + +#### Shortcut Registration + +Replaces the hardcoded if-chains in `eventFilter` with a declarative registry. + +```cpp +enum ShortcutContext { WhenNoInput, Always }; +using ShortcutId = int; + +ShortcutId registerShortcut(const QKeySequence &keys, ShortcutContext ctx, + std::function handler); +void unregisterShortcut(ShortcutId id); +``` + +- **`WhenNoInput`** — the shortcut only fires when no input widget has focus. Use for navigation keys, copy/paste, delete, etc. +- **`Always`** — the shortcut fires even when the user is editing text. Use for Ctrl+S (save), Ctrl+F (find), Ctrl+W (close), etc. + +The handler returns `true` to consume the event, `false` to let it propagate. + +#### Optional Undo/Redo + +```cpp +void setUndoStack(QUndoStack *stack); +QUndoStack *undoStack() const; +``` + +When a non-null `QUndoStack` is set, FocusManager automatically registers three shortcuts (all with `WhenNoInput` context): + +| Shortcut | Action | +|----------|--------| +| `Ctrl+Z` | `stack->undo()` | +| `Ctrl+Shift+Z` | `stack->redo()` | +| `Ctrl+Y` | `stack->redo()` | + +Calling `setUndoStack(nullptr)` unregisters them. The consumer retains full access to the stack for pushing custom undo commands. + +#### Saved Focus Widget + +```cpp +void saveFocusWidget(QWidget *w); +``` + +Store a reference to the host application's focus widget so the plugin can restore focus to it when deactivating. This is useful when the host tells the plugin which widget had focus before the plugin was activated. + +#### Event Filter Internals + +The event filter handles five event types: + +1. **`MouseButtonPress`** — geometry-based click detection. Click inside plugin → activate. Click outside → deactivate. +2. **`FocusIn`** — tracks which input widget has focus. Ignores programmatic focus changes (`OtherFocusReason`) when inactive. +3. **`KeyPress`** — iterates the shortcut registry, matches key sequences, checks context, dispatches handlers. +4. **`ChildAdded`** — sets `Qt::NoFocus` on newly created child widgets to prevent focus theft. +5. **`QApplication::focusChanged`** — connected in the constructor. Detects focus leaving the plugin hierarchy. When focus enters the plugin while inactive, bounces it back to the host via `QTimer::singleShot(0, ...)`. + +--- + +### PluginToolBar + +**Header:** `` + +A `QToolBar` subclass that integrates with `FocusManager` to prevent focus issues caused by toolbar interaction. + +#### The Problem It Solves + +In a Qt plugin embedded in a non-Qt host, clicking a toolbar button transfers focus to the button widget. Without intervention, this causes the host to think the plugin lost focus and deactivate it. PluginToolBar prevents this by: + +1. Setting `Qt::NoFocus` on all action widgets (buttons, combo boxes, etc.). +2. Restoring focus to `primaryView` after every action trigger via `QTimer::singleShot`. +3. Applying compact default styling. + +#### API + +```cpp +explicit PluginToolBar(FocusManager *fm, QWidget *parent = nullptr); + +QAction *addToolAction(const QString &text, + const QKeySequence &shortcut = {}, + int ctx = 0 /* FocusManager::WhenNoInput */); +``` + +- `addToolAction` creates a `QAction`, adds it to the toolbar, and optionally registers a keyboard shortcut through `FocusManager`. The shortcut text is appended to the tooltip automatically. +- Any dynamically added widgets are also enforced to `NoFocus` via an `actionEvent` override. + +--- + +### EditableGridWidget + +**Header:** `` + +A `QWidget` wrapping a caller-injected `QTableView` with full editing capabilities, undo/redo support, drag-to-move, and context menus. **Format-agnostic** — it provides no file I/O, no parsing, no encoding detection. + +Because `QTableWidget` is a subclass of `QTableView`, you can inject either: +- A `QTableWidget` for simple item-based workflows +- A plain `QTableView` with any `QAbstractItemModel` (e.g. `QStandardItemModel`, `QSqlTableModel`) for model-based workflows + +All internal data access goes through `QAbstractItemModel` / `QModelIndex` — zero `QTableWidgetItem` dependencies inside the grid. + +#### GridMode + +The constructor requires a `GridMode` enum that determines the undo/memory strategy: + +| Mode | Undo Strategy | Sorting | Best For | +|------|--------------|---------|----------| +| `GridMode::MemoryDocument` | Full `QUndoStack` tracking. Sort stores before/after snapshots in RAM. | Snapshot-based undo | `QTableWidget`, `QStandardItemModel`, in-memory data | +| `GridMode::LiveDatabase` | Bypasses `QUndoStack` for data mutations. Sort issues `model->sort()` (SQL `ORDER BY`) with no RAM snapshot. | Direct `model->sort()` | `QSqlTableModel`, transactional models | + +Both modes share context menus, drag-to-reorder, focus management, keyboard shortcuts, and the `WrapAnywhereDelegate`. + +#### Constructor (Dependency Injection) + +```cpp +// The caller creates and configures the view, then hands it to the grid. +explicit EditableGridWidget(QTableView *view, GridMode mode, FocusManager *fm, QWidget *parent = nullptr); +``` + +**In-memory document (full undo):** +```cpp +auto *tw = new QTableWidget(); +auto *grid = new QtWlPlugin::EditableGridWidget(tw, QtWlPlugin::GridMode::MemoryDocument, fm, this); +``` + +**Live database (transactional):** +```cpp +auto *tv = new QTableView(); +tv->setModel(sqlModel); +auto *grid = new QtWlPlugin::EditableGridWidget(tv, QtWlPlugin::GridMode::LiveDatabase, fm, this); +``` + +#### Features + +| Feature | Details | +|---------|---------| +| **Undo/Redo** | `QUndoStack` registered with FocusManager. In `MemoryDocument` mode: four undo command types (`EditCellCommand`, `RowColCommand`, `DataSnapshotCommand`, `SectionMoveCommand`). In `LiveDatabase` mode: data mutations bypass the undo stack — the transactional model handles reverts. | +| **Cell editing** | Enter to open editor, Escape to cancel (reverts to pre-edit value), Up/Down to navigate between cells while editing, Enter to commit and advance right (wraps to next row). | +| **Arrow navigation** | Left at column 0 wraps to the last column of the previous row. Right at last column wraps to column 0 of the next row. | +| **Copy/Paste** | `copySelection(separator)` copies selected cells via `model()->data()`. `pasteSelection()` inserts clipboard rows — wrapped in undo commands in `MemoryDocument`, direct model calls in `LiveDatabase`. | +| **Insert/Delete** | `insertRows()`, `deleteSelectedRows()`, `insertColumns()`, `deleteSelectedColumns()` — undo-wrapped in `MemoryDocument`, direct `model->insertRows()`/`removeRows()` in `LiveDatabase`. | +| **Drag-to-move** | Multi-select row or column drag via headers. Uses a debounce timer to coalesce Qt's per-section `sectionMoved` signals into a single undo command. | +| **Column sorting** | Click header once to arm, click again to sort. `MemoryDocument`: stores full data snapshot for undo. `LiveDatabase`: issues `model->sort()` (SQL `ORDER BY`) with zero RAM overhead. | +| **Word wrap** | `WrapAnywhereDelegate` enables character-level text wrapping (not just word boundaries). Toggle with `setWordWrap(bool)`. | +| **Context menus** | Right-click on the table or vertical header → row operations. Right-click on horizontal header → column operations. Includes insert from clipboard. | +| **Dirty tracking** | `isDirty()` / `dirtyChanged(bool)` signal based on `QUndoStack::isClean()`. | + +#### API + +```cpp +explicit EditableGridWidget(QTableView *view, GridMode mode, FocusManager *fm, QWidget *parent = nullptr); + +// Access +QTableView *view() const; // Returns the injected view (may be QTableView or QTableWidget) +GridMode mode() const; // Returns the active mode +QUndoStack *undoStack() const; + +// Row operations +void copySelection(char separator = '\t'); +QString getSelectionAsText(char separator = '\t'); +void pasteSelection(); +void pasteSelectionAt(int atRow); +void insertRows(int count, int atRow); +void deleteSelectedRows(); + +// Column operations +void copyColumnSelection(char separator = '\t'); +void pasteColumnSelectionAt(int atCol); +void insertColumns(int count, int atCol); +void deleteSelectedColumns(); + +// Appearance +void setWordWrap(bool wrap); +bool wordWrap() const; +void setShowGrid(bool show); + +// State +bool isDirty() const; + +// Signals +void dirtyChanged(bool dirty); +``` + +The consumer provides a `QTableView` (or `QTableWidget`) with a model already set. All grid operations go through `view()->model()` using `QModelIndex`. + +#### Registered Shortcuts + +These are automatically registered with `FocusManager` (all `WhenNoInput`): + +| Shortcut | Action | +|----------|--------| +| `Ctrl+C` | Copy selection as TSV | +| `Ctrl+V` | Paste from clipboard | +| `Delete` | Delete selected rows | +| `Enter` / `Return` | Edit current cell | +| `↑` `↓` `←` `→` | Navigate with right-wrap | +| `Ctrl+Z` | Undo (via FocusManager) | +| `Ctrl+Shift+Z` / `Ctrl+Y` | Redo (via FocusManager) | + +--- + +### FindReplacePanel + +**Header:** `` + +The **base class** for find & replace UI. Provides inputs, match options, and action buttons but has **no scope concept** — scope is entirely the consumer's responsibility. + +#### UI Layout + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Find: [_______________] Replace: [_______________] │ +│ ☐ Match Case ☐ Match Entire Cell ☐ Regex │ +│ [Find Prev] [Find Next] [Replace] [Replace All] status ✕ │ +└──────────────────────────────────────────────────────────────┘ +``` + +#### API + +```cpp +explicit FindReplacePanel(FocusManager *fm, QWidget *parent = nullptr); + +// Configuration +void setReplaceEnabled(bool enabled); // hide replace row for read-only views + +// State +QString findText() const; +QString replaceText() const; +bool matchCase() const; +bool matchEntireCell() const; +bool useRegex() const; + +// Status feedback +void setStatusText(const QString &text); + +// Visibility +void showPanel(bool show); +bool isPanelVisible() const; + +// Signals +void findRequested(bool forward); // true = next, false = previous +void replaceRequested(); +void replaceAllRequested(); +void panelClosed(); +``` + +#### Focus Integration + +- When shown, `setFocusProxy` redirects to the find input and selects all text. +- When hidden, `resetFocusProxy` and `restoreViewFocus` return focus to the primary view. +- Find and replace inputs are registered as input widgets with FocusManager. +- `Ctrl+F` and `Ctrl+R` toggle panel visibility (registered as `Always` shortcuts). + +#### Subclass Extension + +The `optionsRow()` layout is exposed to subclasses for inserting additional widgets: + +```cpp +protected: + QHBoxLayout *optionsRow() const; +``` + +--- + +### ScopedFindReplacePanel + +**Header:** `` + +Extends `FindReplacePanel` with a configurable scope combo box. The consumer provides scope labels and reads the current selection in their signal handlers. + +```cpp +explicit ScopedFindReplacePanel(FocusManager *fm, QWidget *parent = nullptr); + +void setScopes(const QStringList &scopes); // e.g. {"All Cells", "Current Column"} +QString currentScope() const; +``` + +#### Usage + +```cpp +auto *panel = new QtWlPlugin::ScopedFindReplacePanel(m_fm, this); +panel->setScopes({"All Cells", "Selected Cells", "Current Column", "Current Row"}); + +connect(panel, &QtWlPlugin::FindReplacePanel::findRequested, + this, [this, panel](bool forward) { + QString scope = panel->currentScope(); + // ... implement search logic using scope + } +); +``` + +--- + +## Utilities + +### EncodingUtils + +**Header:** `` + +Static utility class for encoding detection (via [enca](https://cihar.com/software/enca/)) and encoding conversion (via GLib's `g_convert_with_fallback`). No instance needed — all methods are static. + +#### API + +```cpp +// Detect encoding of raw bytes. Language hint improves accuracy for +// specific scripts (e.g. "ru" for Russian, "zh" for Chinese). +static QString detectEncoding(const QByteArray &data, const QString &language = {}); + +// Detect encoding from a file (reads up to sampleSize bytes). +static QString detectFileEncoding(const QString &filePath, const QString &language = {}, + int sampleSize = 4096, bool readAll = false); + +// Convert from a detected encoding to UTF-8. +static QByteArray toUtf8(const QByteArray &data, const QString &fromEncoding); + +// Convert a UTF-8 QString to a target encoding. +static QByteArray fromUtf8(const QString &text, const QString &toEncoding); + +// One-shot: detect encoding and decode to QString. +static QString decodeToString(const QByteArray &data, const QString &language = {}); + +// Check runtime availability of enca. +static bool isEncaAvailable(); +``` + +#### Usage + +```cpp +// Detect and decode a file +QFile f("/path/to/file.txt"); +f.open(QFile::ReadOnly); +QByteArray raw = f.readAll(); +f.close(); + +QString encoding = QtWlPlugin::EncodingUtils::detectEncoding(raw, "ru"); +// encoding → "windows-1251" + +QString text = QtWlPlugin::EncodingUtils::decodeToString(raw, "ru"); +// text → decoded UTF-8 QString + +// Convert back for saving +QByteArray encoded = QtWlPlugin::EncodingUtils::fromUtf8(text, "windows-1251"); +``` + +#### Supported Encodings + +Detection accuracy depends on the language hint. enca supports: + +| Language | Code | Scripts | +|----------|------|---------| +| Belarusian | `be` | CP1251, ISO-8859-5, IBM866, KOI8-UNI, etc. | +| Bulgarian | `bg` | CP1251, ISO-8859-5, ECMA-113, etc. | +| Chinese | `zh` | BIG5, GBK, GB2312, HZ, etc. | +| Croatian | `hr` | CP1250, ISO-8859-2, ISO-8859-16, etc. | +| Czech | `cs` | CP1250, ISO-8859-2, IBM852, KEYBCS2, etc. | +| Estonian | `et` | CP1257, ISO-8859-4, ISO-8859-13, etc. | +| Hungarian | `hu` | CP1250, ISO-8859-2, IBM852, etc. | +| Latvian | `lv` | CP1257, ISO-8859-4, ISO-8859-13, etc. | +| Lithuanian | `lt` | CP1257, ISO-8859-4, ISO-8859-13, etc. | +| Polish | `pl` | CP1250, ISO-8859-2, ISO-8859-16, etc. | +| Russian | `ru` | CP1251, KOI8-R, ISO-8859-5, IBM866, etc. | +| Slovak | `sk` | CP1250, ISO-8859-2, IBM852, KEYBCS2, etc. | +| Slovene | `sl` | CP1250, ISO-8859-2, IBM852, etc. | +| Ukrainian | `uk` | CP1251, KOI8-U, ISO-8859-5, IBM866, etc. | + +Use `"__"` or an empty string to let enca auto-detect from the system locale. + +--- + +## Examples + +### Plugin with toolbar + find/replace (no grid) + +```cpp +#include +#include +#include + +class TextViewerWidget : public QWidget { + Q_OBJECT +public: + TextViewerWidget(QWidget *parent = nullptr) : QWidget(parent) { + auto *view = new QTextEdit(this); + auto *layout = new QVBoxLayout(this); + + m_fm = new QtWlPlugin::FocusManager(this, view, this); + + m_toolbar = new QtWlPlugin::PluginToolBar(m_fm, this); + m_toolbar->addToolAction("Print", QKeySequence(Qt::CTRL | Qt::Key_P)); + + m_findReplace = new QtWlPlugin::ScopedFindReplacePanel(m_fm, this); + m_findReplace->setScopes({"Entire Document", "Selection"}); + m_findReplace->setReplaceEnabled(false); // read-only viewer + + connect(m_findReplace, &QtWlPlugin::FindReplacePanel::findRequested, + this, &TextViewerWidget::onFind); + + layout->addWidget(m_toolbar); + layout->addWidget(view, 1); + layout->addWidget(m_findReplace); + } + +private slots: + void onFind(bool forward) { + QString query = m_findReplace->findText(); + QString scope = m_findReplace->currentScope(); + // ... implement search + } + +private: + QtWlPlugin::FocusManager *m_fm; + QtWlPlugin::PluginToolBar *m_toolbar; + QtWlPlugin::ScopedFindReplacePanel *m_findReplace; +}; +``` + +### Plugin with editable grid + undo (QTableWidget) + +```cpp +#include +#include +#include +#include + +class DataEditorWidget : public QWidget { + Q_OBJECT +public: + DataEditorWidget(QWidget *parent = nullptr) : QWidget(parent) { + auto *layout = new QVBoxLayout(this); + + // Inject a QTableWidget for item-based convenience + auto *table = new QTableWidget(); + m_fm = new QtWlPlugin::FocusManager(this, table, this); + m_grid = new QtWlPlugin::EditableGridWidget( + table, QtWlPlugin::GridMode::MemoryDocument, m_fm, this); + + m_toolbar = new QtWlPlugin::PluginToolBar(m_fm, this); + auto *actSave = m_toolbar->addToolAction("Save", + QKeySequence(Qt::CTRL | Qt::Key_S), + QtWlPlugin::FocusManager::Always); + connect(actSave, &QAction::triggered, this, &DataEditorWidget::onSave); + + connect(m_grid, &QtWlPlugin::EditableGridWidget::dirtyChanged, + this, [this](bool dirty) { + setWindowTitle(dirty ? "Data Editor *" : "Data Editor"); + }); + + layout->addWidget(m_toolbar); + layout->addWidget(m_grid, 1); + loadData(); + } + +private: + void loadData() { + // You can still access QTableWidget-specific methods on the original pointer + auto *tw = qobject_cast(m_grid->view()); + tw->setRowCount(100); + tw->setColumnCount(5); + // ... populate cells with QTableWidgetItem + } + + void onSave() { + // ... write data in your format + m_fm->undoStack()->setClean(); + } + + QtWlPlugin::FocusManager *m_fm; + QtWlPlugin::PluginToolBar *m_toolbar; + QtWlPlugin::EditableGridWidget *m_grid; +}; +``` + +### Plugin with editable grid + SQL model (QTableView) + +```cpp +#include +#include +#include +#include +#include + +class SqlEditorWidget : public QWidget { + Q_OBJECT +public: + SqlEditorWidget(QSqlDatabase db, QWidget *parent = nullptr) : QWidget(parent) { + auto *layout = new QVBoxLayout(this); + + // Inject a QTableView with a SQL model + auto *sqlView = new QTableView(); + auto *sqlModel = new QSqlTableModel(this, db); + sqlModel->setTable("users"); + sqlModel->select(); + sqlView->setModel(sqlModel); + + m_fm = new QtWlPlugin::FocusManager(this, sqlView, this); + m_grid = new QtWlPlugin::EditableGridWidget( + sqlView, QtWlPlugin::GridMode::LiveDatabase, m_fm, this); + + // The grid handles all focus, context menus, and shortcuts. + // Sorting uses model->sort() → SQL ORDER BY (no RAM snapshot). + // Insert/delete call model directly (no undo stack wrapping). + // model->setData() triggers SQL UPDATEs via QSqlTableModel. + + layout->addWidget(m_grid, 1); + } + +private: + QtWlPlugin::FocusManager *m_fm; + QtWlPlugin::EditableGridWidget *m_grid; +}; +``` + +### Encoding detection on file load + +```cpp +#include + +void MyPlugin::loadFile(const QString &path) { + QFile file(path); + file.open(QFile::ReadOnly); + QByteArray raw = file.readAll(); + file.close(); + + QString encoding = QtWlPlugin::EncodingUtils::detectEncoding(raw); + if (encoding.isEmpty()) + encoding = "UTF-8"; // fallback + + QByteArray utf8 = QtWlPlugin::EncodingUtils::toUtf8(raw, encoding); + QString content = QString::fromUtf8(utf8); + + m_detectedEncoding = encoding; // store for save + // ... display content +} + +void MyPlugin::saveFile(const QString &path) { + QString content = /* ... get content ... */; + QByteArray encoded = QtWlPlugin::EncodingUtils::fromUtf8(content, m_detectedEncoding); + + QFile file(path); + file.open(QFile::WriteOnly); + file.write(encoded); + file.close(); +} +``` + +--- + +## Design Decisions + +### Shortcut Registry over Hardcoded eventFilter + +Existing plugins (`csvview`, `logview`, `kate`) each contain near-identical `eventFilter` implementations with large if-chains mapping key combinations to actions. This library replaces that pattern with a declarative `registerShortcut()` API. New plugins never need to subclass or override `eventFilter` for keyboard shortcuts. + +### Static Library + +Each consumer plugin links `libwayland_qt_base.a` and produces a self-contained `.wlx` with no runtime dependency on a separate shared object. This simplifies deployment — just ship the `.wlx` file. + +### Optional Undo + +Not all plugins need undo/redo (e.g. a read-only log viewer). Calling `setUndoStack()` is optional. When set, undo shortcuts are auto-registered; when cleared, they're auto-unregistered. The consumer always has direct access to the `QUndoStack` for pushing custom commands, checking `isClean()`, etc. + +### FindReplacePanel Hierarchy + +The base class `FindReplacePanel` has zero scope awareness. This is intentional — a text editor plugin's "scope" concept (selection, whole file) is fundamentally different from a grid plugin's (all cells, current column, current row). The base class provides the UI and signals; the consumer implements matching. + +`ScopedFindReplacePanel` adds a scope combo box for plugins that want predefined scope options. It inserts into the base's `optionsRow()` layout, so there's no UI duplication. + +### Model-Based, Format-Agnostic Grid + +`EditableGridWidget` accepts a `QTableView*` via dependency injection and performs all data operations through `QAbstractItemModel` (`model()->data()`, `model()->setData()`, `model()->insertRows()`, etc.). This means the same grid code works identically whether the underlying model is a `QTableWidget`'s internal model, a `QStandardItemModel`, a `QSqlTableModel`, or any custom `QAbstractItemModel`. The consumer is responsible for file I/O, encoding, and format-specific quoting — the grid knows nothing about data formats. + +### GridMode: MemoryDocument vs LiveDatabase + +The `GridMode` enum forces the downstream developer to explicitly acknowledge the memory paradigm of the data they are loading. `MemoryDocument` enables full undo/redo tracking — every edit, insert, delete, and sort is wrapped in a `QUndoCommand` backed by in-memory data snapshots. `LiveDatabase` bypasses all memory-intensive operations: sorting delegates to `model->sort()` (which translates to SQL `ORDER BY` for `QSqlTableModel`), and structural mutations (insert/delete) call the model directly without undo wrappers. This prevents the catastrophic RAM explosion that would occur if a 5-million-row SQLite table were snapshotted into `QVariantList` objects for undo support. Both modes share identical context menus, drag-to-reorder, focus proxying, keyboard shortcuts, and the `WrapAnywhereDelegate`. + +### enca as Build Dependency + +The encoding detection library [enca](https://cihar.com/software/enca/) is downloaded and statically compiled during the CMake configure step. This mirrors the pattern used by `csvview` and avoids requiring enca to be installed system-wide. The static link means no runtime dependency. + +--- + +## Directory Layout + +``` +wlx/wlxbase_wlqt/ +├── CMakeLists.txt +├── README.md +├── include/ +│ └── wlxbase_wlqt/ +│ ├── EditableGridWidget.h +│ ├── EncodingUtils.h +│ ├── FindReplacePanel.h +│ ├── FocusManager.h +│ ├── PluginToolBar.h +│ └── ScopedFindReplacePanel.h +└── src/ + ├── EditableGridWidget.cpp + ├── EncodingUtils.cpp + ├── FindReplacePanel.cpp + ├── FocusManager.cpp + ├── PluginToolBar.cpp + └── ScopedFindReplacePanel.cpp +``` diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/CrashLogger.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/CrashLogger.h new file mode 100644 index 0000000..8ed40c4 --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/CrashLogger.h @@ -0,0 +1,74 @@ +#pragma once + +/// @file CrashLogger.h +/// Global exception logging for WLX plugins. +/// +/// Usage in wlx_entry.cpp: +/// @code +/// #include +/// +/// HWND DCPCALL ListLoad(HWND ParentWin, char* FileToLoad, int ShowFlags) +/// { +/// WLX_TRY { +/// // ... normal plugin code ... +/// } WLX_CATCH("ListLoad"); +/// return nullptr; // fallback return on exception +/// } +/// @endcode +/// +/// Logs are written to .log next to the .wlx file. + +#include +#include + +namespace QtWlPlugin { + +class CrashLogger { +public: + /// Initialize with any address inside the plugin .so/.wlx + /// (typically a function pointer like &ListLoad). + /// Determines the plugin path and creates the log file path. + static void init(void *addressInPlugin); + + /// Log a caught std::exception with stack trace. + static void log(const char *entryPoint, const std::exception &e); + + /// Log an unknown exception (catch ...) with stack trace. + static void logUnknown(const char *entryPoint); + + /// Log a custom message (for warnings, state dumps, etc.) + static void logMessage(const char *entryPoint, const char *message); + + /// Get the log file path (empty if not initialized). + static const std::string &logPath(); + +private: + static void writeEntry(const char *entryPoint, const char *type, + const char *message); +}; + +} // namespace QtWlPlugin + +/// Convenience macros for wrapping WLX entry points. +/// +/// WLX_TRY { ... } WLX_CATCH("FunctionName"); +/// +/// On first use, auto-initializes the logger with the current function address. +#define WLX_TRY \ + { \ + static bool _wlx_logger_init = false; \ + if (!_wlx_logger_init) { \ + _wlx_logger_init = true; \ + QtWlPlugin::CrashLogger::init( \ + reinterpret_cast(&_wlx_logger_init)); \ + } \ + } \ + try + +#define WLX_CATCH(funcName) \ + catch (const std::exception &_wlx_ex) { \ + QtWlPlugin::CrashLogger::log(funcName, _wlx_ex); \ + } \ + catch (...) { \ + QtWlPlugin::CrashLogger::logUnknown(funcName); \ + } diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/EditableGridWidget.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/EditableGridWidget.h new file mode 100644 index 0000000..a5812ec --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/EditableGridWidget.h @@ -0,0 +1,154 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QtWlPlugin { + +class FocusManager; + +/// Defines the memory and undo strategy for EditableGridWidget. +/// +/// MemoryDocument: Full QUndoStack tracking for all data mutations. +/// Best for QTableWidget, QStandardItemModel, or any in-memory model. +/// Sorting snapshots entire table state for undo. +/// +/// LiveDatabase: Bypasses QUndoStack for data mutations (insert, delete, sort). +/// Best for QSqlTableModel or other transactional models. +/// Sorting delegates to model->sort() (SQL ORDER BY) with no RAM snapshot. +/// Copy, context menus, drag-to-reorder, focus, and shortcuts still work. +enum class GridMode { + MemoryDocument, + LiveDatabase +}; + +/// Custom delegate that wraps text at any character (not just word boundaries). +class WrapAnywhereDelegate : public QStyledItemDelegate { +public: + using QStyledItemDelegate::QStyledItemDelegate; + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + void setWrapAnywhere(bool wrap); + bool wrapAnywhere() const; + +private: + bool m_wrap = false; +}; + +/// A QTableView wrapper with full undo/redo, keyboard navigation, +/// drag-to-move columns/rows, context menus, and editing support. +/// +/// Accepts a pre-instantiated QTableView* and a GridMode via dependency injection. +/// Because QTableWidget is a subclass of QTableView, you can inject either: +/// +/// // Item-based (in-memory, full undo): +/// auto *grid = new EditableGridWidget(new QTableWidget(), GridMode::MemoryDocument, fm, this); +/// +/// // Database (transactional, no RAM snapshots): +/// auto *sqlView = new QTableView(); +/// sqlView->setModel(sqlModel); +/// auto *grid = new EditableGridWidget(sqlView, GridMode::LiveDatabase, fm, this); +/// +/// All data access goes through QAbstractItemModel — no QTableWidgetItem +/// dependency in the grid's own logic. +class EditableGridWidget : public QWidget { + Q_OBJECT +public: + /// Takes ownership of the view and parents it to this widget. + explicit EditableGridWidget(QTableView *view, GridMode mode, FocusManager *fm, QWidget *parent = nullptr); + + /// Access the underlying view (may be QTableView or QTableWidget). + QTableView *view() const; + GridMode mode() const; + QUndoStack *undoStack() const; + + // --- Data operations (format-agnostic, model-based) --- + void copySelection(char separator = '\t'); + QString getSelectionAsText(char separator = '\t'); + void pasteSelection(); + void pasteSelectionAt(int atRow); + void insertRows(int count, int atRow); + void deleteSelectedRows(); + + void copyColumnSelection(char separator = '\t'); + void pasteColumnSelectionAt(int atCol); + void insertColumns(int count, int atCol); + void deleteSelectedColumns(); + + // --- Appearance --- + void setWordWrap(bool wrap); + bool wordWrap() const; + void setShowGrid(bool show); + + // --- Context menu integration --- + /// Set the filter row widget for Filters toggle in context menu. + void setFilterRow(class FilterRowWidget *filterRow); + + /// Enable the Dark theme toggle in context menu. + void setThemeToggleEnabled(bool enabled); + + /// Register additional context menu entries. + /// The callback receives the QMenu and the clicked QModelIndex. + void setExtraContextMenuCallback( + std::function callback); + + // --- State --- + bool isDirty() const; + +signals: + void dirtyChanged(bool dirty); + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private: + void setupView(); + void registerShortcuts(); + void setupDragToMove(); + void showRowContextMenu(const QPoint &pos); + void showColumnContextMenu(const QPoint &pos); + void onSortByColumn(int column); + void onDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, + const QList &roles); + void updateRowNumbers(); + bool isSectionSelected(QHeaderView *header, int logicalIndex) const; + + /// Helper: row/column count via the model + int rowCount() const; + int colCount() const; + + FocusManager *m_fm; + QTableView *m_view; + GridMode m_mode; + QUndoStack *m_undoStack; + WrapAnywhereDelegate *m_wrapDelegate; + bool m_isProgrammaticChange; + + // Sort state + int m_lastSortColumn; + Qt::SortOrder m_lastSortOrder; + + // Drag-to-move state + QHeaderView *m_dragHeader; + int m_dragLogicalIndex; + QList m_dragBeforeOrder; + QSet m_dragSelectedSections; + bool m_isDraggingSection; + QTimer *m_moveDebounceTimer; + + // Context menu integrations + FilterRowWidget *m_filterRow = nullptr; + bool m_themeToggleEnabled = false; + std::function m_extraMenuCallback; +}; + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/EncodingUtils.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/EncodingUtils.h new file mode 100644 index 0000000..880140a --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/EncodingUtils.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +namespace QtWlPlugin { + +/// Encoding detection and conversion utility for plugin developers. +/// +/// Wraps the enca library for detection and glib for encoding conversion. +/// All methods are static — no instance needed. +class EncodingUtils { +public: + /// Detect the encoding of raw byte data. + /// @param data Raw bytes to analyze + /// @param language 2-letter ISO language code hint for enca (e.g. "ru", "en", "de"). + /// If empty, derived from the current locale. + /// @return Detected encoding name (e.g. "UTF-8", "windows-1251") or empty on failure. + static QString detectEncoding(const QByteArray &data, const QString &language = {}); + + /// Detect encoding from a file. Reads up to sampleSize bytes by default. + static QString detectFileEncoding(const QString &filePath, const QString &language = {}, + int sampleSize = 4096, bool readAll = false); + + /// Convert byte data from one encoding to UTF-8. + /// Returns the original data if conversion fails. + static QByteArray toUtf8(const QByteArray &data, const QString &fromEncoding); + + /// Convert a UTF-8 QString to a target encoding. + /// Returns UTF-8 bytes if conversion fails. + static QByteArray fromUtf8(const QString &text, const QString &toEncoding); + + /// Detect encoding and decode to QString in one call. + static QString decodeToString(const QByteArray &data, const QString &language = {}); + + /// Check if enca support is available at runtime. + static bool isEncaAvailable(); +}; + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/FilterRowWidget.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/FilterRowWidget.h new file mode 100644 index 0000000..fb59b70 --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/FilterRowWidget.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include + +class QLineEdit; +class QHBoxLayout; +class QTableView; + +namespace QtWlPlugin { + +/// A row of QLineEdit widgets for per-column filtering of a QTableView. +/// +/// Sits between the column headers and the data. Each column gets its own +/// filter input. Widths sync with column widths automatically. +/// +/// Usage: +/// auto *filter = new FilterRowWidget(tableView, this); +/// connect(filter, &FilterRowWidget::filterChanged, ...); +class FilterRowWidget : public QWidget { + Q_OBJECT +public: + explicit FilterRowWidget(QTableView *view, QWidget *parent = nullptr); + + void setFilterVisible(bool visible); + bool isFilterVisible() const; + void clearFilters(); + + /// Rebuild filter inputs when the model/columns change. + void syncToModel(); + +signals: + void filterChanged(int column, const QString &text); + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private: + void rebuildInputs(); + void syncWidths(); + + QTableView *m_view; + QHBoxLayout *m_layout; + QVector m_inputs; + bool m_visible = true; +}; + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/FilterableHeaderView.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/FilterableHeaderView.h new file mode 100644 index 0000000..38fb37c --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/FilterableHeaderView.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +class QLineEdit; + +namespace QtWlPlugin { + +/// QHeaderView with an optional embedded filter row below the column labels. +/// +/// When filtering is enabled, QLineEdit inputs appear directly under each +/// column header, pixel-aligned with the sections (no spacer hacks needed). +/// +/// Usage: +/// auto *header = new FilterableHeaderView(Qt::Horizontal, tableView); +/// header->setFilterEnabled(true); +/// tableView->setHorizontalHeader(header); +/// connect(header, &FilterableHeaderView::filterChanged, ...); +class FilterableHeaderView : public QHeaderView { + Q_OBJECT +public: + explicit FilterableHeaderView(Qt::Orientation orientation, QWidget *parent = nullptr); + + /// Enable or disable the filter row. + void setFilterEnabled(bool enabled); + bool isFilterEnabled() const; + + /// Enable or disable the header labels (if false, the header collapses to only show the filters). + void setHeaderVisible(bool visible); + bool isHeaderVisible() const; + + /// Clear all filter inputs. + void clearFilters(); + + /// Get the current filter text for a column. + QString filterText(int column) const; + + QSize sizeHint() const override; + +signals: + /// Emitted when the user types in a column's filter input. + void filterChanged(int column, const QString &text); + +protected: + void updateGeometries() override; + void paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const override; + +private slots: + void adjustInputPositions(); + +private: + void rebuildInputs(); + + bool m_filterEnabled = false; + bool m_headerVisible = true; + int m_filterRowHeight = 24; + QVector m_inputs; +}; + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/FindReplacePanel.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/FindReplacePanel.h new file mode 100644 index 0000000..1f3f487 --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/FindReplacePanel.h @@ -0,0 +1,74 @@ +#pragma once + +#include + +class QLineEdit; +class QCheckBox; +class QLabel; +class QPushButton; +class QHBoxLayout; +class QVBoxLayout; + +namespace QtWlPlugin { + +class FocusManager; + +/// Base find & replace panel with no scope concept. +/// +/// Provides the UI shell (find/replace inputs, match options, action buttons) +/// and emits signals. The consumer implements the actual matching logic by +/// connecting to findRequested, replaceRequested, and replaceAllRequested. +/// +/// Scope is entirely the consumer's responsibility at this level. +/// For built-in scope support, use ScopedFindReplacePanel instead. +class FindReplacePanel : public QWidget { + Q_OBJECT +public: + explicit FindReplacePanel(FocusManager *fm, QWidget *parent = nullptr); + + // --- Configuration --- + void setReplaceEnabled(bool enabled); + + // --- State --- + QString findText() const; + QString replaceText() const; + bool matchCase() const; + bool matchEntireCell() const; + bool useRegex() const; + + // --- Status feedback --- + void setStatusText(const QString &text); + + // --- Visibility --- + void showPanel(bool show); + bool isPanelVisible() const; + + // --- Access for subclasses --- + FocusManager *focusManager() const; + QLabel *statusLabel() const; + +signals: + void findRequested(bool forward); + void replaceRequested(); + void replaceAllRequested(); + void panelClosed(); + +protected: + /// Hook for subclasses to insert widgets into the options row. + QHBoxLayout *optionsRow() const; + +private: + FocusManager *m_fm; + QLineEdit *m_txtFind; + QLineEdit *m_txtReplace; + QLabel *m_lblReplace; + QCheckBox *m_chkMatchCase; + QCheckBox *m_chkMatchEntire; + QCheckBox *m_chkRegex; + QLabel *m_lblStatus; + QHBoxLayout *m_optionsRow; + QPushButton *m_btnReplace; + QPushButton *m_btnReplaceAll; +}; + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/FocusManager.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/FocusManager.h new file mode 100644 index 0000000..1bdf3c2 --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/FocusManager.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QUndoStack; + +namespace QtWlPlugin { + +/// Core focus management framework for Qt WLX plugins embedded in a host application. +/// +/// Manages the entire focus lifecycle: activation/deactivation on click, +/// focus bounce prevention, input widget tracking, and a shortcut registry +/// that replaces the hardcoded eventFilter if-chains found in existing plugins. +class FocusManager : public QObject { + Q_OBJECT +public: + explicit FocusManager(QWidget *pluginRoot, QWidget *primaryView, QObject *parent = nullptr); + ~FocusManager() override; + + // --- Activation --- + bool isActive() const; + void setActive(bool active); + + // --- Input widget tracking --- + void addInputWidget(QWidget *w); + void removeInputWidget(QWidget *w); + bool isInputWidget(QWidget *w) const; + QWidget *activeInput() const; + + // --- Focus proxy management --- + void setFocusProxy(QWidget *proxy); + void resetFocusProxy(); + + // --- Shortcut registration --- + enum ShortcutContext { WhenNoInput, Always }; + using ShortcutId = int; + ShortcutId registerShortcut(const QKeySequence &keys, ShortcutContext ctx, + std::function handler); + void unregisterShortcut(ShortcutId id); + + // --- Optional undo/redo --- + /// When set, auto-registers Ctrl+Z (undo), Ctrl+Shift+Z (redo), Ctrl+Y (redo). + /// The consumer retains full access to the stack for custom manipulation. + void setUndoStack(QUndoStack *stack); + QUndoStack *undoStack() const; + + // --- Saved focus (for restoring to host app) --- + void saveFocusWidget(QWidget *w); + + // --- Access --- + QWidget *pluginRoot() const; + QWidget *primaryView() const; + + // --- Focus restoration --- + void restoreViewFocus(); + static void expectReloadFocus(); + +signals: + void activated(); + void deactivated(); + void inputWidgetEntered(QWidget *w); + void inputWidgetExited(); + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private: + void installFocusGuard(); + void restoreFocusToDC(); + + QPointer m_pluginRoot; + QPointer m_primaryView; + bool m_isActive; + QPointer m_savedFocusWidget; + QPointer m_activeInput; + QSet m_extraInputWidgets; + + QUndoStack *m_undoStack; + QVector m_undoShortcutIds; + + struct RegisteredShortcut { + ShortcutId id; + QKeySequence keys; + ShortcutContext ctx; + std::function handler; + }; + QVector m_shortcuts; + ShortcutId m_nextShortcutId; + static bool s_reloadFocusTarget; +}; + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/PluginSplitView.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/PluginSplitView.h new file mode 100644 index 0000000..504c73a --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/PluginSplitView.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace QtWlPlugin { + +/// Reusable left-panel + right-content splitter. +/// +/// Both structview (tree) and dbview (table list) use this pattern. +/// Sets sensible defaults: left panel 180px, stretch on right. +class PluginSplitView : public QSplitter { + Q_OBJECT +public: + explicit PluginSplitView(QWidget *leftPanel, QWidget *rightContent, + QWidget *parent = nullptr); + + void setLeftWidth(int pixels); + QWidget *leftPanel() const; + QWidget *rightContent() const; + +private: + QWidget *m_left; + QWidget *m_right; +}; + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/PluginStatusBar.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/PluginStatusBar.h new file mode 100644 index 0000000..9d460e6 --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/PluginStatusBar.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +class QHBoxLayout; + +namespace QtWlPlugin { + +/// A compact status bar for WLX plugins. +/// +/// Displays configurable sections separated by vertical lines. +/// Typical content: encoding, format name, row count, extra info. +/// +/// Example: | UTF-8 | JSON | Rows: 4/4 | +/// Example: | Tables: 11 | Views: 0 | SQLite | Rows: 3/59 | +class PluginStatusBar : public QWidget { + Q_OBJECT +public: + explicit PluginStatusBar(QWidget *parent = nullptr); + + void setEncoding(const QString &encoding); + void setFormatInfo(const QString &info); + void setRowCount(int filtered, int total); + void setExtraInfo(const QString &key, const QString &value); + void removeExtraInfo(const QString &key); + +private: + void rebuild(); + QFrame *createSeparator(); + + QLabel *m_encodingLabel; + QLabel *m_formatLabel; + QLabel *m_rowLabel; + QMap m_extras; + QHBoxLayout *m_layout; +}; + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/PluginToolBar.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/PluginToolBar.h new file mode 100644 index 0000000..6bb3bc3 --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/PluginToolBar.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include + +namespace QtWlPlugin { + +class FocusManager; + +enum class ButtonDisplay { + IconOnly, + TextOnly, + Both +}; + +enum class IconMode { + System, + Unicode +}; + +/// A QToolBar subclass that automatically integrates with FocusManager. +/// +/// All action widgets are set to Qt::NoFocus, and focus is restored to the +/// primary view after any action trigger. Provides convenience for adding +/// actions with automatic shortcut registration through FocusManager. +class PluginToolBar : public QToolBar { + Q_OBJECT +public: + explicit PluginToolBar(FocusManager *fm, QWidget *parent = nullptr); + + /// Add an action and optionally register a shortcut through FocusManager. + QAction *addToolAction(const QString &text, + const QKeySequence &shortcut = {}, + int ctx = 0 /* FocusManager::WhenNoInput */, + const QString &systemIconName = {}, + const QString &unicodeIcon = {}, + ButtonDisplay display = ButtonDisplay::Both, + IconMode iconMode = IconMode::System); + +protected: + void actionEvent(QActionEvent *event) override; + +private: + void enforceNoFocus(); + FocusManager *m_fm; +}; + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/ScopedFindReplacePanel.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/ScopedFindReplacePanel.h new file mode 100644 index 0000000..72eb137 --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/ScopedFindReplacePanel.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +class QComboBox; + +namespace QtWlPlugin { + +/// FindReplacePanel subclass that adds configurable scope options via a QComboBox. +/// +/// The consumer sets scope labels (e.g. "All Cells", "Current Column") via setScopes(), +/// then reads currentScope() in their signal handlers to filter the search. +class ScopedFindReplacePanel : public FindReplacePanel { + Q_OBJECT +public: + explicit ScopedFindReplacePanel(FocusManager *fm, QWidget *parent = nullptr); + + void setScopes(const QStringList &scopes); + QString currentScope() const; + +private: + QComboBox *m_comboScope; +}; + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/SequentialRowProxyModel.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/SequentialRowProxyModel.h new file mode 100644 index 0000000..1e15078 --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/SequentialRowProxyModel.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace QtWlPlugin { + +class SequentialRowProxyModel : public QSortFilterProxyModel { + Q_OBJECT +public: + explicit SequentialRowProxyModel(QObject *parent = nullptr); + ~SequentialRowProxyModel() override; + + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; +}; + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/include/wlxbase_wlqt/ThemeManager.h b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/ThemeManager.h new file mode 100644 index 0000000..d2ea90e --- /dev/null +++ b/wlx/wlxbase_wlqt/include/wlxbase_wlqt/ThemeManager.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +namespace QtWlPlugin { + +/// Shared dark/light theme toggle for WLX plugins. +/// +/// Applies a comprehensive Qt stylesheet to the given widget tree. +/// Preference is persisted in QSettings. +class ThemeManager { +public: + enum Theme { Light, Dark }; + + static void applyTheme(QWidget *root, Theme theme); + static Theme currentTheme(); + static void toggleTheme(QWidget *root); + static bool isDark(); + +private: + static QString darkStylesheet(); + static Theme s_current; +}; + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/CrashLogger.cpp b/wlx/wlxbase_wlqt/src/CrashLogger.cpp new file mode 100644 index 0000000..f14665c --- /dev/null +++ b/wlx/wlxbase_wlqt/src/CrashLogger.cpp @@ -0,0 +1,193 @@ +#include + +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#include +#else +#include +#endif + +namespace QtWlPlugin { + +static std::string s_logPath; +static std::string s_pluginName; +static std::mutex s_mutex; + +void CrashLogger::init(void *addressInPlugin) +{ + if (!s_logPath.empty()) + return; // already initialized + +#ifndef _WIN32 + Dl_info info; + if (dladdr(addressInPlugin, &info) && info.dli_fname) { + // Plugin path: /path/to/build/myplugin_qt6.wlx + std::string pluginPath(info.dli_fname); + + // Extract directory + char *pathCopy = strdup(pluginPath.c_str()); + std::string dir(dirname(pathCopy)); + free(pathCopy); + + // Extract plugin name (without extension) + char *baseCopy = strdup(pluginPath.c_str()); + std::string base(basename(baseCopy)); + free(baseCopy); + + auto dot = base.rfind('.'); + if (dot != std::string::npos) + base = base.substr(0, dot); + + s_pluginName = base; + s_logPath = dir + "/" + base + ".log"; + } +#else + HMODULE hModule = nullptr; + GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(addressInPlugin), &hModule); + if (hModule) { + char path[MAX_PATH]; + GetModuleFileNameA(hModule, path, MAX_PATH); + std::string pluginPath(path); + + auto lastSlash = pluginPath.rfind('\\'); + std::string dir = (lastSlash != std::string::npos) ? pluginPath.substr(0, lastSlash) : "."; + std::string base = (lastSlash != std::string::npos) ? pluginPath.substr(lastSlash + 1) : pluginPath; + + auto dot = base.rfind('.'); + if (dot != std::string::npos) + base = base.substr(0, dot); + + s_pluginName = base; + s_logPath = dir + "\\" + base + ".log"; + } +#endif +} + +const std::string &CrashLogger::logPath() +{ + return s_logPath; +} + +/// Format a backtrace into the log. +static std::string captureBacktrace() +{ + std::string result; +#ifndef _WIN32 + void *frames[64]; + int count = backtrace(frames, 64); + char **symbols = backtrace_symbols(frames, count); + + if (symbols) { + // Skip first 3 frames (captureBacktrace, writeEntry, log/logUnknown) + for (int i = 3; i < count; ++i) { + result += " "; + + // Try to demangle the symbol + // Format: "./libfoo.so(+0x1234) [0x7fff1234]" + // or: "./libfoo.so(_ZN3Foo3barEv+0x42) [0x7fff1234]" + std::string sym(symbols[i]); + auto lparen = sym.find('('); + auto plus = sym.find('+', lparen != std::string::npos ? lparen : 0); + auto rparen = sym.find(')', lparen != std::string::npos ? lparen : 0); + + if (lparen != std::string::npos && rparen != std::string::npos + && plus != std::string::npos && plus > lparen + 1) { + std::string mangled = sym.substr(lparen + 1, plus - lparen - 1); + int status = -1; + char *demangled = abi::__cxa_demangle(mangled.c_str(), nullptr, nullptr, &status); + if (status == 0 && demangled) { + result += sym.substr(0, lparen + 1); + result += demangled; + result += sym.substr(plus); + free(demangled); + } else { + result += sym; + } + } else { + result += sym; + } + result += "\n"; + } + free(symbols); + } +#endif + return result; +} + +void CrashLogger::writeEntry(const char *entryPoint, const char *type, + const char *message) +{ + if (s_logPath.empty()) + return; + + std::lock_guard lock(s_mutex); + + FILE *f = fopen(s_logPath.c_str(), "a"); + if (!f) + return; + + // Timestamp + time_t now = time(nullptr); + struct tm tm; + localtime_r(&now, &tm); + char timebuf[64]; + strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M:%S", &tm); + + fprintf(f, "--- %s [%s] %s ---\n", timebuf, s_pluginName.c_str(), entryPoint); + fprintf(f, " Type: %s\n", type); + if (message && message[0]) + fprintf(f, " Message: %s\n", message); + + // Stack trace + std::string bt = captureBacktrace(); + if (!bt.empty()) { + fprintf(f, " Stack trace:\n%s", bt.c_str()); + } + + fprintf(f, "\n"); + fclose(f); + + // Also print to stderr so it shows in console + fprintf(stderr, "[%s] EXCEPTION in %s: %s: %s\n", + s_pluginName.c_str(), entryPoint, type, + (message && message[0]) ? message : "(no message)"); +} + +void CrashLogger::log(const char *entryPoint, const std::exception &e) +{ + // Try to get the demangled exception type name + const char *typeName = "std::exception"; +#ifndef _WIN32 + int status = -1; + char *demangled = abi::__cxa_demangle(typeid(e).name(), nullptr, nullptr, &status); + if (status == 0 && demangled) { + writeEntry(entryPoint, demangled, e.what()); + free(demangled); + return; + } +#endif + typeName = typeid(e).name(); + writeEntry(entryPoint, typeName, e.what()); +} + +void CrashLogger::logUnknown(const char *entryPoint) +{ + writeEntry(entryPoint, "unknown (catch ...)", "Non-std::exception object thrown"); +} + +void CrashLogger::logMessage(const char *entryPoint, const char *message) +{ + writeEntry(entryPoint, "info", message); +} + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/EditableGridWidget.cpp b/wlx/wlxbase_wlqt/src/EditableGridWidget.cpp new file mode 100644 index 0000000..20bdcd5 --- /dev/null +++ b/wlx/wlxbase_wlqt/src/EditableGridWidget.cpp @@ -0,0 +1,1216 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QtWlPlugin { + +// --------------------------------------------------------------------------- +// Undo Commands — all operate through QAbstractItemModel, not QTableWidgetItem +// --------------------------------------------------------------------------- + +class EditCellCommand : public QUndoCommand { +public: + EditCellCommand(QAbstractItemModel *model, const QModelIndex &index, + const QVariant &oldValue, const QVariant &newValue, + QUndoCommand *parent = nullptr) + : QUndoCommand(parent), m_model(model), + m_row(index.row()), m_col(index.column()), + m_oldValue(oldValue), m_newValue(newValue) { + setText(QString("Edit cell (%1, %2)").arg(m_row).arg(m_col)); + } + void undo() override { + QModelIndex idx = m_model->index(m_row, m_col); + m_model->setData(idx, m_oldValue, Qt::EditRole); + } + void redo() override { + QModelIndex idx = m_model->index(m_row, m_col); + m_model->setData(idx, m_newValue, Qt::EditRole); + } +private: + QAbstractItemModel *m_model; + int m_row, m_col; + QVariant m_oldValue, m_newValue; +}; + +class RowColCommand : public QUndoCommand { +public: + RowColCommand(QAbstractItemModel *model, int index, int count, bool isRow, bool isInsert, + QUndoCommand *parent = nullptr) + : QUndoCommand(parent), m_model(model), m_index(index), m_count(count), + m_isRow(isRow), m_isInsert(isInsert) { + setText(QString("%1 %2 %3(s)").arg(isInsert ? "Insert" : "Delete") + .arg(count).arg(isRow ? "row" : "col")); + if (!isInsert) { + // Snapshot data before deletion for undo restore + int crossDim = isRow ? model->columnCount() : model->rowCount(); + for (int i = 0; i < count; ++i) { + QVariantList list; + for (int j = 0; j < crossDim; ++j) { + QModelIndex idx = isRow ? model->index(index + i, j) + : model->index(j, index + i); + list << model->data(idx, Qt::EditRole); + } + m_data << list; + } + } + } + void undo() override { if (m_isInsert) applyDelete(); else applyInsert(); } + void redo() override { if (m_isInsert) applyInsert(); else applyDelete(); } + +private: + void applyInsert() { + if (m_isRow) m_model->insertRows(m_index, m_count); + else m_model->insertColumns(m_index, m_count); + + // Restore saved data if we have any (undo of delete) + if (!m_data.isEmpty()) { + int crossDim = m_isRow ? m_model->columnCount() : m_model->rowCount(); + for (int i = 0; i < m_count && i < m_data.size(); ++i) { + const QVariantList &list = m_data[i]; + for (int j = 0; j < crossDim && j < list.size(); ++j) { + QModelIndex idx = m_isRow ? m_model->index(m_index + i, j) + : m_model->index(j, m_index + i); + m_model->setData(idx, list[j], Qt::EditRole); + } + } + } + } + void applyDelete() { + if (m_isRow) m_model->removeRows(m_index, m_count); + else m_model->removeColumns(m_index, m_count); + } + QAbstractItemModel *m_model; + int m_index, m_count; + QList m_data; + bool m_isRow, m_isInsert; +}; + +class DataSnapshotCommand : public QUndoCommand { +public: + DataSnapshotCommand(QAbstractItemModel *model, const QList &before, + const QList &after, const QString &text) + : m_model(model), m_before(before), m_after(after), m_first(true) { setText(text); } + void undo() override { restore(m_before); } + void redo() override { if (m_first) { m_first = false; return; } restore(m_after); } +private: + void restore(const QList &data) { + for (int r = 0; r < data.size() && r < m_model->rowCount(); ++r) + for (int c = 0; c < data[r].size() && c < m_model->columnCount(); ++c) { + QModelIndex idx = m_model->index(r, c); + m_model->setData(idx, data[r][c], Qt::EditRole); + } + } + QAbstractItemModel *m_model; + QList m_before, m_after; + bool m_first; +}; + +class SectionMoveCommand : public QUndoCommand { +public: + SectionMoveCommand(QHeaderView *header, const QList &beforeOrder, + const QList &afterOrder, const QString &text) + : m_header(header), m_before(beforeOrder), m_after(afterOrder), m_first(true) { setText(text); } + void undo() override { restore(m_before); } + void redo() override { if (m_first) { m_first = false; return; } restore(m_after); } +private: + void restore(const QList &order) { + for (int target = 0; target < order.size(); ++target) { + int logical = order[target]; + int currentVisual = m_header->visualIndex(logical); + if (currentVisual != target) + m_header->moveSection(currentVisual, target); + } + } + QHeaderView *m_header; + QList m_before, m_after; + bool m_first; +}; + +// --------------------------------------------------------------------------- +// WrapAnywhereDelegate — already model-based (uses QModelIndex) +// --------------------------------------------------------------------------- + +void WrapAnywhereDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const { + QStyleOptionViewItem opt = option; + initStyleOption(&opt, index); + QString text = opt.text; + opt.text.clear(); + QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &opt, painter); + + if (!text.isEmpty()) { + painter->save(); + QRect textRect = QApplication::style()->subElementRect(QStyle::SE_ItemViewItemText, &opt); + painter->setClipRect(textRect); + painter->setFont(opt.font); + QTextOption textOption; + textOption.setWrapMode(m_wrap ? QTextOption::WrapAnywhere : QTextOption::NoWrap); + textOption.setAlignment(opt.displayAlignment); + if (opt.state & QStyle::State_Selected) + painter->setPen(opt.palette.color(QPalette::HighlightedText)); + else + painter->setPen(opt.palette.color(QPalette::Text)); + painter->drawText(textRect, text, textOption); + painter->restore(); + } +} + +QSize WrapAnywhereDelegate::sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const { + if (!m_wrap) return QStyledItemDelegate::sizeHint(option, index); + QStyleOptionViewItem opt = option; + initStyleOption(&opt, index); + QRect textRect = QApplication::style()->subElementRect(QStyle::SE_ItemViewItemText, &opt); + int width = textRect.width(); + if (width <= 0) width = opt.rect.width(); + QTextDocument doc; + doc.setDefaultFont(opt.font); + QTextOption textOption; + textOption.setWrapMode(QTextOption::WrapAnywhere); + doc.setDefaultTextOption(textOption); + doc.setTextWidth(width); + doc.setPlainText(opt.text); + return QSize(width, qMax((int)doc.size().height(), opt.fontMetrics.height())); +} + +void WrapAnywhereDelegate::setWrapAnywhere(bool wrap) { m_wrap = wrap; } +bool WrapAnywhereDelegate::wrapAnywhere() const { return m_wrap; } + +// --------------------------------------------------------------------------- +// EditableGridWidget +// --------------------------------------------------------------------------- + +EditableGridWidget::EditableGridWidget(QTableView *view, GridMode mode, FocusManager *fm, QWidget *parent) + : QWidget(parent) + , m_fm(fm) + , m_view(view) + , m_mode(mode) + , m_isProgrammaticChange(false) + , m_lastSortColumn(-1) + , m_lastSortOrder(Qt::AscendingOrder) + , m_dragHeader(nullptr) + , m_dragLogicalIndex(-1) + , m_isDraggingSection(false) +{ + setFocusPolicy(Qt::NoFocus); + + // Take ownership + m_view->setParent(this); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(m_view); + + setupView(); + + m_undoStack = new QUndoStack(this); + fm->setUndoStack(m_undoStack); + + connect(m_undoStack, &QUndoStack::cleanChanged, this, [this](bool clean) { + emit dirtyChanged(!clean); + }); + connect(m_undoStack, &QUndoStack::indexChanged, this, [this]() { updateRowNumbers(); }); + + // MemoryDocument mode: track data changes for undo integration. + // LiveDatabase mode: the model handles its own transactions; + // intercepting dataChanged would force the entire DB into RAM. + if (m_mode == GridMode::MemoryDocument) { + if (m_view->model()) { + connect(m_view->model(), &QAbstractItemModel::dataChanged, + this, &EditableGridWidget::onDataChanged); + } + + // Stash old value when entering a cell editor + connect(fm, &FocusManager::inputWidgetEntered, this, [this](QWidget *) { + QModelIndex current = m_view->currentIndex(); + if (!current.isValid() || !m_view->model()) return; + + QAbstractItemModel *model = m_view->model(); + QModelIndex sourceIndex = current; + QAbstractItemModel *sourceModel = model; + if (auto *proxy = qobject_cast(model)) { + sourceModel = proxy->sourceModel(); + sourceIndex = proxy->mapToSource(current); + } + + QVariant existing = sourceModel->data(sourceIndex, Qt::UserRole); + if (!existing.isValid()) { + m_isProgrammaticChange = true; + sourceModel->setData(sourceIndex, + sourceModel->data(sourceIndex, Qt::EditRole), Qt::UserRole); + m_isProgrammaticChange = false; + } + }); + } + + registerShortcuts(); + setupDragToMove(); +} + +void EditableGridWidget::setupView() +{ + m_view->setFocusPolicy(Qt::ClickFocus); + m_view->setSelectionMode(QAbstractItemView::ExtendedSelection); + + m_view->horizontalHeader()->setSectionsClickable(true); + m_view->horizontalHeader()->setHighlightSections(true); + m_view->horizontalHeader()->setSectionsMovable(false); + m_view->horizontalHeader()->setContextMenuPolicy(Qt::CustomContextMenu); + m_view->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); + + m_view->verticalHeader()->setSectionsClickable(true); + m_view->verticalHeader()->setHighlightSections(true); + m_view->verticalHeader()->setSectionsMovable(false); + m_view->verticalHeader()->setContextMenuPolicy(Qt::CustomContextMenu); + m_view->verticalHeader()->setSectionResizeMode(QHeaderView::Interactive); + + m_view->setSortingEnabled(false); + + m_wrapDelegate = new WrapAnywhereDelegate(m_view); + m_view->setItemDelegate(m_wrapDelegate); + + m_view->setContextMenuPolicy(Qt::CustomContextMenu); + + connect(m_view, &QWidget::customContextMenuRequested, this, + [this](const QPoint &pos) { showRowContextMenu(pos); }); + connect(m_view->verticalHeader(), &QWidget::customContextMenuRequested, this, + [this](const QPoint &pos) { showRowContextMenu(pos); }); + connect(m_view->horizontalHeader(), &QWidget::customContextMenuRequested, this, + [this](const QPoint &pos) { showColumnContextMenu(pos); }); + connect(m_view->horizontalHeader(), &QHeaderView::sectionClicked, this, + &EditableGridWidget::onSortByColumn); + + // Install event filter on header viewports for drag-to-move + m_view->horizontalHeader()->viewport()->installEventFilter(this); + m_view->verticalHeader()->viewport()->installEventFilter(this); + // Install on view itself for in-editor key handling + m_view->installEventFilter(this); +} + +int EditableGridWidget::rowCount() const +{ + return m_view->model() ? m_view->model()->rowCount() : 0; +} + +int EditableGridWidget::colCount() const +{ + return m_view->model() ? m_view->model()->columnCount() : 0; +} + +void EditableGridWidget::registerShortcuts() +{ + // Ctrl+C → copy + m_fm->registerShortcut(QKeySequence(Qt::CTRL | Qt::Key_C), FocusManager::WhenNoInput, + [this]() { copySelection('\t'); return true; }); + + // Ctrl+V → paste + m_fm->registerShortcut(QKeySequence(Qt::CTRL | Qt::Key_V), FocusManager::WhenNoInput, + [this]() { pasteSelection(); return true; }); + + // Delete → delete selected rows + m_fm->registerShortcut(QKeySequence(Qt::Key_Delete), FocusManager::WhenNoInput, + [this]() { deleteSelectedRows(); return true; }); + + // Enter/Return → edit current cell + m_fm->registerShortcut(QKeySequence(Qt::Key_Return), FocusManager::WhenNoInput, + [this]() { + QModelIndex current = m_view->currentIndex(); + if (current.isValid()) { m_view->edit(current); return true; } + return false; + }); + m_fm->registerShortcut(QKeySequence(Qt::Key_Enter), FocusManager::WhenNoInput, + [this]() { + QModelIndex current = m_view->currentIndex(); + if (current.isValid()) { m_view->edit(current); return true; } + return false; + }); + + // Arrow keys with right-wrap + auto arrowHandler = [this](int key) -> bool { + QModelIndex current = m_view->currentIndex(); + if (!current.isValid()) return false; + int visualCol = m_view->horizontalHeader()->visualIndex(current.column()); + int visualRow = m_view->verticalHeader()->visualIndex(current.row()); + int numRows = rowCount(); + int numCols = colCount(); + if (key == Qt::Key_Up) visualRow--; + if (key == Qt::Key_Down) visualRow++; + if (key == Qt::Key_Left) { + visualCol--; + if (visualCol < 0 && visualRow > 0) { + visualCol = numCols - 1; + visualRow--; + } + } + if (key == Qt::Key_Right) { + visualCol++; + if (visualCol >= numCols && visualRow < numRows - 1) { + visualCol = 0; + visualRow++; + } + } + visualRow = qBound(0, visualRow, numRows - 1); + visualCol = qBound(0, visualCol, numCols - 1); + int r = m_view->verticalHeader()->logicalIndex(visualRow); + int c = m_view->horizontalHeader()->logicalIndex(visualCol); + m_view->setCurrentIndex(m_view->model()->index(r, c)); + return true; + }; + + m_fm->registerShortcut(QKeySequence(Qt::Key_Up), FocusManager::WhenNoInput, + [=]() { return arrowHandler(Qt::Key_Up); }); + m_fm->registerShortcut(QKeySequence(Qt::Key_Down), FocusManager::WhenNoInput, + [=]() { return arrowHandler(Qt::Key_Down); }); + m_fm->registerShortcut(QKeySequence(Qt::Key_Left), FocusManager::WhenNoInput, + [=]() { return arrowHandler(Qt::Key_Left); }); + m_fm->registerShortcut(QKeySequence(Qt::Key_Right), FocusManager::WhenNoInput, + [=]() { return arrowHandler(Qt::Key_Right); }); +} + +void EditableGridWidget::setupDragToMove() +{ + m_moveDebounceTimer = new QTimer(this); + m_moveDebounceTimer->setSingleShot(true); + m_moveDebounceTimer->setInterval(0); + + connect(m_moveDebounceTimer, &QTimer::timeout, this, [this]() { + if (!m_isDraggingSection || !m_dragHeader) return; + + int newVisual = m_dragHeader->visualIndex(m_dragLogicalIndex); + bool anyMoved = (newVisual != m_dragBeforeOrder.indexOf(m_dragLogicalIndex)); + + if (anyMoved) { + bool isHorizontal = (m_dragHeader == m_view->horizontalHeader()); + QList currentOrder; + for (int v = 0; v < m_dragHeader->count(); ++v) + currentOrder.append(m_dragHeader->logicalIndex(v)); + + QList nonSelected; + for (int li : currentOrder) + if (!m_dragSelectedSections.contains(li)) nonSelected.append(li); + + QList selectedInOrder; + for (int li : m_dragBeforeOrder) + if (m_dragSelectedSections.contains(li)) selectedInOrder.append(li); + + int insertIdx = qBound(0, newVisual, nonSelected.size()); + QList targetOrder; + for (int i = 0; i < insertIdx; ++i) targetOrder.append(nonSelected[i]); + for (int li : selectedInOrder) targetOrder.append(li); + for (int i = insertIdx; i < nonSelected.size(); ++i) targetOrder.append(nonSelected[i]); + + for (int v = 0; v < targetOrder.size(); ++v) { + int logical = targetOrder[v]; + int curVisual = m_dragHeader->visualIndex(logical); + if (curVisual != v) m_dragHeader->moveSection(curVisual, v); + } + + QList afterOrder; + for (int v = 0; v < m_dragHeader->count(); ++v) + afterOrder.append(m_dragHeader->logicalIndex(v)); + m_undoStack->push(new SectionMoveCommand(m_dragHeader, m_dragBeforeOrder, afterOrder, + isHorizontal ? "Move columns" : "Move rows")); + updateRowNumbers(); + } + + m_isDraggingSection = false; + m_dragHeader->setSectionsMovable(false); + m_dragHeader = nullptr; + }); + + auto connectMoveDebounce = [this](QHeaderView *header) { + connect(header, &QHeaderView::sectionMoved, this, [this](int, int, int) { + if (m_isDraggingSection) m_moveDebounceTimer->start(); + }); + }; + connectMoveDebounce(m_view->horizontalHeader()); + connectMoveDebounce(m_view->verticalHeader()); +} + +bool EditableGridWidget::eventFilter(QObject *obj, QEvent *event) +{ + QHeaderView *hHeader = m_view->horizontalHeader(); + QHeaderView *vHeader = m_view->verticalHeader(); + + // --- Header viewport: drag-to-move --- + if (event->type() == QEvent::MouseButtonPress) { + if (obj == hHeader->viewport() || obj == vHeader->viewport()) { + QHeaderView *header = (obj == hHeader->viewport()) ? hHeader : vHeader; + auto *me = static_cast(event); + int logicalIndex = header->logicalIndexAt(me->pos()); + if (logicalIndex >= 0 && isSectionSelected(header, logicalIndex)) { + header->setSectionsMovable(true); + m_isDraggingSection = true; + m_dragHeader = header; + m_dragLogicalIndex = logicalIndex; + m_dragBeforeOrder.clear(); + for (int v = 0; v < header->count(); ++v) + m_dragBeforeOrder.append(header->logicalIndex(v)); + + bool isHorizontal = (header == hHeader); + m_dragSelectedSections.clear(); + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + for (const QModelIndex &idx : sel) { + int li = isHorizontal ? idx.column() : idx.row(); + m_dragSelectedSections.insert(li); + } + + QItemSelection savedSel = m_view->selectionModel()->selection(); + QTimer::singleShot(0, this, [this, savedSel]() { + if (m_isDraggingSection) + m_view->selectionModel()->select(savedSel, QItemSelectionModel::ClearAndSelect); + }); + } else { + header->setSectionsMovable(false); + } + } + } + + // --- In-editor key handling --- + if (event->type() == QEvent::KeyPress && obj == m_view && m_fm->activeInput()) { + auto *ke = static_cast(event); + QAbstractItemModel *model = m_view->model(); + if (!model) return QWidget::eventFilter(obj, event); + + if (ke->key() == Qt::Key_Escape) { + QModelIndex current = m_view->currentIndex(); + if (current.isValid()) { + QVariant oldData = model->data(current, Qt::UserRole); + if (oldData.isValid()) { + m_isProgrammaticChange = true; + model->setData(current, oldData, Qt::EditRole); + model->setData(current, QVariant(), Qt::UserRole); + m_isProgrammaticChange = false; + } + } + m_view->closePersistentEditor(m_view->currentIndex()); + return true; + } + + if (ke->key() == Qt::Key_Up || ke->key() == Qt::Key_Down) { + QModelIndex current = m_view->currentIndex(); + int r = current.row(), c = current.column(); + if (ke->key() == Qt::Key_Up) r--; + if (ke->key() == Qt::Key_Down) r++; + r = qBound(0, r, rowCount() - 1); + + // Cancel current edit without saving + if (current.isValid()) { + QVariant oldData = model->data(current, Qt::UserRole); + if (oldData.isValid()) { + m_isProgrammaticChange = true; + model->setData(current, oldData, Qt::EditRole); + model->setData(current, QVariant(), Qt::UserRole); + m_isProgrammaticChange = false; + } + } + m_view->closePersistentEditor(m_view->currentIndex()); + QModelIndex target = model->index(r, c); + m_view->setCurrentIndex(target); + m_view->edit(target); + return true; + } + + if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { + QModelIndex current = m_view->currentIndex(); + int r = current.row(), c = current.column(); + + // Commit via delegate + QAbstractItemDelegate *delegate = m_view->itemDelegateForIndex(current); + QWidget *editor = m_fm->activeInput(); + if (delegate && editor) + delegate->setModelData(editor, model, current); + + // Navigate right (wrap to next row) + int visualCol = m_view->horizontalHeader()->visualIndex(c); + int visualRow = m_view->verticalHeader()->visualIndex(r); + visualCol++; + if (visualCol >= colCount()) { + visualCol = 0; + visualRow++; + } + if (visualRow < rowCount()) { + int nr = m_view->verticalHeader()->logicalIndex(visualRow); + int nc = m_view->horizontalHeader()->logicalIndex(visualCol); + QTimer::singleShot(0, this, [this, nr, nc]() { + if (m_view->model()) + m_view->setCurrentIndex(m_view->model()->index(nr, nc)); + }); + } + return true; + } + } + + return QWidget::eventFilter(obj, event); +} + +QTableView *EditableGridWidget::view() const { return m_view; } +GridMode EditableGridWidget::mode() const { return m_mode; } +QUndoStack *EditableGridWidget::undoStack() const { return m_undoStack; } +bool EditableGridWidget::isDirty() const { return !m_undoStack->isClean(); } + +void EditableGridWidget::setWordWrap(bool wrap) +{ + m_wrapDelegate->setWrapAnywhere(wrap); + m_view->setWordWrap(wrap); + if (wrap) { + m_view->resizeRowsToContents(); + } else { + m_view->verticalHeader()->setDefaultSectionSize(m_view->fontMetrics().height() + 8); + m_view->resizeRowsToContents(); + } +} + +bool EditableGridWidget::wordWrap() const { return m_wrapDelegate->wrapAnywhere(); } +void EditableGridWidget::setShowGrid(bool show) { m_view->setShowGrid(show); } + +void EditableGridWidget::setFilterRow(FilterRowWidget *filterRow) { m_filterRow = filterRow; } +void EditableGridWidget::setThemeToggleEnabled(bool enabled) { m_themeToggleEnabled = enabled; } + +void EditableGridWidget::setExtraContextMenuCallback( + std::function callback) +{ + m_extraMenuCallback = std::move(callback); +} + +void EditableGridWidget::onDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, + const QList &roles) +{ + Q_UNUSED(bottomRight); + Q_UNUSED(roles); + if (m_isProgrammaticChange || !topLeft.isValid()) return; + + QAbstractItemModel *model = m_view->model(); + if (!model) return; + + QModelIndex sourceIndex = topLeft; + QAbstractItemModel *sourceModel = model; + if (auto *proxy = qobject_cast(model)) { + sourceModel = proxy->sourceModel(); + sourceIndex = proxy->mapToSource(topLeft); + } + + QVariant oldData = sourceModel->data(sourceIndex, Qt::UserRole); + if (!oldData.isValid()) return; + + QVariant newValue = sourceModel->data(sourceIndex, Qt::EditRole); + QString oldText = oldData.toString(); + QString newText = newValue.toString(); + + m_isProgrammaticChange = true; + sourceModel->setData(sourceIndex, QVariant(), Qt::UserRole); // clear stash + m_isProgrammaticChange = false; + + if (oldText != newText) { + m_isProgrammaticChange = true; + sourceModel->setData(sourceIndex, oldData, Qt::EditRole); // revert so undo command applies it + m_isProgrammaticChange = false; + m_undoStack->push(new EditCellCommand(sourceModel, sourceIndex, oldData, newValue)); + } +} + +void EditableGridWidget::onSortByColumn(int column) +{ + QAbstractItemModel *model = m_view->model(); + if (!model) return; + + if (column != m_lastSortColumn) { + m_lastSortColumn = column; + m_lastSortOrder = Qt::AscendingOrder; + m_view->horizontalHeader()->setSortIndicator(-1, Qt::AscendingOrder); + return; + } + + // --- LiveDatabase mode --- + // Do not snapshot memory. Just issue the ORDER BY command via model->sort(). + // QSqlTableModel translates this to an SQL query and re-fetches lazily. + if (m_mode == GridMode::LiveDatabase) { + QAbstractItemModel *targetModel = model; + if (auto *proxy = qobject_cast(model)) { + if (proxy->sourceModel()) + targetModel = proxy->sourceModel(); + } + targetModel->sort(column, m_lastSortOrder); + m_view->horizontalHeader()->setSortIndicatorShown(true); + m_view->horizontalHeader()->setSortIndicator(column, m_lastSortOrder); + m_lastSortOrder = (m_lastSortOrder == Qt::AscendingOrder) ? Qt::DescendingOrder : Qt::AscendingOrder; + return; + } + + // --- MemoryDocument mode --- + // Safely snapshot in-memory data for undo/redo. + int rows = rowCount(), cols = colCount(); + + QList beforeData; + for (int r = 0; r < rows; ++r) { + QVariantList row; + for (int c = 0; c < cols; ++c) + row << model->data(model->index(r, c), Qt::EditRole); + beforeData << row; + } + + model->sort(column, m_lastSortOrder); + + QList afterData; + for (int r = 0; r < rows; ++r) { + QVariantList row; + for (int c = 0; c < cols; ++c) + row << model->data(model->index(r, c), Qt::EditRole); + afterData << row; + } + + m_undoStack->push(new DataSnapshotCommand(model, beforeData, afterData, "Sort")); + m_view->horizontalHeader()->setSortIndicatorShown(true); + m_view->horizontalHeader()->setSortIndicator(column, m_lastSortOrder); + m_lastSortOrder = (m_lastSortOrder == Qt::AscendingOrder) ? Qt::DescendingOrder : Qt::AscendingOrder; +} + +void EditableGridWidget::updateRowNumbers() +{ + QAbstractItemModel *model = m_view->model(); + if (!model) return; + QHeaderView *vh = m_view->verticalHeader(); + for (int v = 0; v < rowCount(); ++v) { + int logical = vh->logicalIndex(v); + model->setHeaderData(logical, Qt::Vertical, QString::number(v + 1)); + } +} + +bool EditableGridWidget::isSectionSelected(QHeaderView *header, int logicalIndex) const +{ + QItemSelectionModel *sel = m_view->selectionModel(); + QAbstractItemModel *model = m_view->model(); + if (!sel || !model) return false; + bool isHorizontal = (header == m_view->horizontalHeader()); + if (isHorizontal) { + for (int r = 0; r < rowCount(); ++r) + if (!sel->isSelected(model->index(r, logicalIndex))) return false; + return rowCount() > 0; + } else { + for (int c = 0; c < colCount(); ++c) + if (!sel->isSelected(model->index(logicalIndex, c))) return false; + return colCount() > 0; + } +} + +// --------------------------------------------------------------------------- +// Copy / Paste / Insert / Delete — all via QAbstractItemModel +// --------------------------------------------------------------------------- + +void EditableGridWidget::copySelection(char separator) +{ + QString text = getSelectionAsText(separator); + if (!text.isEmpty()) + QApplication::clipboard()->setText(text); +} + +QString EditableGridWidget::getSelectionAsText(char separator) +{ + QAbstractItemModel *model = m_view->model(); + if (!model || !m_view->selectionModel()) return {}; + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (sel.isEmpty()) return {}; + + int minVRow = rowCount(), maxVRow = -1; + int minVCol = colCount(), maxVCol = -1; + for (const auto &index : sel) { + int vr = m_view->verticalHeader()->visualIndex(index.row()); + int vc = m_view->horizontalHeader()->visualIndex(index.column()); + if (vr < minVRow) minVRow = vr; if (vr > maxVRow) maxVRow = vr; + if (vc < minVCol) minVCol = vc; if (vc > maxVCol) maxVCol = vc; + } + + QString outText; + for (int vr = minVRow; vr <= maxVRow; ++vr) { + int r = m_view->verticalHeader()->logicalIndex(vr); + QStringList rowItems; + for (int vc = minVCol; vc <= maxVCol; ++vc) { + int c = m_view->horizontalHeader()->logicalIndex(vc); + QModelIndex idx = model->index(r, c); + QString cellText; + if (m_view->selectionModel()->isSelected(idx)) + cellText = model->data(idx, Qt::DisplayRole).toString(); + rowItems << cellText; + } + outText += rowItems.join(separator) + "\n"; + } + return outText; +} + +void EditableGridWidget::pasteSelection() +{ + QAbstractItemModel *model = m_view->model(); + if (!model) return; + + if (m_mode == GridMode::LiveDatabase) { + // LiveDatabase: insert at end without undo wrapping + pasteSelectionAt(rowCount()); + return; + } + + // MemoryDocument: insert at selection with undo macro for reorder + int targetVisualRow = rowCount(); + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (!sel.isEmpty()) { + int minVRow = rowCount(); + for (const auto &index : sel) { + int vr = m_view->verticalHeader()->visualIndex(index.row()); + if (vr < minVRow) minVRow = vr; + } + targetVisualRow = minVRow; + } + + int endRow = rowCount(); + bool needsMove = (targetVisualRow < endRow); + + QList beforeOrder; + QHeaderView *vh = m_view->verticalHeader(); + if (needsMove) { + for (int v = 0; v < vh->count(); ++v) + beforeOrder.append(vh->logicalIndex(v)); + } + + if (needsMove) m_undoStack->beginMacro("Paste rows"); + pasteSelectionAt(endRow); + + int rowsInserted = rowCount() - endRow; + if (rowsInserted > 0 && needsMove) { + QList midOrder; + for (int v = 0; v < vh->count(); ++v) midOrder.append(vh->logicalIndex(v)); + + for (int i = 0; i < rowsInserted; ++i) { + int logicalRow = endRow + i; + int curVisual = vh->visualIndex(logicalRow); + vh->moveSection(curVisual, targetVisualRow + i); + } + + QList afterOrder; + for (int v = 0; v < vh->count(); ++v) afterOrder.append(vh->logicalIndex(v)); + m_undoStack->push(new SectionMoveCommand(vh, midOrder, afterOrder, "Move pasted rows")); + updateRowNumbers(); + } + if (needsMove) m_undoStack->endMacro(); +} + +void EditableGridWidget::pasteSelectionAt(int atRow) +{ + QAbstractItemModel *model = m_view->model(); + if (!model) return; + int targetCols = colCount(); + if (targetCols <= 0) return; + + QString text = QApplication::clipboard()->text(); + if (text.isEmpty()) return; + + QStringList lines = text.split(QRegularExpression("\r?\n")); + if (!lines.isEmpty() && lines.last().isEmpty()) lines.removeLast(); + if (lines.isEmpty()) return; + + // Detect separator: try tab first, then comma + char sep = '\t'; + QStringList testList = lines.first().split(QLatin1Char('\t')); + if (testList.size() != targetCols) { + QStringList commaTest = lines.first().split(QLatin1Char(',')); + if (commaTest.size() == targetCols) sep = ','; + else if (testList.size() != targetCols) return; + } + + int rowsToInsert = lines.size(); + + QAbstractItemModel *targetModel = model; + int targetRow = atRow; + if (auto *proxy = qobject_cast(model)) { + targetModel = proxy->sourceModel(); + if (atRow < proxy->rowCount()) { + QModelIndex proxyIdx = proxy->index(atRow, 0); + targetRow = proxy->mapToSource(proxyIdx).row(); + } else { + targetRow = targetModel->rowCount(); + } + } + + if (m_mode == GridMode::LiveDatabase) { + // Direct model insertion, no undo command wrapper + targetModel->insertRows(targetRow, rowsToInsert); + } else { + m_isProgrammaticChange = true; + m_undoStack->push(new RowColCommand(targetModel, targetRow, rowsToInsert, true, true)); + m_isProgrammaticChange = false; + } + + for (int i = 0; i < rowsToInsert; ++i) { + QStringList list = lines.at(i).split(QLatin1Char(sep)); + for (int vc = 0; vc < targetCols; ++vc) { + int c = m_view->horizontalHeader()->logicalIndex(vc); + QString cellText = vc < list.size() ? list.at(vc).trimmed() : ""; + m_isProgrammaticChange = true; + targetModel->setData(targetModel->index(targetRow + i, c), cellText, Qt::EditRole); + m_isProgrammaticChange = false; + } + } +} + +void EditableGridWidget::insertRows(int count, int atRow) +{ + QAbstractItemModel *model = m_view->model(); + if (!model || colCount() <= 0 || count <= 0) return; + + QAbstractItemModel *targetModel = model; + int targetRow = atRow; + if (auto *proxy = qobject_cast(model)) { + targetModel = proxy->sourceModel(); + if (atRow < proxy->rowCount()) { + QModelIndex proxyIdx = proxy->index(atRow, 0); + targetRow = proxy->mapToSource(proxyIdx).row(); + } else { + targetRow = targetModel->rowCount(); + } + } + + if (m_mode == GridMode::LiveDatabase) { + targetModel->insertRows(targetRow, count); + return; + } + m_undoStack->push(new RowColCommand(targetModel, targetRow, count, true, true)); +} + +void EditableGridWidget::deleteSelectedRows() +{ + QAbstractItemModel *model = m_view->model(); + if (!model || !m_view->selectionModel()) return; + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (sel.isEmpty()) return; + + QAbstractItemModel *targetModel = model; + QSortFilterProxyModel *proxy = qobject_cast(model); + if (proxy) { + targetModel = proxy->sourceModel(); + } + + QSet rowsSet; + for (const auto &index : sel) { + if (proxy) { + rowsSet.insert(proxy->mapToSource(index).row()); + } else { + rowsSet.insert(index.row()); + } + } + QList rowsToDelete = rowsSet.values(); + std::sort(rowsToDelete.begin(), rowsToDelete.end(), std::greater()); + + if (m_mode == GridMode::LiveDatabase) { + for (int r : rowsToDelete) + targetModel->removeRow(r); + return; + } + + m_undoStack->beginMacro("Delete rows"); + for (int r : rowsToDelete) + m_undoStack->push(new RowColCommand(targetModel, r, 1, true, false)); + m_undoStack->endMacro(); +} + +void EditableGridWidget::copyColumnSelection(char separator) +{ + QAbstractItemModel *model = m_view->model(); + if (!model || !m_view->selectionModel()) return; + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (sel.isEmpty()) return; + + int minCol = colCount(), maxCol = -1; + for (const auto &index : sel) { + if (index.column() < minCol) minCol = index.column(); + if (index.column() > maxCol) maxCol = index.column(); + } + + QString outText; + for (int r = 0; r < rowCount(); ++r) { + QStringList rowItems; + for (int c = minCol; c <= maxCol; ++c) + rowItems << model->data(model->index(r, c), Qt::DisplayRole).toString(); + outText += rowItems.join(separator) + "\n"; + } + QApplication::clipboard()->setText(outText); +} + +void EditableGridWidget::pasteColumnSelectionAt(int atCol) +{ + QAbstractItemModel *model = m_view->model(); + if (!model) return; + + QAbstractItemModel *targetModel = model; + if (auto *proxy = qobject_cast(model)) + targetModel = proxy->sourceModel(); + + QString text = QApplication::clipboard()->text(); + if (text.isEmpty()) return; + + QStringList lines = text.split(QRegularExpression("\r?\n")); + if (!lines.isEmpty() && lines.last().isEmpty()) lines.removeLast(); + if (lines.isEmpty()) return; + + if (lines.size() != rowCount()) { + QMessageBox::warning(this, "Paste Error", + QString("Clipboard contains %1 rows, but table has %2.") + .arg(lines.size()).arg(rowCount())); + return; + } + + char sep = '\t'; + int colsToInsert = lines.first().split(QLatin1Char('\t')).size(); + if (colsToInsert <= 1) { + sep = ','; + colsToInsert = lines.first().split(QLatin1Char(',')).size(); + } + + if (m_mode == GridMode::LiveDatabase) { + // Direct model insertion, no undo command wrapper + targetModel->insertColumns(atCol, colsToInsert); + } else { + m_isProgrammaticChange = true; + m_undoStack->push(new RowColCommand(targetModel, atCol, colsToInsert, false, true)); + m_isProgrammaticChange = false; + } + + QSortFilterProxyModel *proxy = qobject_cast(model); + for (int r = 0; r < rowCount(); ++r) { + int targetRow = r; + if (proxy) { + QModelIndex proxyIdx = proxy->index(r, 0); + targetRow = proxy->mapToSource(proxyIdx).row(); + } + QStringList list = lines.at(r).split(QLatin1Char(sep)); + for (int c = 0; c < colsToInsert; ++c) { + QString cellText = c < list.size() ? list.at(c).trimmed() : ""; + m_isProgrammaticChange = true; + targetModel->setData(targetModel->index(targetRow, atCol + c), cellText, Qt::EditRole); + m_isProgrammaticChange = false; + } + } +} + +void EditableGridWidget::insertColumns(int count, int atCol) +{ + QAbstractItemModel *model = m_view->model(); + if (!model || rowCount() <= 0 || count <= 0) return; + + QAbstractItemModel *targetModel = model; + if (auto *proxy = qobject_cast(model)) + targetModel = proxy->sourceModel(); + + if (m_mode == GridMode::LiveDatabase) { + targetModel->insertColumns(atCol, count); + return; + } + m_undoStack->push(new RowColCommand(targetModel, atCol, count, false, true)); +} + +void EditableGridWidget::deleteSelectedColumns() +{ + QAbstractItemModel *model = m_view->model(); + if (!model || !m_view->selectionModel()) return; + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (sel.isEmpty()) return; + + QAbstractItemModel *targetModel = model; + if (auto *proxy = qobject_cast(model)) + targetModel = proxy->sourceModel(); + + QSet colsSet; + for (const auto &index : sel) colsSet.insert(index.column()); + QList colsToDelete = colsSet.values(); + std::sort(colsToDelete.begin(), colsToDelete.end(), std::greater()); + + if (m_mode == GridMode::LiveDatabase) { + for (int c : colsToDelete) + targetModel->removeColumn(c); + return; + } + + m_undoStack->beginMacro("Delete cols"); + for (int c : colsToDelete) + m_undoStack->push(new RowColCommand(targetModel, c, 1, false, false)); + m_undoStack->endMacro(); +} + +// --------------------------------------------------------------------------- +// Context Menus +// --------------------------------------------------------------------------- + +void EditableGridWidget::showRowContextMenu(const QPoint &pos) +{ + QAbstractItemModel *model = m_view->model(); + if (!model) return; + + QMenu menu(this); + + // --- Copy cell / Copy row --- + QModelIndex clickedIdx = m_view->indexAt(pos); + QAction *actCopyCell = nullptr; + QAction *actCopyRow = nullptr; + + if (clickedIdx.isValid()) { + actCopyCell = menu.addAction(QStringLiteral("Copy cell")); + actCopyRow = menu.addAction(QStringLiteral("Copy row")); + menu.addSeparator(); + } + + // --- Selection-based actions --- + QAction *actCopyTSV = nullptr, *actCopyCSV = nullptr, *actDelete = nullptr; + QAction *actInsertAbove = nullptr, *actInsertBelow = nullptr; + QAction *actPasteAbove = nullptr, *actPasteBelow = nullptr; + + int minRow = rowCount(), maxRow = -1, numRows = 0; + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (!sel.isEmpty()) { + QSet rows; + for (const auto &index : sel) { + rows.insert(index.row()); + if (index.row() < minRow) minRow = index.row(); + if (index.row() > maxRow) maxRow = index.row(); + } + numRows = rows.size(); + actCopyTSV = menu.addAction("Copy Selection as TSV"); + actCopyCSV = menu.addAction("Copy Selection as CSV"); + menu.addSeparator(); + actDelete = menu.addAction("Delete Selected Rows"); + } else { + int clickedRow = m_view->rowAt(pos.y()); + if (clickedRow >= 0) { minRow = maxRow = clickedRow; numRows = 1; } + } + + if (numRows > 0) { + menu.addSeparator(); + QString rowStr = (numRows == 1) ? "1 row" : QString("%1 rows").arg(numRows); + actInsertAbove = menu.addAction(QString("Insert %1 above").arg(rowStr)); + actInsertBelow = menu.addAction(QString("Insert %1 below").arg(rowStr)); + if (!QApplication::clipboard()->text().isEmpty()) { + menu.addSeparator(); + actPasteAbove = menu.addAction("Insert from Clipboard above"); + actPasteBelow = menu.addAction("Insert from Clipboard below"); + } + } + + // --- Extra callback (KV binary ops, etc.) --- + if (m_extraMenuCallback && clickedIdx.isValid()) { + menu.addSeparator(); + m_extraMenuCallback(&menu, clickedIdx); + } + + // --- Filters and Dark theme toggles --- + menu.addSeparator(); + + QAction *actFilters = nullptr; + if (m_filterRow) { + actFilters = menu.addAction(QStringLiteral("Filters")); + actFilters->setCheckable(true); + actFilters->setChecked(m_filterRow->isFilterVisible()); + } + + QAction *actDarkTheme = nullptr; + if (m_themeToggleEnabled) { + actDarkTheme = menu.addAction(QStringLiteral("Dark theme")); + actDarkTheme->setCheckable(true); + actDarkTheme->setChecked(ThemeManager::isDark()); + } + + QAction *res = menu.exec(m_view->viewport()->mapToGlobal(pos)); + QTimer::singleShot(0, this, [this]() { m_fm->restoreViewFocus(); }); + if (!res) return; + + // Handle results + if (res == actCopyCell && clickedIdx.isValid()) { + QString text = model->data(clickedIdx, Qt::DisplayRole).toString(); + QApplication::clipboard()->setText(text); + } else if (res == actCopyRow && clickedIdx.isValid()) { + int row = clickedIdx.row(); + QStringList cells; + for (int c = 0; c < model->columnCount(); ++c) + cells << model->data(model->index(row, c), Qt::DisplayRole).toString(); + QApplication::clipboard()->setText(cells.join(QChar('\t'))); + } else if (res == actCopyTSV) copySelection('\t'); + else if (res == actCopyCSV) copySelection(','); + else if (res == actDelete) deleteSelectedRows(); + else if (res == actInsertAbove) insertRows(numRows, minRow); + else if (res == actInsertBelow) insertRows(numRows, maxRow + 1); + else if (res == actPasteAbove) pasteSelectionAt(minRow); + else if (res == actPasteBelow) pasteSelectionAt(maxRow + 1); + else if (res == actFilters && m_filterRow) { + m_filterRow->setFilterVisible(!m_filterRow->isFilterVisible()); + } else if (res == actDarkTheme) { + ThemeManager::toggleTheme(window()); + } +} + +void EditableGridWidget::showColumnContextMenu(const QPoint &pos) +{ + QAbstractItemModel *model = m_view->model(); + if (!model) return; + + QMenu menu(this); + QAction *actCopy = nullptr, *actDelete = nullptr; + QAction *actInsertLeft = nullptr, *actInsertRight = nullptr; + QAction *actPasteLeft = nullptr, *actPasteRight = nullptr; + + int minCol = colCount(), maxCol = -1, numCols = 0; + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (!sel.isEmpty()) { + QSet cols; + for (const auto &index : sel) { + cols.insert(index.column()); + if (index.column() < minCol) minCol = index.column(); + if (index.column() > maxCol) maxCol = index.column(); + } + numCols = cols.size(); + actCopy = menu.addAction("Copy Columns"); + menu.addSeparator(); + actDelete = menu.addAction("Delete Selected Columns"); + } else { + int clickedCol = m_view->columnAt(pos.x()); + if (clickedCol >= 0) { minCol = maxCol = clickedCol; numCols = 1; } + } + + if (numCols > 0) { + menu.addSeparator(); + QString colStr = (numCols == 1) ? "1 col" : QString("%1 cols").arg(numCols); + actInsertLeft = menu.addAction(QString("Insert %1 left").arg(colStr)); + actInsertRight = menu.addAction(QString("Insert %1 right").arg(colStr)); + if (!QApplication::clipboard()->text().isEmpty()) { + menu.addSeparator(); + actPasteLeft = menu.addAction("Insert from Clipboard left"); + actPasteRight = menu.addAction("Insert from Clipboard right"); + } + } + + QAction *res = menu.exec(m_view->horizontalHeader()->viewport()->mapToGlobal(pos)); + QTimer::singleShot(0, this, [this]() { m_fm->restoreViewFocus(); }); + if (!res) return; + + if (res == actCopy) copyColumnSelection('\t'); + else if (res == actDelete) deleteSelectedColumns(); + else if (res == actInsertLeft) insertColumns(numCols, minCol); + else if (res == actInsertRight) insertColumns(numCols, maxCol + 1); + else if (res == actPasteLeft) pasteColumnSelectionAt(minCol); + else if (res == actPasteRight) pasteColumnSelectionAt(maxCol + 1); +} + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/EncodingUtils.cpp b/wlx/wlxbase_wlqt/src/EncodingUtils.cpp new file mode 100644 index 0000000..9523459 --- /dev/null +++ b/wlx/wlxbase_wlqt/src/EncodingUtils.cpp @@ -0,0 +1,122 @@ +#include + +#include +#include +#include +#include +#include + +namespace QtWlPlugin { + +QString EncodingUtils::detectEncoding(const QByteArray &data, const QString &language) +{ + if (data.isEmpty()) + return {}; + + QString lang = language; + if (lang.isEmpty()) { + // Derive from current locale without changing it + const char *loc = setlocale(LC_ALL, nullptr); + if (loc && strlen(loc) >= 2) + lang = QString::fromLatin1(loc, 2); + else + lang = "__"; + } + + EncaAnalyser analyser = enca_analyser_alloc(lang.toStdString().c_str()); + if (!analyser) + return {}; + + enca_set_threshold(analyser, 1.38); + enca_set_multibyte(analyser, 1); + enca_set_ambiguity(analyser, 1); + enca_set_garbage_test(analyser, 1); + enca_set_filtering(analyser, 0); + + EncaEncoding encoding = enca_analyse(analyser, + (unsigned char *)data.data(), (size_t)data.size()); + + QString result; + if (encoding.charset > 0 && encoding.charset != 27) { + const char *name = enca_charset_name(encoding.charset, ENCA_NAME_STYLE_ICONV); + if (name) + result = QString::fromLatin1(name); + } + + enca_analyser_free(analyser); + return result; +} + +QString EncodingUtils::detectFileEncoding(const QString &filePath, const QString &language, + int sampleSize, bool readAll) +{ + QFile file(filePath); + if (!file.open(QFile::ReadOnly)) + return {}; + + QByteArray data = readAll ? file.readAll() : file.read(sampleSize); + file.close(); + + return detectEncoding(data, language); +} + +QByteArray EncodingUtils::toUtf8(const QByteArray &data, const QString &fromEncoding) +{ + if (fromEncoding.isEmpty() || fromEncoding.compare("UTF-8", Qt::CaseInsensitive) == 0) + return data; + + gsize len; + gchar *converted = g_convert_with_fallback( + data.data(), data.size(), + "UTF-8", fromEncoding.toStdString().c_str(), + NULL, NULL, &len, NULL); + + if (converted) { + QByteArray result(converted, len); + g_free(converted); + return result; + } + return data; +} + +QByteArray EncodingUtils::fromUtf8(const QString &text, const QString &toEncoding) +{ + if (toEncoding.isEmpty() || toEncoding.compare("UTF-8", Qt::CaseInsensitive) == 0) + return text.toUtf8(); + + QByteArray utf8 = text.toUtf8(); + gsize len; + gchar *converted = g_convert_with_fallback( + utf8.data(), utf8.size(), + toEncoding.toStdString().c_str(), "UTF-8", + NULL, NULL, &len, NULL); + + if (converted) { + QByteArray result(converted, len); + g_free(converted); + return result; + } + return utf8; +} + +QString EncodingUtils::decodeToString(const QByteArray &data, const QString &language) +{ + QString encoding = detectEncoding(data, language); + if (encoding.isEmpty()) + return QString::fromUtf8(data); + + QByteArray utf8 = toUtf8(data, encoding); + return QString::fromUtf8(utf8); +} + +bool EncodingUtils::isEncaAvailable() +{ + EncaAnalyser analyser = enca_analyser_alloc("__"); + if (analyser) { + enca_analyser_free(analyser); + return true; + } + return false; +} + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/FilterRowWidget.cpp b/wlx/wlxbase_wlqt/src/FilterRowWidget.cpp new file mode 100644 index 0000000..0e61cdc --- /dev/null +++ b/wlx/wlxbase_wlqt/src/FilterRowWidget.cpp @@ -0,0 +1,102 @@ +#include + +#include +#include +#include +#include +#include +#include + +namespace QtWlPlugin { + +FilterRowWidget::FilterRowWidget(QTableView *view, QWidget *parent) + : QWidget(parent) + , m_view(view) + , m_layout(new QHBoxLayout(this)) +{ + m_layout->setContentsMargins(0, 0, 0, 0); + m_layout->setSpacing(0); + setFixedHeight(24); + + // Track header geometry changes to sync widths + if (m_view->horizontalHeader()) + m_view->horizontalHeader()->installEventFilter(this); + + rebuildInputs(); +} + +void FilterRowWidget::rebuildInputs() +{ + // Clear existing + for (auto *input : m_inputs) + delete input; + m_inputs.clear(); + + QAbstractItemModel *model = m_view->model(); + if (!model) return; + + int cols = model->columnCount(); + for (int c = 0; c < cols; ++c) { + auto *input = new QLineEdit(this); + input->setPlaceholderText(QStringLiteral("Filter...")); + input->setMaximumHeight(22); + input->setFrame(true); + input->setClearButtonEnabled(true); + + int col = c; + connect(input, &QLineEdit::textChanged, this, [this, col](const QString &text) { + emit filterChanged(col, text); + }); + + m_layout->addWidget(input); + m_inputs.append(input); + } + + syncWidths(); +} + +void FilterRowWidget::syncWidths() +{ + QHeaderView *header = m_view->horizontalHeader(); + if (!header) return; + + for (int c = 0; c < m_inputs.size(); ++c) { + int w = header->sectionSize(c); + m_inputs[c]->setFixedWidth(w); + } +} + +void FilterRowWidget::setFilterVisible(bool visible) +{ + m_visible = visible; + setVisible(visible); +} + +bool FilterRowWidget::isFilterVisible() const +{ + return m_visible; +} + +void FilterRowWidget::clearFilters() +{ + for (auto *input : m_inputs) + input->clear(); +} + +void FilterRowWidget::syncToModel() +{ + rebuildInputs(); +} + +bool FilterRowWidget::eventFilter(QObject *obj, QEvent *event) +{ + if (obj == m_view->horizontalHeader()) { + if (event->type() == QEvent::Resize + || event->type() == QEvent::LayoutRequest) { + syncWidths(); + } + } + return QWidget::eventFilter(obj, event); +} + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/FilterableHeaderView.cpp b/wlx/wlxbase_wlqt/src/FilterableHeaderView.cpp new file mode 100644 index 0000000..f41e7c7 --- /dev/null +++ b/wlx/wlxbase_wlqt/src/FilterableHeaderView.cpp @@ -0,0 +1,173 @@ +#include + +#include +#include +#include +#include + +namespace QtWlPlugin { + +FilterableHeaderView::FilterableHeaderView(Qt::Orientation orientation, QWidget *parent) + : QHeaderView(orientation, parent) +{ + // Re-position filter inputs whenever sections change + connect(this, &QHeaderView::sectionResized, this, &FilterableHeaderView::adjustInputPositions); + connect(this, &QHeaderView::sectionMoved, this, &FilterableHeaderView::adjustInputPositions); + connect(this, &QHeaderView::geometriesChanged, this, &FilterableHeaderView::adjustInputPositions); +} + +void FilterableHeaderView::setFilterEnabled(bool enabled) +{ + if (m_filterEnabled == enabled) + return; + m_filterEnabled = enabled; + + if (m_filterEnabled) { + rebuildInputs(); + } else { + for (auto *input : m_inputs) + delete input; + m_inputs.clear(); + } + + // Force header to recalculate its size + updateGeometries(); + viewport()->update(); +} + +bool FilterableHeaderView::isFilterEnabled() const +{ + return m_filterEnabled; +} + +void FilterableHeaderView::clearFilters() +{ + for (auto *input : m_inputs) + input->clear(); +} + +QString FilterableHeaderView::filterText(int column) const +{ + if (column >= 0 && column < m_inputs.size()) + return m_inputs[column]->text(); + return {}; +} + +void FilterableHeaderView::setHeaderVisible(bool visible) +{ + if (m_headerVisible == visible) + return; + m_headerVisible = visible; + updateGeometries(); + viewport()->update(); +} + +bool FilterableHeaderView::isHeaderVisible() const +{ + return m_headerVisible; +} + +QSize FilterableHeaderView::sizeHint() const +{ + int h = 0; + if (m_headerVisible) { + h += QHeaderView::sizeHint().height(); + } + if (m_filterEnabled) { + h += m_filterRowHeight; + } + return QSize(QHeaderView::sizeHint().width(), h); +} + +void FilterableHeaderView::updateGeometries() +{ + int bottomMargin = 0; + if (m_filterEnabled && m_headerVisible) { + bottomMargin = m_filterRowHeight; + } + setViewportMargins(0, 0, 0, bottomMargin); + + QHeaderView::updateGeometries(); + + if (m_filterEnabled) + adjustInputPositions(); +} + +void FilterableHeaderView::paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const +{ + if (!m_headerVisible) { + painter->save(); + QStyleOptionHeader opt; + initStyleOption(&opt); + opt.section = logicalIndex; + opt.rect = rect; + opt.text.clear(); + opt.icon = QIcon(); + style()->drawControl(QStyle::CE_HeaderSection, &opt, painter, this); + painter->restore(); + return; + } + QHeaderView::paintSection(painter, rect, logicalIndex); +} + +void FilterableHeaderView::adjustInputPositions() +{ + if (!m_filterEnabled || !model()) + return; + + int cols = model()->columnCount(); + + // Rebuild if column count changed + if (m_inputs.size() != cols) + rebuildInputs(); + + int labelHeight = m_headerVisible ? QHeaderView::sizeHint().height() : 0; + + for (int i = 0; i < m_inputs.size() && i < cols; ++i) { + int logicalIdx = logicalIndex(i); + if (logicalIdx < 0 || logicalIdx >= m_inputs.size()) + continue; + + int xPos = sectionViewportPosition(logicalIdx); + int w = sectionSize(logicalIdx); + + m_inputs[logicalIdx]->setGeometry(xPos, labelHeight, w, m_filterRowHeight); + m_inputs[logicalIdx]->setVisible(!isSectionHidden(logicalIdx)); + } +} + +void FilterableHeaderView::rebuildInputs() +{ + for (auto *input : m_inputs) + delete input; + m_inputs.clear(); + + if (!model() || !m_filterEnabled) + return; + + int cols = model()->columnCount(); + m_inputs.reserve(cols); + + for (int c = 0; c < cols; ++c) { + auto *input = new QLineEdit(this); + input->setPlaceholderText(QStringLiteral("Filter...")); + input->setFrame(true); + input->setClearButtonEnabled(true); + + // Use a small font to fit the compact row height + QFont f = input->font(); + f.setPointSize(f.pointSize() - 1); + input->setFont(f); + + int col = c; + connect(input, &QLineEdit::textChanged, this, [this, col](const QString &text) { + emit filterChanged(col, text); + }); + + m_inputs.append(input); + } + + adjustInputPositions(); +} + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/FindReplacePanel.cpp b/wlx/wlxbase_wlqt/src/FindReplacePanel.cpp new file mode 100644 index 0000000..409b71c --- /dev/null +++ b/wlx/wlxbase_wlqt/src/FindReplacePanel.cpp @@ -0,0 +1,151 @@ +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace QtWlPlugin { + +FindReplacePanel::FindReplacePanel(FocusManager *fm, QWidget *parent) + : QWidget(parent) + , m_fm(fm) +{ + setObjectName("FindReplacePanel"); + setVisible(false); + setStyleSheet( + "QWidget#FindReplacePanel { background-color: palette(window); border-top: 1px solid palette(mid); }" + "QPushButton { border: 1px solid palette(mid); border-radius: 3px; padding: 2px 8px; background-color: palette(button); }" + "QPushButton:hover { background-color: palette(light); }" + "QPushButton:pressed { background-color: palette(midlight); }" + "QPushButton#CloseButton { border: none; background: transparent; }" + "QPushButton#CloseButton:hover { background-color: palette(light); }" + ); + + auto *panelLayout = new QVBoxLayout(this); + panelLayout->setContentsMargins(6, 6, 6, 6); + panelLayout->setSpacing(6); + + // --- Row 1: Find + Replace inputs --- + auto *row1 = new QHBoxLayout(); + row1->setSpacing(6); + + auto *lblFind = new QLabel("Find:", this); + m_txtFind = new QLineEdit(this); + m_txtFind->setPlaceholderText("Search query..."); + + m_lblReplace = new QLabel("Replace:", this); + m_txtReplace = new QLineEdit(this); + m_txtReplace->setPlaceholderText("Replacement text..."); + + row1->addWidget(lblFind); + row1->addWidget(m_txtFind, 1); + row1->addWidget(m_lblReplace); + row1->addWidget(m_txtReplace, 1); + + // --- Row 2: Options + actions --- + m_optionsRow = new QHBoxLayout(); + m_optionsRow->setSpacing(6); + + m_chkMatchCase = new QCheckBox("Match Case", this); + m_chkMatchEntire = new QCheckBox("Match Entire Cell", this); + m_chkRegex = new QCheckBox("Regular Expression", this); + + m_chkMatchCase->setFocusPolicy(Qt::NoFocus); + m_chkMatchEntire->setFocusPolicy(Qt::NoFocus); + m_chkRegex->setFocusPolicy(Qt::NoFocus); + + auto *btnFindPrev = new QPushButton("Find Previous", this); + auto *btnFindNext = new QPushButton("Find Next", this); + m_btnReplace = new QPushButton("Replace", this); + m_btnReplaceAll = new QPushButton("Replace All", this); + + btnFindPrev->setFocusPolicy(Qt::NoFocus); + btnFindNext->setFocusPolicy(Qt::NoFocus); + m_btnReplace->setFocusPolicy(Qt::NoFocus); + m_btnReplaceAll->setFocusPolicy(Qt::NoFocus); + + m_lblStatus = new QLabel(this); + m_lblStatus->setStyleSheet("color: palette(link); font-weight: bold;"); + + auto *btnClose = new QPushButton("\u2715", this); + btnClose->setObjectName("CloseButton"); + btnClose->setFixedWidth(30); + btnClose->setFlat(true); + btnClose->setFocusPolicy(Qt::NoFocus); + + m_optionsRow->addWidget(m_chkMatchCase); + m_optionsRow->addWidget(m_chkMatchEntire); + m_optionsRow->addWidget(m_chkRegex); + m_optionsRow->addWidget(btnFindPrev); + m_optionsRow->addWidget(btnFindNext); + m_optionsRow->addWidget(m_btnReplace); + m_optionsRow->addWidget(m_btnReplaceAll); + m_optionsRow->addWidget(m_lblStatus, 1); + m_optionsRow->addWidget(btnClose); + + panelLayout->addLayout(row1); + panelLayout->addLayout(m_optionsRow); + + // --- Connections --- + connect(btnFindNext, &QPushButton::clicked, this, [this]() { emit findRequested(true); }); + connect(btnFindPrev, &QPushButton::clicked, this, [this]() { emit findRequested(false); }); + connect(m_btnReplace, &QPushButton::clicked, this, &FindReplacePanel::replaceRequested); + connect(m_btnReplaceAll, &QPushButton::clicked, this, &FindReplacePanel::replaceAllRequested); + connect(btnClose, &QPushButton::clicked, this, [this]() { showPanel(false); }); + connect(m_txtFind, &QLineEdit::returnPressed, this, [this]() { emit findRequested(true); }); + connect(m_txtReplace, &QLineEdit::returnPressed, this, &FindReplacePanel::replaceRequested); + + // Register find/replace inputs as input widgets with FocusManager + fm->addInputWidget(m_txtFind); + fm->addInputWidget(m_txtReplace); + + // Register shortcuts (Always context — should work even when editing) + fm->registerShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), FocusManager::Always, + [this]() { showPanel(!isPanelVisible()); return true; }); + fm->registerShortcut(QKeySequence(Qt::CTRL | Qt::Key_R), FocusManager::Always, + [this]() { showPanel(!isPanelVisible()); return true; }); +} + +void FindReplacePanel::setReplaceEnabled(bool enabled) +{ + m_lblReplace->setVisible(enabled); + m_txtReplace->setVisible(enabled); + m_btnReplace->setVisible(enabled); + m_btnReplaceAll->setVisible(enabled); +} + +QString FindReplacePanel::findText() const { return m_txtFind->text(); } +QString FindReplacePanel::replaceText() const { return m_txtReplace->text(); } +bool FindReplacePanel::matchCase() const { return m_chkMatchCase->isChecked(); } +bool FindReplacePanel::matchEntireCell() const { return m_chkMatchEntire->isChecked(); } +bool FindReplacePanel::useRegex() const { return m_chkRegex->isChecked(); } + +void FindReplacePanel::setStatusText(const QString &text) { m_lblStatus->setText(text); } + +void FindReplacePanel::showPanel(bool show) +{ + setVisible(show); + if (show) { + m_fm->setFocusProxy(m_txtFind); + m_txtFind->setFocus(Qt::OtherFocusReason); + m_txtFind->selectAll(); + m_lblStatus->clear(); + } else { + m_fm->resetFocusProxy(); + m_lblStatus->clear(); + m_fm->restoreViewFocus(); + emit panelClosed(); + } +} + +bool FindReplacePanel::isPanelVisible() const { return isVisible(); } + +FocusManager *FindReplacePanel::focusManager() const { return m_fm; } +QLabel *FindReplacePanel::statusLabel() const { return m_lblStatus; } +QHBoxLayout *FindReplacePanel::optionsRow() const { return m_optionsRow; } + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/FocusManager.cpp b/wlx/wlxbase_wlqt/src/FocusManager.cpp new file mode 100644 index 0000000..3087136 --- /dev/null +++ b/wlx/wlxbase_wlqt/src/FocusManager.cpp @@ -0,0 +1,332 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QtWlPlugin { + +bool FocusManager::s_reloadFocusTarget = false; + +FocusManager::FocusManager(QWidget *pluginRoot, QWidget *primaryView, QObject *parent) + : QObject(parent) + , m_pluginRoot(pluginRoot) + , m_primaryView(primaryView) + , m_isActive(false) + , m_undoStack(nullptr) + , m_nextShortcutId(1) +{ + installFocusGuard(); + + // Detect focus entering/leaving the plugin hierarchy + connect(qApp, &QApplication::focusChanged, this, [this](QWidget *old, QWidget *now) { + // Skip focus management when a modal dialog (save dialog, message box) + // is active — calling setActive(false) during a modal causes crashes. + if (QApplication::activeModalWidget()) + return; + + bool oldInside = old && m_pluginRoot && (old == m_pluginRoot || m_pluginRoot->isAncestorOf(old)); + bool nowInside = now && m_pluginRoot && (now == m_pluginRoot || m_pluginRoot->isAncestorOf(now)); + + if (m_isActive) { + if (oldInside && !nowInside) { + setActive(false); + } + } else { + if (nowInside && !oldInside) { + // Focus entering while inactive — bounce it back to the host + if (old) { + QPointer pOld(old); + QTimer::singleShot(0, this, [this, pOld]() { + if (pOld) { + QWidget *currentFocus = QApplication::focusWidget(); + if (currentFocus && (currentFocus == m_pluginRoot || + m_pluginRoot->isAncestorOf(currentFocus))) { + pOld->setFocus(Qt::OtherFocusReason); + } + } + }); + } else { + QTimer::singleShot(0, this, [this]() { + QWidget *currentFocus = QApplication::focusWidget(); + if (currentFocus && (currentFocus == m_pluginRoot || + m_pluginRoot->isAncestorOf(currentFocus))) { + restoreFocusToDC(); + } + }); + } + } + } + }); + + if (s_reloadFocusTarget) { + s_reloadFocusTarget = false; + QTimer::singleShot(50, this, [this]() { + setActive(true); + restoreViewFocus(); + }); + } +} + +FocusManager::~FocusManager() +{ + if (qApp) + qApp->removeEventFilter(this); +} + +// --- Activation --- + +bool FocusManager::isActive() const { return m_isActive; } + +void FocusManager::setActive(bool active) +{ + if (m_isActive == active) + return; + + m_isActive = active; + + if (!active) { + m_activeInput = nullptr; + m_pluginRoot->clearFocus(); + if (m_pluginRoot->parentWidget()) + m_pluginRoot->parentWidget()->setFocus(Qt::OtherFocusReason); + emit deactivated(); + } else { + emit activated(); + } +} + +// --- Input widget tracking --- + +void FocusManager::addInputWidget(QWidget *w) +{ + if (w) + m_extraInputWidgets.insert(w); +} + +void FocusManager::removeInputWidget(QWidget *w) +{ + m_extraInputWidgets.remove(w); +} + +bool FocusManager::isInputWidget(QWidget *w) const +{ + if (!w) + return false; + if (m_extraInputWidgets.contains(w)) + return true; + // A descendant of primaryView (but not primaryView itself) is an input widget + // (e.g. a cell editor spawned inside a QTableWidget) + return w != m_primaryView && m_primaryView->isAncestorOf(w); +} + +QWidget *FocusManager::activeInput() const { return m_activeInput; } + +// --- Focus proxy --- + +void FocusManager::setFocusProxy(QWidget *proxy) +{ + if (m_pluginRoot) + m_pluginRoot->setFocusProxy(proxy); +} + +void FocusManager::resetFocusProxy() +{ + if (m_pluginRoot && m_primaryView) + m_pluginRoot->setFocusProxy(m_primaryView); +} + +// --- Shortcut registration --- + +FocusManager::ShortcutId FocusManager::registerShortcut( + const QKeySequence &keys, ShortcutContext ctx, std::function handler) +{ + ShortcutId id = m_nextShortcutId++; + m_shortcuts.append({id, keys, ctx, std::move(handler)}); + return id; +} + +void FocusManager::unregisterShortcut(ShortcutId id) +{ + m_shortcuts.erase( + std::remove_if(m_shortcuts.begin(), m_shortcuts.end(), + [id](const RegisteredShortcut &s) { return s.id == id; }), + m_shortcuts.end()); +} + +// --- Optional undo/redo --- + +void FocusManager::setUndoStack(QUndoStack *stack) +{ + // Unregister previous undo shortcuts + for (ShortcutId id : m_undoShortcutIds) + unregisterShortcut(id); + m_undoShortcutIds.clear(); + + m_undoStack = stack; + + if (stack) { + // Ctrl+Z → undo + m_undoShortcutIds.append(registerShortcut( + QKeySequence(Qt::CTRL | Qt::Key_Z), WhenNoInput, + [stack]() { + if (stack->canUndo()) { stack->undo(); return true; } + return false; + })); + + // Ctrl+Shift+Z → redo + m_undoShortcutIds.append(registerShortcut( + QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Z), WhenNoInput, + [stack]() { + if (stack->canRedo()) { stack->redo(); return true; } + return false; + })); + + // Ctrl+Y → redo + m_undoShortcutIds.append(registerShortcut( + QKeySequence(Qt::CTRL | Qt::Key_Y), WhenNoInput, + [stack]() { + if (stack->canRedo()) { stack->redo(); return true; } + return false; + })); + } +} + +QUndoStack *FocusManager::undoStack() const { return m_undoStack; } + +// --- Saved focus --- + +void FocusManager::saveFocusWidget(QWidget *w) { m_savedFocusWidget = w; } + +// --- Access --- + +QWidget *FocusManager::pluginRoot() const { return m_pluginRoot; } +QWidget *FocusManager::primaryView() const { return m_primaryView; } + +// --- Focus restoration --- + +void FocusManager::restoreViewFocus() +{ + if (m_primaryView) + m_primaryView->setFocus(Qt::OtherFocusReason); +} + +void FocusManager::expectReloadFocus() +{ + s_reloadFocusTarget = true; + QTimer::singleShot(1000, []() { + s_reloadFocusTarget = false; + }); +} + +void FocusManager::restoreFocusToDC() +{ + if (m_savedFocusWidget) { + m_savedFocusWidget->setFocus(Qt::OtherFocusReason); + } else if (m_pluginRoot) { + if (QWidget *fw = QApplication::focusWidget()) { + if (fw == m_pluginRoot || fw->isAncestorOf(m_pluginRoot) || + m_pluginRoot->isAncestorOf(fw)) + fw->clearFocus(); + } + } +} + +void FocusManager::installFocusGuard() +{ + if (qApp) + qApp->installEventFilter(this); + if (m_pluginRoot && m_primaryView) + m_pluginRoot->setFocusProxy(m_primaryView); +} + +// --- Event filter (the critical focus/shortcut engine) --- + +bool FocusManager::eventFilter(QObject *obj, QEvent *event) +{ + QWidget *w = qobject_cast(obj); + + // --- Geometry-based click detection --- + if (event->type() == QEvent::MouseButtonPress) { + if (!m_pluginRoot) return false; + + auto *me = static_cast(event); + const QPoint gp = me->globalPosition().toPoint(); + const QRect gr(m_pluginRoot->mapToGlobal(QPoint(0, 0)), m_pluginRoot->size()); + + if (m_isActive && !gr.contains(gp)) { + // Click outside plugin — deactivate + setActive(false); + return false; + } else if (!m_isActive && gr.contains(gp)) { + // Click inside plugin — activate + m_isActive = true; + emit activated(); + if (w && (w->focusPolicy() & Qt::ClickFocus)) { + w->setFocus(Qt::MouseFocusReason); + } else if (m_primaryView) { + m_primaryView->setFocus(Qt::MouseFocusReason); + } + } + } + + // --- FocusIn tracking --- + if (event->type() == QEvent::FocusIn) { + if (w && m_pluginRoot && (w == m_pluginRoot || m_pluginRoot->isAncestorOf(w))) { + auto *fe = static_cast(event); + if (!m_isActive && fe->reason() == Qt::OtherFocusReason) { + // Programmatic focus entry while inactive — don't activate + return false; + } + if (!m_isActive) { + m_isActive = true; + emit activated(); + } + if (isInputWidget(w)) { + m_activeInput = w; + emit inputWidgetEntered(w); + } + } + } + + // --- KeyPress: shortcut dispatch --- + if (event->type() == QEvent::KeyPress && m_isActive) { + auto *ke = static_cast(event); + QKeySequence pressed(ke->modifiers() | ke->key()); + + for (const auto &shortcut : m_shortcuts) { + if (shortcut.keys.count() != 1) + continue; + if (pressed[0] != shortcut.keys[0]) + continue; + + // Check context + if (shortcut.ctx == WhenNoInput && m_activeInput) + continue; + + if (shortcut.handler && shortcut.handler()) + return true; + } + } + + // --- ChildAdded: NoFocus enforcement --- + if (event->type() == QEvent::ChildAdded) { + if (w && m_pluginRoot && (w == m_pluginRoot || m_pluginRoot->isAncestorOf(w))) { + auto *ce = static_cast(event); + if (auto *childWidget = qobject_cast(ce->child())) { + if (!isInputWidget(childWidget)) + childWidget->setFocusPolicy(Qt::NoFocus); + } + } + } + + return QObject::eventFilter(obj, event); +} + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/PluginSplitView.cpp b/wlx/wlxbase_wlqt/src/PluginSplitView.cpp new file mode 100644 index 0000000..cf02f26 --- /dev/null +++ b/wlx/wlxbase_wlqt/src/PluginSplitView.cpp @@ -0,0 +1,34 @@ +#include + +namespace QtWlPlugin { + +PluginSplitView::PluginSplitView(QWidget *leftPanel, QWidget *rightContent, + QWidget *parent) + : QSplitter(Qt::Horizontal, parent) + , m_left(leftPanel) + , m_right(rightContent) +{ + addWidget(m_left); + addWidget(m_right); + + m_left->setMinimumWidth(100); + m_left->setMaximumWidth(350); + + setStretchFactor(0, 0); // Left: fixed + setStretchFactor(1, 1); // Right: stretches + + setSizes({180, 600}); + setChildrenCollapsible(false); + + setHandleWidth(3); +} + +void PluginSplitView::setLeftWidth(int pixels) +{ + setSizes({pixels, width() - pixels}); +} + +QWidget *PluginSplitView::leftPanel() const { return m_left; } +QWidget *PluginSplitView::rightContent() const { return m_right; } + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/PluginStatusBar.cpp b/wlx/wlxbase_wlqt/src/PluginStatusBar.cpp new file mode 100644 index 0000000..d2be345 --- /dev/null +++ b/wlx/wlxbase_wlqt/src/PluginStatusBar.cpp @@ -0,0 +1,136 @@ +#include + +#include +#include +#include + +namespace QtWlPlugin { + +PluginStatusBar::PluginStatusBar(QWidget *parent) + : QWidget(parent) + , m_layout(new QHBoxLayout(this)) +{ + m_layout->setContentsMargins(4, 2, 4, 2); + m_layout->setSpacing(0); + setFixedHeight(22); + setStyleSheet(QStringLiteral( + "PluginStatusBar { border-top: 1px solid #c0c0c0; }" + ).replace(QStringLiteral("PluginStatusBar"), + QStringLiteral("QtWlPlugin--PluginStatusBar"))); + + QFont smallFont = font(); + smallFont.setPointSize(9); + + m_encodingLabel = new QLabel(this); + m_encodingLabel->setFont(smallFont); + + m_formatLabel = new QLabel(this); + m_formatLabel->setFont(smallFont); + + m_rowLabel = new QLabel(this); + m_rowLabel->setFont(smallFont); + + rebuild(); +} + +QFrame *PluginStatusBar::createSeparator() +{ + auto *sep = new QFrame(this); + sep->setFrameShape(QFrame::VLine); + sep->setFrameShadow(QFrame::Sunken); + sep->setFixedWidth(2); + return sep; +} + +void PluginStatusBar::rebuild() +{ + // Remove all items from layout (without deleting the labels themselves) + while (m_layout->count() > 0) { + QLayoutItem *item = m_layout->takeAt(0); + // Delete separators and spacers, keep our persistent labels + if (item->widget() + && item->widget() != m_encodingLabel + && item->widget() != m_formatLabel + && item->widget() != m_rowLabel + && !m_extras.values().contains(qobject_cast(item->widget()))) { + delete item->widget(); + } + delete item; + } + + // Add extras first (sorted by key) + QStringList keys = m_extras.keys(); + keys.sort(); + for (const auto &key : keys) { + if (m_layout->count() > 0) + m_layout->addWidget(createSeparator()); + m_layout->addWidget(m_extras[key]); + } + + // Encoding + if (!m_encodingLabel->text().isEmpty()) { + if (m_layout->count() > 0) + m_layout->addWidget(createSeparator()); + m_layout->addWidget(m_encodingLabel); + } + + // Format + if (!m_formatLabel->text().isEmpty()) { + if (m_layout->count() > 0) + m_layout->addWidget(createSeparator()); + m_layout->addWidget(m_formatLabel); + } + + // Spacer → row count on right + m_layout->addStretch(1); + + if (!m_rowLabel->text().isEmpty()) { + m_layout->addWidget(createSeparator()); + m_layout->addWidget(m_rowLabel); + } +} + +void PluginStatusBar::setEncoding(const QString &encoding) +{ + m_encodingLabel->setText(QStringLiteral(" %1 ").arg(encoding)); + rebuild(); +} + +void PluginStatusBar::setFormatInfo(const QString &info) +{ + m_formatLabel->setText(QStringLiteral(" %1 ").arg(info)); + rebuild(); +} + +void PluginStatusBar::setRowCount(int filtered, int total) +{ + if (filtered == total) + m_rowLabel->setText(QStringLiteral(" Rows: %1 ").arg(total)); + else + m_rowLabel->setText(QStringLiteral(" Rows: %1/%2 ").arg(filtered).arg(total)); + rebuild(); +} + +void PluginStatusBar::setExtraInfo(const QString &key, const QString &value) +{ + QLabel *label = m_extras.value(key, nullptr); + if (!label) { + QFont smallFont = font(); + smallFont.setPointSize(9); + label = new QLabel(this); + label->setFont(smallFont); + m_extras[key] = label; + } + label->setText(QStringLiteral(" %1: %2 ").arg(key, value)); + rebuild(); +} + +void PluginStatusBar::removeExtraInfo(const QString &key) +{ + if (m_extras.contains(key)) { + delete m_extras.take(key); + rebuild(); + } +} + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/PluginToolBar.cpp b/wlx/wlxbase_wlqt/src/PluginToolBar.cpp new file mode 100644 index 0000000..a21e153 --- /dev/null +++ b/wlx/wlxbase_wlqt/src/PluginToolBar.cpp @@ -0,0 +1,158 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace QtWlPlugin { + +PluginToolBar::PluginToolBar(FocusManager *fm, QWidget *parent) + : QToolBar(parent) + , m_fm(fm) +{ + setFocusPolicy(Qt::NoFocus); + setStyleSheet( + "QToolBar { spacing: 2px; }" + "QToolButton { padding: 2px 4px; margin: 1px; }" + ); + enforceNoFocus(); +} + +static QIcon iconFromText(const QString &text, QWidget *parent) +{ + QPixmap pixmap(32, 32); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + + QFont font = parent ? parent->font() : QFont(); + font.setPixelSize(22); + painter.setFont(font); + + QColor color = Qt::black; + if (parent) { + color = parent->palette().color(QPalette::ButtonText); + } + painter.setPen(color); + painter.drawText(pixmap.rect(), Qt::AlignCenter, text); + painter.end(); + + return QIcon(pixmap); +} + +static QString getFallbackUnicodeIcon(const QString &systemIconName) +{ + if (systemIconName == QStringLiteral("document-save")) + return QStringLiteral("🖫"); + if (systemIconName == QStringLiteral("document-save-as")) + return QStringLiteral("🖪"); + if (systemIconName == QStringLiteral("edit-undo")) + return QStringLiteral("↶"); + if (systemIconName == QStringLiteral("edit-redo")) + return QStringLiteral("↷"); + if (systemIconName == QStringLiteral("document-print")) + return QString::fromUtf8("\xf0\x9f\x96\xa8\xef\xb8\x8e"); // 🖨︎ + if (systemIconName == QStringLiteral("view-refresh")) + return QStringLiteral("⟳"); + if (systemIconName == QStringLiteral("visibility")) + return QString::fromUtf8("\xf0\x9f\x91\x81\xef\xb8\x8e"); // 👁︎ + if (systemIconName == QStringLiteral("format-text-direction-ltr")) + return QString::fromUtf8("\xe2\x86\xa9\xef\xb8\x8e"); // ↩︎ + if (systemIconName == QStringLiteral("document-open")) + return QString::fromUtf8("\xe2\x86\x97\xef\xb8\x8e"); // ↗︎ + if (systemIconName == QStringLiteral("edit-find")) + return QString::fromUtf8("\xf0\x9f\x94\x8d\xef\xb8\x8e"); // 🔍︎ + return QString(); +} + +QAction *PluginToolBar::addToolAction(const QString &text, + const QKeySequence &shortcut, + int ctx, + const QString &systemIconName, + const QString &unicodeIcon, + ButtonDisplay display, + IconMode iconMode) +{ + QIcon icon; + QString resolvedUnicode = unicodeIcon; + if (resolvedUnicode.isEmpty() && !systemIconName.isEmpty()) { + resolvedUnicode = getFallbackUnicodeIcon(systemIconName); + } + + if (iconMode == IconMode::System) { + if (!systemIconName.isEmpty()) { + icon = QIcon::fromTheme(systemIconName); + } + if (icon.isNull() && !resolvedUnicode.isEmpty()) { + icon = iconFromText(resolvedUnicode, this); + } + } else { // IconMode::Unicode + if (!resolvedUnicode.isEmpty()) { + icon = iconFromText(resolvedUnicode, this); + } + } + + QAction *action = new QAction(text, this); + if (!icon.isNull()) { + action->setIcon(icon); + } + action->setProperty("buttonDisplay", static_cast(display)); + addAction(action); + + if (!shortcut.isEmpty()) { + action->setToolTip(text + " (" + shortcut.toString(QKeySequence::NativeText) + ")"); + + if (m_fm) { + m_fm->registerShortcut( + shortcut, static_cast(ctx), + [action]() { action->trigger(); return true; }); + } + } else { + action->setToolTip(text); + } + + // Restore focus to primary view after action trigger + connect(action, &QAction::triggered, this, [this]() { + QTimer::singleShot(0, this, [this]() { + if (m_fm) + m_fm->restoreViewFocus(); + }); + }); + + return action; +} + +void PluginToolBar::actionEvent(QActionEvent *event) +{ + QToolBar::actionEvent(event); + enforceNoFocus(); +} + +void PluginToolBar::enforceNoFocus() +{ + for (QAction *action : actions()) { + QWidget *w = widgetForAction(action); + if (w) { + w->setFocusPolicy(Qt::NoFocus); + if (auto *btn = qobject_cast(w)) { + QVariant val = action->property("buttonDisplay"); + if (val.isValid()) { + auto display = static_cast(val.toInt()); + if (display == ButtonDisplay::IconOnly) { + btn->setToolButtonStyle(Qt::ToolButtonIconOnly); + } else if (display == ButtonDisplay::TextOnly) { + btn->setToolButtonStyle(Qt::ToolButtonTextOnly); + } else { + btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + } + } + } + } + } +} + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/ScopedFindReplacePanel.cpp b/wlx/wlxbase_wlqt/src/ScopedFindReplacePanel.cpp new file mode 100644 index 0000000..d611b08 --- /dev/null +++ b/wlx/wlxbase_wlqt/src/ScopedFindReplacePanel.cpp @@ -0,0 +1,34 @@ +#include +#include + +#include +#include +#include + +namespace QtWlPlugin { + +ScopedFindReplacePanel::ScopedFindReplacePanel(FocusManager *fm, QWidget *parent) + : FindReplacePanel(fm, parent) +{ + auto *lblScope = new QLabel("Scope:", this); + m_comboScope = new QComboBox(this); + m_comboScope->setFocusPolicy(Qt::NoFocus); + + // Insert at the beginning of the options row (before checkboxes) + QHBoxLayout *row = optionsRow(); + row->insertWidget(0, lblScope); + row->insertWidget(1, m_comboScope); +} + +void ScopedFindReplacePanel::setScopes(const QStringList &scopes) +{ + m_comboScope->clear(); + m_comboScope->addItems(scopes); +} + +QString ScopedFindReplacePanel::currentScope() const +{ + return m_comboScope->currentText(); +} + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/SequentialRowProxyModel.cpp b/wlx/wlxbase_wlqt/src/SequentialRowProxyModel.cpp new file mode 100644 index 0000000..e6aac0f --- /dev/null +++ b/wlx/wlxbase_wlqt/src/SequentialRowProxyModel.cpp @@ -0,0 +1,22 @@ +#include + +namespace QtWlPlugin { + +SequentialRowProxyModel::SequentialRowProxyModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ +} + +SequentialRowProxyModel::~SequentialRowProxyModel() +{ +} + +QVariant SequentialRowProxyModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation == Qt::Vertical && role == Qt::DisplayRole) { + return section + 1; + } + return QSortFilterProxyModel::headerData(section, orientation, role); +} + +} // namespace QtWlPlugin diff --git a/wlx/wlxbase_wlqt/src/ThemeManager.cpp b/wlx/wlxbase_wlqt/src/ThemeManager.cpp new file mode 100644 index 0000000..4c4e407 --- /dev/null +++ b/wlx/wlxbase_wlqt/src/ThemeManager.cpp @@ -0,0 +1,259 @@ +#include + +#include + +namespace QtWlPlugin { + +ThemeManager::Theme ThemeManager::s_current = ThemeManager::Light; + +void ThemeManager::applyTheme(QWidget *root, Theme theme) +{ + s_current = theme; + + if (theme == Light) { + root->setStyleSheet(QString()); + } else { + root->setStyleSheet(darkStylesheet()); + } + + // Persist + QSettings settings(QStringLiteral("QtWlPlugin"), QStringLiteral("Preferences")); + settings.setValue(QStringLiteral("theme"), theme == Dark ? QStringLiteral("dark") : QStringLiteral("light")); +} + +ThemeManager::Theme ThemeManager::currentTheme() +{ + QSettings settings(QStringLiteral("QtWlPlugin"), QStringLiteral("Preferences")); + QString val = settings.value(QStringLiteral("theme"), QStringLiteral("light")).toString(); + s_current = (val == QStringLiteral("dark")) ? Dark : Light; + return s_current; +} + +void ThemeManager::toggleTheme(QWidget *root) +{ + applyTheme(root, s_current == Light ? Dark : Light); +} + +bool ThemeManager::isDark() +{ + return s_current == Dark; +} + +QString ThemeManager::darkStylesheet() +{ + return QStringLiteral(R"( + QWidget { + background-color: #1e1e1e; + color: #d4d4d4; + selection-background-color: #264f78; + selection-color: #ffffff; + } + + QTableView { + background-color: #1e1e1e; + alternate-background-color: #252526; + gridline-color: #3c3c3c; + border: 1px solid #3c3c3c; + } + + QHeaderView::section { + background-color: #333333; + color: #d4d4d4; + border: 1px solid #3c3c3c; + padding: 3px 5px; + font-weight: bold; + } + + QHeaderView::section:hover { + background-color: #404040; + } + + QTreeView { + background-color: #252526; + alternate-background-color: #2d2d2d; + border: 1px solid #3c3c3c; + } + + QTreeView::item:selected { + background-color: #264f78; + } + + QTreeView::item:hover { + background-color: #2a2d2e; + } + + QListWidget { + background-color: #252526; + border: 1px solid #3c3c3c; + } + + QListWidget::item:selected { + background-color: #264f78; + } + + QListWidget::item:hover { + background-color: #2a2d2e; + } + + QToolBar { + background-color: #333333; + border-bottom: 1px solid #3c3c3c; + spacing: 2px; + } + + QToolBar QLabel { + color: #d4d4d4; + } + + QToolButton { + background-color: transparent; + color: #d4d4d4; + border: 1px solid transparent; + padding: 3px 6px; + border-radius: 3px; + } + + QToolButton:hover { + background-color: #404040; + border-color: #505050; + } + + QToolButton:checked { + background-color: #264f78; + border-color: #3a7bd5; + } + + QLineEdit { + background-color: #3c3c3c; + color: #d4d4d4; + border: 1px solid #555555; + padding: 2px 4px; + border-radius: 2px; + } + + QLineEdit:focus { + border-color: #007acc; + } + + QComboBox { + background-color: #3c3c3c; + color: #d4d4d4; + border: 1px solid #555555; + padding: 2px 4px; + border-radius: 2px; + } + + QComboBox::drop-down { + border-left: 1px solid #555555; + } + + QComboBox QAbstractItemView { + background-color: #252526; + color: #d4d4d4; + selection-background-color: #264f78; + } + + QMenu { + background-color: #252526; + color: #d4d4d4; + border: 1px solid #3c3c3c; + } + + QMenu::item:selected { + background-color: #264f78; + } + + QMenu::separator { + height: 1px; + background: #3c3c3c; + margin: 4px 8px; + } + + QTabWidget::pane { + border: 1px solid #3c3c3c; + background-color: #1e1e1e; + } + + QTabBar::tab { + background-color: #2d2d2d; + color: #969696; + border: 1px solid #3c3c3c; + padding: 4px 12px; + margin-right: 1px; + } + + QTabBar::tab:selected { + background-color: #1e1e1e; + color: #d4d4d4; + border-bottom-color: #1e1e1e; + } + + QTabBar::tab:hover { + background-color: #353535; + } + + QPlainTextEdit { + background-color: #1e1e1e; + color: #d4d4d4; + border: 1px solid #3c3c3c; + font-family: "Cascadia Code", "Fira Code", "Source Code Pro", monospace; + } + + QSplitter::handle { + background-color: #3c3c3c; + } + + QSplitter::handle:hover { + background-color: #007acc; + } + + QScrollBar:vertical { + background-color: #1e1e1e; + width: 12px; + border: none; + } + + QScrollBar::handle:vertical { + background-color: #424242; + min-height: 20px; + border-radius: 3px; + margin: 2px; + } + + QScrollBar::handle:vertical:hover { + background-color: #555555; + } + + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; + } + + QScrollBar:horizontal { + background-color: #1e1e1e; + height: 12px; + border: none; + } + + QScrollBar::handle:horizontal { + background-color: #424242; + min-width: 20px; + border-radius: 3px; + margin: 2px; + } + + QScrollBar::handle:horizontal:hover { + background-color: #555555; + } + + QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 0px; + } + + /* Status bar */ + QFrame[frameShape="5"] { + color: #555555; + } + )"); +} + +} // namespace QtWlPlugin