diff --git a/build.sh b/build.sh index 78deeb2..5ac04d8 100755 --- a/build.sh +++ b/build.sh @@ -72,6 +72,14 @@ 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 +make -C wlx/csvview/src clean all +install -m 644 wlx/csvview/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/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/csvview_qt6.wlx b/wlx/csvview/csvview_qt6.wlx new file mode 100755 index 0000000..8c1893b Binary files /dev/null and b/wlx/csvview/csvview_qt6.wlx 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..be4825d --- /dev/null +++ b/wlx/csvview/src/plugin.cpp @@ -0,0 +1,2589 @@ +#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 + +#include +#include +#include +#include +#include +#include + +#include "wlxplugin.h" +#include "enca.h" + +#define _(STRING) gettext(STRING) +#define GETTEXT_PACKAGE "plugins" + +bool gQuoted = true; +bool gGrid = true; +bool gResize = true; +bool gEnca = true; +bool gReadAll = false; +QString gLang = "ru"; + +QStringList parse_line(const QByteArray &line, const char *encoding, char separator, QList *wasQuotedOut = nullptr) +{ + QStringList list; + QByteArray utf8Line; + + if (encoding[0] != '\0') + { + gsize len; + gchar *converted = g_convert_with_fallback(line.data(), line.size(), "UTF-8", encoding, NULL, NULL, &len, NULL); + + if (converted) + { + utf8Line = QByteArray(converted, len); + g_free(converted); + } + else + utf8Line = line; + } + 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; +} + +static const int WasQuotedRole = Qt::UserRole + 2; + +// 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 { + QStyleOptionViewItem opt = option; + initStyleOption(&opt, index); + + // Let the default style draw everything (background, selection, focus rect) + // but without the text + QString text = opt.text; + opt.text.clear(); + QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &opt, painter); + + // Now draw the text ourselves with WrapAnywhere + 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); + + // Use the right color depending on selection state + 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 sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override { + 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 setWrapAnywhere(bool wrap) { m_wrap = wrap; } + bool wrapAnywhere() const { return m_wrap; } +private: + bool m_wrap = false; +}; + +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 RowColCommand : public QUndoCommand { +protected: + QTableWidget *m_view; + int m_index, m_count; + QList m_data; + bool m_isRow, m_isInsert; +public: + RowColCommand(QTableWidget *view, int index, int count, bool isRow, bool isInsert, QUndoCommand *parent = nullptr) + : QUndoCommand(parent), m_view(view), 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) { + for (int i = 0; i < count; ++i) { + QStringList list; + int limit = isRow ? view->columnCount() : view->rowCount(); + for (int j = 0; j < limit; ++j) { + QTableWidgetItem *item = isRow ? view->item(index + i, j) : view->item(j, index + i); + list << (item ? item->text() : ""); + } + m_data << list; + } + } else { + for (int i = 0; i < count; ++i) { + QStringList list; + int limit = isRow ? view->columnCount() : view->rowCount(); + for (int j = 0; j < limit; ++j) list << ""; + m_data << list; + } + } + } + void applyInsert() { + m_view->blockSignals(true); + for (int i = 0; i < m_count; ++i) { + if (m_isRow) m_view->insertRow(m_index + i); + else m_view->insertColumn(m_index + i); + + QStringList list = i < m_data.size() ? m_data[i] : QStringList(); + int limit = m_isRow ? m_view->columnCount() : m_view->rowCount(); + for (int j = 0; j < limit; ++j) { + QString text = j < list.size() ? list[j] : ""; + QTableWidgetItem *item = new QTableWidgetItem(text); + item->setToolTip(text); + item->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsEditable); + if (m_isRow) m_view->setItem(m_index + i, j, item); + else m_view->setItem(j, m_index + i, item); + } + } + m_view->blockSignals(false); + } + void applyDelete() { + m_view->blockSignals(true); + for (int i = m_count - 1; i >= 0; --i) { + if (m_isRow) m_view->removeRow(m_index + i); + else m_view->removeColumn(m_index + i); + } + m_view->blockSignals(false); + } + void undo() override { if (m_isInsert) applyDelete(); else applyInsert(); } + void redo() override { if (m_isInsert) applyInsert(); else applyDelete(); } +}; + +// Undo command that stores a full data snapshot (used for sort) +class DataSnapshotCommand : public QUndoCommand { +public: + DataSnapshotCommand(QTableWidget *view, const QList &before, const QList &after, const QString &text) + : m_view(view), 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) { + m_view->blockSignals(true); + for (int r = 0; r < data.size() && r < m_view->rowCount(); ++r) + for (int c = 0; c < data[r].size() && c < m_view->columnCount(); ++c) + if (QTableWidgetItem *item = m_view->item(r, c)) item->setText(data[r][c]); + m_view->blockSignals(false); + } + QTableWidget *m_view; + QList m_before, m_after; + bool m_first; +}; + +// Undo command for section (row/column) moves - saves full visual order +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; +}; + +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; } + + void copySelection(char separator); + QString getSelectionAsText(char separator); + void pasteSelection(); + void pasteSelectionAt(int atRow); + void insertEmptyRows(int count, int atRow); + void deleteSelection(); + + void copyColumnSelection(char separator); + void pasteColumnSelectionAt(int atCol); + void insertEmptyColumns(int count, int atCol); + void deleteColumnSelection(); + + void setActive(bool active); + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private slots: + void onItemChanged(QTableWidgetItem *item); + void onUndoStackCleanChanged(bool clean); + +private: + void onSave(); + void onSaveAs(); + void onReload(); + void onToggleTextMode(bool checked); + void onToggleWordWrap(bool checked); + void updateTextView(); + void showContextMenu(const QPoint &pos); + void showColumnContextMenu(const QPoint &pos); + void onSortByColumn(int column); + + void installFocusGuard(); + void restoreViewFocus(); + bool isInputWidget(QWidget *w) const; + bool isSectionSelected(QHeaderView *header, int logicalIndex) const; + void restoreFocusToDC(); + void updateRowNumbers(); + + QTableWidget *m_view; + QToolBar *m_toolbar; + QString m_currentFile; + + char m_separator; + char m_encoding[256]; + bool m_firstLineAsHeader; + + QPointer m_savedFocusWidget; + QPointer m_activeInput; + + QUndoStack *m_undoStack; + QLabel *m_dirtyIndicator; + bool m_isProgrammaticChange; + + QStackedWidget *m_stackedWidget; + QTextBrowser *m_textBrowser; + WrapAnywhereDelegate *m_wrapDelegate; + QAction *m_actTextMode; + QAction *m_actWordWrap; + + int m_lastSortColumn; + Qt::SortOrder m_lastSortOrder; + + // Drag-to-move state + QHeaderView *m_dragHeader; + int m_dragLogicalIndex; + QList m_dragBeforeOrder; + QSet m_dragSelectedSections; // Saved before Qt clears selection + bool m_isDraggingSection; + QTimer *m_moveDebounceTimer; + bool m_isActive; + + // Find/Replace Panel & Actions + QWidget *m_findReplacePanel; + QLineEdit *m_txtFind; + QLineEdit *m_txtReplace; + QCheckBox *m_chkMatchCase; + QCheckBox *m_chkMatchEntire; + QCheckBox *m_chkRegex; + QComboBox *m_comboScope; + QLabel *m_lblStatus; + QAction *m_actFindReplace; + + void showFindReplacePanel(bool show); + void doFind(bool forward); + void doReplace(); + void doReplaceAll(); + bool cellMatches(int row, int col, const QString &query, bool matchCase, bool entireCell, bool regexFlag); +}; + +CsvViewerWidget::CsvViewerWidget(QWidget *parent) + : QWidget(parent), m_savedFocusWidget(nullptr), m_activeInput(nullptr), m_separator(','), m_firstLineAsHeader(true), m_isProgrammaticChange(false), + m_lastSortColumn(-1), m_lastSortOrder(Qt::AscendingOrder), m_dragHeader(nullptr), m_dragLogicalIndex(-1), m_isDraggingSection(false), m_isActive(false), + m_findReplacePanel(nullptr), m_txtFind(nullptr), m_txtReplace(nullptr), m_chkMatchCase(nullptr), m_chkMatchEntire(nullptr), m_chkRegex(nullptr), + m_comboScope(nullptr), m_lblStatus(nullptr), m_actFindReplace(nullptr) +{ + memset(m_encoding, 0, sizeof(m_encoding)); + + setFocusPolicy(Qt::NoFocus); + + QVBoxLayout *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + + m_toolbar = new QToolBar(this); + m_toolbar->setFocusPolicy(Qt::NoFocus); + m_toolbar->setStyleSheet( + "QToolBar { spacing: 2px; }" + "QToolButton { padding: 2px 4px; margin: 1px; }" + ); + + m_undoStack = new QUndoStack(this); + + QAction *actUndo = new QAction("↶ Undo", this); + actUndo->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Z)); + actUndo->setToolTip("Undo (Ctrl+Z)"); + actUndo->setEnabled(false); + QObject::connect(actUndo, &QAction::triggered, m_undoStack, &QUndoStack::undo); + QObject::connect(m_undoStack, &QUndoStack::canUndoChanged, actUndo, &QAction::setEnabled); + + QAction *actRedo = new QAction("↷ Redo", this); + actRedo->setShortcuts({QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Z), QKeySequence(Qt::CTRL | Qt::Key_Y)}); + actRedo->setToolTip("Redo (Ctrl+Y)"); + actRedo->setEnabled(false); + QObject::connect(actRedo, &QAction::triggered, m_undoStack, &QUndoStack::redo); + QObject::connect(m_undoStack, &QUndoStack::canRedoChanged, actRedo, &QAction::setEnabled); + + m_dirtyIndicator = new QLabel("✓", this); + m_dirtyIndicator->setContentsMargins(4, 0, 4, 0); + m_toolbar->addWidget(m_dirtyIndicator); + + QAction *actSave = m_toolbar->addAction("🖫 Save"); + actSave->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_S)); + actSave->setToolTip("Save (Ctrl+S)"); + + QAction *actSaveAs = m_toolbar->addAction("🖪 Save As..."); + actSaveAs->setToolTip("Save As..."); + + m_toolbar->addAction(actUndo); + m_toolbar->addAction(actRedo); + + QAction *actPrint = m_toolbar->addAction(QString::fromUtf8("\xf0\x9f\x96\xa8\xef\xb8\x8e Print")); + actPrint->setToolTip("Print (Ctrl+P)"); + actPrint->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_P)); + + QAction *actReload = m_toolbar->addAction("⟳ Reload"); + actReload->setToolTip("Reload file from disk"); + + QAction *actHeader = m_toolbar->addAction("\xf0\x9f\x96\x88 Header Row"); + actHeader->setCheckable(true); + actHeader->setChecked(true); + actHeader->setToolTip("Toggle First Line As Header"); + + m_actFindReplace = m_toolbar->addAction(QString::fromUtf8("\xf0\x9f\x94\x8d\xef\xb8\x8e Find/Replace")); + m_actFindReplace->setToolTip("Find and Replace (Ctrl+F / Ctrl+R)"); + m_actFindReplace->setCheckable(true); + + m_actTextMode = m_toolbar->addAction(QString::fromUtf8("\xf0\x9f\x91\x81\xef\xb8\x8e Show Text")); + m_actTextMode->setCheckable(true); + m_actTextMode->setToolTip("Toggle text view mode"); + + m_actWordWrap = m_toolbar->addAction(QString::fromUtf8("\xe2\x86\xa9\xef\xb8\x8e Line Wrap")); + m_actWordWrap->setCheckable(true); + m_actWordWrap->setToolTip("Toggle line wrap"); + + QAction *actEditor = m_toolbar->addAction(QString::fromUtf8("\xe2\x86\x97\xef\xb8\x8e Open Externally")); + actEditor->setToolTip("Open in default system application"); + + addAction(actSave); + + layout->addWidget(m_toolbar); + + m_stackedWidget = new QStackedWidget(this); + + m_view = new QTableWidget(this); + 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); // We handle drag-to-select ourselves + m_view->horizontalHeader()->setContextMenuPolicy(Qt::CustomContextMenu); + + m_view->verticalHeader()->setSectionsClickable(true); + m_view->verticalHeader()->setHighlightSections(true); + m_view->verticalHeader()->setSectionsMovable(false); // We handle drag-to-select ourselves + m_view->verticalHeader()->setContextMenuPolicy(Qt::CustomContextMenu); + + m_view->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); + m_view->verticalHeader()->setSectionResizeMode(QHeaderView::Interactive); + + m_view->setSortingEnabled(false); + + // Install the custom delegate for character-level wrapping + m_wrapDelegate = new WrapAnywhereDelegate(m_view); + m_view->setItemDelegate(m_wrapDelegate); + + m_view->setContextMenuPolicy(Qt::CustomContextMenu); + + m_textBrowser = new QTextBrowser(this); + m_textBrowser->setOpenLinks(false); + m_textBrowser->setReadOnly(true); + + m_stackedWidget->addWidget(m_view); + m_stackedWidget->addWidget(m_textBrowser); + layout->addWidget(m_stackedWidget); + + // Initialize Find/Replace panel + m_findReplacePanel = new QWidget(this); + m_findReplacePanel->setObjectName("FindReplacePanel"); + m_findReplacePanel->setVisible(false); + m_findReplacePanel->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); }" + ); + + QVBoxLayout *panelLayout = new QVBoxLayout(m_findReplacePanel); + panelLayout->setContentsMargins(6, 6, 6, 6); + panelLayout->setSpacing(6); + + QHBoxLayout *row1 = new QHBoxLayout(); + row1->setSpacing(6); + QHBoxLayout *row2 = new QHBoxLayout(); + row2->setSpacing(6); + + QLabel *lblFind = new QLabel("Find:", m_findReplacePanel); + m_txtFind = new QLineEdit(m_findReplacePanel); + m_txtFind->setPlaceholderText("Search query..."); + + QLabel *lblReplace = new QLabel("Replace:", m_findReplacePanel); + m_txtReplace = new QLineEdit(m_findReplacePanel); + m_txtReplace->setPlaceholderText("Replacement text..."); + + m_chkMatchCase = new QCheckBox("Match Case", m_findReplacePanel); + m_chkMatchEntire = new QCheckBox("Match Entire Cell", m_findReplacePanel); + m_chkRegex = new QCheckBox("Regular Expression", m_findReplacePanel); + + m_chkMatchCase->setFocusPolicy(Qt::NoFocus); + m_chkMatchEntire->setFocusPolicy(Qt::NoFocus); + m_chkRegex->setFocusPolicy(Qt::NoFocus); + + row1->addWidget(lblFind); + row1->addWidget(m_txtFind, 1); + row1->addWidget(lblReplace); + row1->addWidget(m_txtReplace, 1); + + QLabel *lblScope = new QLabel("Scope:", m_findReplacePanel); + m_comboScope = new QComboBox(m_findReplacePanel); + m_comboScope->addItems({"All Cells", "Selected Cells", "Current Column", "Current Row"}); + m_comboScope->setFocusPolicy(Qt::NoFocus); + + QPushButton *btnFindPrev = new QPushButton("Find Previous", m_findReplacePanel); + QPushButton *btnFindNext = new QPushButton("Find Next", m_findReplacePanel); + QPushButton *btnReplace = new QPushButton("Replace", m_findReplacePanel); + QPushButton *btnReplaceAll = new QPushButton("Replace All", m_findReplacePanel); + + btnFindPrev->setFocusPolicy(Qt::NoFocus); + btnFindNext->setFocusPolicy(Qt::NoFocus); + btnReplace->setFocusPolicy(Qt::NoFocus); + btnReplaceAll->setFocusPolicy(Qt::NoFocus); + + m_lblStatus = new QLabel(m_findReplacePanel); + m_lblStatus->setStyleSheet("color: palette(link); font-weight: bold;"); + + QPushButton *btnClose = new QPushButton("✕", m_findReplacePanel); + btnClose->setObjectName("CloseButton"); + btnClose->setFixedWidth(30); + btnClose->setFlat(true); + btnClose->setFocusPolicy(Qt::NoFocus); + + row2->addWidget(lblScope); + row2->addWidget(m_comboScope); + row2->addWidget(m_chkMatchCase); + row2->addWidget(m_chkMatchEntire); + row2->addWidget(m_chkRegex); + row2->addWidget(btnFindPrev); + row2->addWidget(btnFindNext); + row2->addWidget(btnReplace); + row2->addWidget(btnReplaceAll); + row2->addWidget(m_lblStatus, 1); + row2->addWidget(btnClose); + + panelLayout->addLayout(row1); + panelLayout->addLayout(row2); + + layout->addWidget(m_findReplacePanel); + + // Connect Find/Replace signals + QObject::connect(m_actFindReplace, &QAction::toggled, this, [this](bool checked) { + showFindReplacePanel(checked); + }); + QObject::connect(btnFindNext, &QPushButton::clicked, this, [this]() { doFind(true); }); + QObject::connect(btnFindPrev, &QPushButton::clicked, this, [this]() { doFind(false); }); + QObject::connect(btnReplace, &QPushButton::clicked, this, &CsvViewerWidget::doReplace); + QObject::connect(btnReplaceAll, &QPushButton::clicked, this, &CsvViewerWidget::doReplaceAll); + QObject::connect(btnClose, &QPushButton::clicked, this, [this]() { showFindReplacePanel(false); }); + + QObject::connect(m_txtFind, &QLineEdit::returnPressed, this, [this]() { doFind(true); }); + QObject::connect(m_txtReplace, &QLineEdit::returnPressed, this, &CsvViewerWidget::doReplace); + + QObject::connect(actSave, &QAction::triggered, this, [this]() { onSave(); }); + QObject::connect(actSaveAs, &QAction::triggered, this, [this]() { onSaveAs(); }); + QObject::connect(actReload, &QAction::triggered, this, [this]() { onReload(); }); + QObject::connect(actHeader, &QAction::toggled, this, [this](bool checked) { + m_firstLineAsHeader = checked; + onReload(); + }); + QObject::connect(actEditor, &QAction::triggered, this, [this]() { + QDesktopServices::openUrl(QUrl::fromLocalFile(m_currentFile)); + }); + QObject::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); + }); + QObject::connect(m_actTextMode, &QAction::toggled, this, &CsvViewerWidget::onToggleTextMode); + QObject::connect(m_actWordWrap, &QAction::toggled, this, &CsvViewerWidget::onToggleWordWrap); + + QObject::connect(m_view, &QWidget::customContextMenuRequested, this, [this](const QPoint &pos) { showContextMenu(pos); }); + QObject::connect(m_view->verticalHeader(), &QWidget::customContextMenuRequested, this, [this](const QPoint &pos) { showContextMenu(pos); }); + QObject::connect(m_view->horizontalHeader(), &QWidget::customContextMenuRequested, this, [this](const QPoint &pos) { showColumnContextMenu(pos); }); + + // Sort by clicking column header + QObject::connect(m_view->horizontalHeader(), &QHeaderView::sectionClicked, this, &CsvViewerWidget::onSortByColumn); + + QObject::connect(m_undoStack, &QUndoStack::cleanChanged, this, &CsvViewerWidget::onUndoStackCleanChanged); + QObject::connect(m_undoStack, &QUndoStack::indexChanged, this, [this]() { updateRowNumbers(); }); + QObject::connect(m_view, &QTableWidget::itemChanged, this, &CsvViewerWidget::onItemChanged); + + // Focus management: detect when focus leaves or enters our widget hierarchy + connect(qApp, &QApplication::focusChanged, this, [this](QWidget *old, QWidget *now) { + bool oldInside = old && (old == this || this->isAncestorOf(old)); + bool nowInside = now && (now == this || this->isAncestorOf(now)); + + if (m_isActive) { + if (oldInside && !nowInside) { + // Focus left our plugin + setActive(false); + } + } else { + if (nowInside && !oldInside) { + // Focus is entering the plugin programmatically while inactive. + // Restore focus to the widget that had it (old), or fallback to DC. + if (old) { + QPointer pOld(old); + QTimer::singleShot(0, this, [this, pOld]() { + if (pOld) { + QWidget *currentFocus = QApplication::focusWidget(); + if (currentFocus && (currentFocus == this || this->isAncestorOf(currentFocus))) { + pOld->setFocus(Qt::OtherFocusReason); + } + } + }); + } else { + QTimer::singleShot(0, this, [this]() { + QWidget *currentFocus = QApplication::focusWidget(); + if (currentFocus && (currentFocus == this || this->isAncestorOf(currentFocus))) { + restoreFocusToDC(); + } + }); + } + } + } + }); + + // Debounce timer for section moves (sectionMoved fires many times during drag) + m_moveDebounceTimer = new QTimer(this); + m_moveDebounceTimer->setSingleShot(true); + m_moveDebounceTimer->setInterval(0); + QObject::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()); + + // Current visual order (after Qt moved the dragged section) + QList currentOrder; + for (int v = 0; v < m_dragHeader->count(); ++v) + currentOrder.append(m_dragHeader->logicalIndex(v)); + + // Non-selected in current visual order + QList nonSelected; + for (int li : currentOrder) { + if (!m_dragSelectedSections.contains(li)) + nonSelected.append(li); + } + + // Selected in original visual order (preserving relative order) + QList selectedInOrder; + for (int li : m_dragBeforeOrder) { + if (m_dragSelectedSections.contains(li)) + selectedInOrder.append(li); + } + + // First selected item goes to the drop position + int insertIdx = qBound(0, newVisual, nonSelected.size()); + + // Build target order: nonSelected with selectedInOrder inserted at insertIdx + 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]); + + // Apply target order via moveSection + 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; + }); + + // Connect sectionMoved to restart the debounce timer + auto connectMoveDebounce = [this](QHeaderView *header) { + QObject::connect(header, &QHeaderView::sectionMoved, this, [this](int, int, int) { + if (m_isDraggingSection) + m_moveDebounceTimer->start(); + }); + }; + connectMoveDebounce(m_view->horizontalHeader()); + connectMoveDebounce(m_view->verticalHeader()); + + installFocusGuard(); + + for (QAction *action : m_toolbar->actions()) { + QWidget *w = m_toolbar->widgetForAction(action); + if (w) { + w->setFocusPolicy(Qt::NoFocus); + } + QObject::connect(action, &QAction::triggered, this, [this]() { + QTimer::singleShot(0, this, &CsvViewerWidget::restoreViewFocus); + }); + } +} + +CsvViewerWidget::~CsvViewerWidget() +{ + m_moveDebounceTimer->stop(); + m_view->blockSignals(true); + m_undoStack->blockSignals(true); + if (qApp) qApp->removeEventFilter(this); +} + +void CsvViewerWidget::installFocusGuard() +{ + if (qApp) qApp->installEventFilter(this); + setFocusProxy(m_view); +} + +void CsvViewerWidget::setActive(bool active) +{ + m_isActive = active; + if (!active) { + m_activeInput = nullptr; + clearFocus(); + if (parentWidget()) { + parentWidget()->setFocus(Qt::OtherFocusReason); + } + } +} + +bool CsvViewerWidget::isInputWidget(QWidget *w) const +{ + if (!w) return false; + if (w != m_view && m_view->isAncestorOf(w)) return true; + return false; +} + +void CsvViewerWidget::restoreFocusToDC() +{ + if (m_savedFocusWidget) { + m_savedFocusWidget->setFocus(Qt::OtherFocusReason); + } else { + if (QWidget *fw = QApplication::focusWidget()) { + if (fw == this || fw->isAncestorOf(this) || this->isAncestorOf(fw)) + fw->clearFocus(); + } + } +} + +void CsvViewerWidget::restoreViewFocus() +{ + if (m_stackedWidget->currentWidget() == m_view) { + m_view->setFocus(Qt::OtherFocusReason); + } else { + m_textBrowser->setFocus(Qt::OtherFocusReason); + } +} + +void CsvViewerWidget::updateRowNumbers() +{ + QHeaderView *vh = m_view->verticalHeader(); + m_view->blockSignals(true); + for (int v = 0; v < m_view->rowCount(); ++v) { + int logical = vh->logicalIndex(v); + QTableWidgetItem *item = m_view->verticalHeaderItem(logical); + if (!item) { + item = new QTableWidgetItem(); + m_view->setVerticalHeaderItem(logical, item); + } + item->setText(QString::number(v + 1)); + } + m_view->blockSignals(false); +} + +bool CsvViewerWidget::isSectionSelected(QHeaderView *header, int logicalIndex) const +{ + QItemSelectionModel *sel = m_view->selectionModel(); + if (!sel) return false; + bool isHorizontal = (header == m_view->horizontalHeader()); + if (isHorizontal) { + // Check if ALL rows in this column are selected + for (int r = 0; r < m_view->rowCount(); ++r) { + if (!sel->isSelected(m_view->model()->index(r, logicalIndex))) + return false; + } + return m_view->rowCount() > 0; + } else { + // Check if ALL columns in this row are selected + for (int c = 0; c < m_view->columnCount(); ++c) { + if (!sel->isSelected(m_view->model()->index(logicalIndex, c))) + return false; + } + return m_view->columnCount() > 0; + } +} + +void CsvViewerWidget::onUndoStackCleanChanged(bool clean) { + m_dirtyIndicator->setText(clean ? "✓" : "✱"); +} + +void CsvViewerWidget::onItemChanged(QTableWidgetItem *item) { + if (m_isProgrammaticChange) return; + if (!item) return; + + // Check if we have old text stashed in UserRole + QVariant oldData = item->data(Qt::UserRole); + if (!oldData.isValid()) return; + + QString oldText = oldData.toString(); + QString newText = item->text(); + + // Clear the stashed value so we don't re-trigger + m_isProgrammaticChange = true; + item->setData(Qt::UserRole, QVariant()); + m_isProgrammaticChange = false; + + if (oldText != newText) { + bool oldQuoted = item->data(WasQuotedRole).toBool(); + bool newQuoted = oldQuoted || newText.contains(m_separator); + m_isProgrammaticChange = true; + item->setText(oldText); // Revert so the undo command applies the new text + m_isProgrammaticChange = false; + m_undoStack->push(new EditCellCommand(m_view, item->row(), item->column(), oldText, newText, oldQuoted, newQuoted)); + } +} + +void CsvViewerWidget::onToggleTextMode(bool checked) { + if (checked) { + // Commit any active cell editor before switching to text view + if (m_activeInput) { + QModelIndex current = m_view->currentIndex(); + QAbstractItemDelegate *delegate = m_view->itemDelegateForIndex(current); + if (delegate) + delegate->setModelData(m_activeInput, m_view->model(), current); + m_view->closePersistentEditor(m_view->currentItem()); + m_activeInput = nullptr; + } + showFindReplacePanel(false); + updateTextView(); + m_stackedWidget->setCurrentWidget(m_textBrowser); + } else { + m_stackedWidget->setCurrentWidget(m_view); + } +} + +void CsvViewerWidget::onToggleWordWrap(bool checked) { + // For grid mode: use the custom delegate + m_wrapDelegate->setWrapAnywhere(checked); + m_view->setWordWrap(checked); + if (checked) { + m_view->resizeRowsToContents(); + } else { + m_view->verticalHeader()->setDefaultSectionSize(m_view->fontMetrics().height() + 8); + m_view->resizeRowsToContents(); + } + + // 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) { + // Plain text mode for large files + 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); +} + +void CsvViewerWidget::onSortByColumn(int column) { + if (column != m_lastSortColumn) { + // First click on a new column: just remember it, don't sort + m_lastSortColumn = column; + m_lastSortOrder = Qt::AscendingOrder; + m_view->horizontalHeader()->setSortIndicator(-1, Qt::AscendingOrder); + return; + } + + // Second+ click on same column: sort + QList beforeData; + for (int r = 0; r < m_view->rowCount(); ++r) { + QStringList row; + for (int c = 0; c < m_view->columnCount(); ++c) + row << (m_view->item(r, c) ? m_view->item(r, c)->text() : ""); + beforeData << row; + } + + Qt::SortOrder order = m_lastSortOrder; + + m_view->blockSignals(true); + m_view->sortItems(column, order); + m_view->blockSignals(false); + + QList afterData; + for (int r = 0; r < m_view->rowCount(); ++r) { + QStringList row; + for (int c = 0; c < m_view->columnCount(); ++c) + row << (m_view->item(r, c) ? m_view->item(r, c)->text() : ""); + afterData << row; + } + + m_undoStack->push(new DataSnapshotCommand(m_view, beforeData, afterData, "Sort")); + m_view->horizontalHeader()->setSortIndicatorShown(true); + m_view->horizontalHeader()->setSortIndicator(column, order); + + // Toggle for next click + m_lastSortOrder = (order == Qt::AscendingOrder) ? Qt::DescendingOrder : Qt::AscendingOrder; +} + +bool CsvViewerWidget::eventFilter(QObject *obj, QEvent *event) +{ + QWidget *w = qobject_cast(obj); + // --- Determine if our plugin has focus --- + bool pluginHasFocus = m_isActive; + + // --- Geometry-based click detection --- + if (event->type() == QEvent::MouseButtonPress) { + auto *me = static_cast(event); + const QPoint gp = me->globalPosition().toPoint(); + const QRect gr(mapToGlobal(QPoint(0, 0)), size()); + if (m_isActive && !gr.contains(gp)) { + // Click outside our plugin — deactivate + setActive(false); + return false; // let the click through to DC + } else if (!m_isActive && gr.contains(gp)) { + // Click inside our plugin — activate + m_isActive = true; + if (w && (w->focusPolicy() & Qt::ClickFocus)) { + w->setFocus(Qt::MouseFocusReason); + } else { + if (m_stackedWidget->currentWidget() == m_view) + m_view->setFocus(Qt::MouseFocusReason); + else + m_textBrowser->setFocus(Qt::MouseFocusReason); + } + } + } + + // --- Track FocusIn on our children --- + if (event->type() == QEvent::FocusIn) { + if (w && (w == this || this->isAncestorOf(w))) { + QFocusEvent *fe = static_cast(event); + if (!m_isActive && fe->reason() == Qt::OtherFocusReason) { + // Focus bounce or programmatic focus entry while inactive. + // Do not activate the plugin, let the event filter propagate, + // and let QApplication::focusChanged handle focus restoration. + return false; + } + m_isActive = true; + if (isInputWidget(w)) { + m_activeInput = w; + if (QTableWidgetItem *item = m_view->currentItem()) { + if (!item->data(Qt::UserRole).isValid()) { + m_isProgrammaticChange = true; + item->setData(Qt::UserRole, item->text()); + m_isProgrammaticChange = false; + } + } + } + } + } + + // --- Top-level key handling (only when plugin is active) --- + if (event->type() == QEvent::KeyPress && pluginHasFocus) { + auto *ke = static_cast(event); + QWidget *focusW = qApp->focusWidget(); + bool isFindReplaceEvent = m_findReplacePanel && focusW && (focusW == m_findReplacePanel || m_findReplacePanel->isAncestorOf(focusW)); + + // Ctrl+F or Ctrl+R: Find/Replace (only in table view) + if (m_stackedWidget->currentWidget() == m_view) { + if ((ke->modifiers() & Qt::ControlModifier) && (ke->key() == Qt::Key_F || ke->key() == Qt::Key_R)) { + showFindReplacePanel(!m_findReplacePanel->isVisible()); + return true; + } + } + // Ctrl+S: Save + if ((ke->modifiers() & Qt::ControlModifier) && ke->key() == Qt::Key_S) { + onSave(); + return true; + } + // Ctrl+Z: Undo (only when not editing and not in Find/Replace inputs) + if (!m_activeInput && !isFindReplaceEvent && (ke->modifiers() & Qt::ControlModifier) && !(ke->modifiers() & Qt::ShiftModifier) && ke->key() == Qt::Key_Z) { + if (m_undoStack->canUndo()) { + m_undoStack->undo(); + return true; + } + } + // Ctrl+Shift+Z: Redo + if (!m_activeInput && !isFindReplaceEvent && (ke->modifiers() & Qt::ControlModifier) && (ke->modifiers() & Qt::ShiftModifier) && ke->key() == Qt::Key_Z) { + if (m_undoStack->canRedo()) { + m_undoStack->redo(); + return true; + } + } + // Ctrl+Y: Redo + if (!m_activeInput && !isFindReplaceEvent && (ke->modifiers() & Qt::ControlModifier) && ke->key() == Qt::Key_Y) { + if (m_undoStack->canRedo()) { + m_undoStack->redo(); + return true; + } + } + if (!m_activeInput && m_stackedWidget->currentWidget() == m_view && !isFindReplaceEvent) { + if ((ke->modifiers() & Qt::ControlModifier) && ke->key() == Qt::Key_C) { + copySelection('\t'); + return true; + } + if ((ke->modifiers() & Qt::ControlModifier) && ke->key() == Qt::Key_V) { + pasteSelection(); + return true; + } + if (ke->key() == Qt::Key_Delete) { + deleteSelection(); + return true; + } + // Enter on a selected cell: open editor + if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { + if (m_view->currentItem()) { + m_view->editItem(m_view->currentItem()); + return true; + } + } + // Arrow key navigation (with right-wrap) + if (ke->key() == Qt::Key_Up || ke->key() == Qt::Key_Down || + ke->key() == Qt::Key_Left || ke->key() == Qt::Key_Right) { + int visualCol = m_view->horizontalHeader()->visualIndex(m_view->currentIndex().column()); + int visualRow = m_view->verticalHeader()->visualIndex(m_view->currentIndex().row()); + if (ke->key() == Qt::Key_Up) visualRow--; + if (ke->key() == Qt::Key_Down) visualRow++; + if (ke->key() == Qt::Key_Left) { + visualCol--; + if (visualCol < 0 && visualRow > 0) { + visualCol = m_view->columnCount() - 1; + visualRow--; + } + } + if (ke->key() == Qt::Key_Right) { + visualCol++; + if (visualCol >= m_view->columnCount() && visualRow < m_view->rowCount() - 1) { + visualCol = 0; + visualRow++; + } + } + visualRow = qBound(0, visualRow, m_view->rowCount() - 1); + visualCol = qBound(0, visualCol, m_view->columnCount() - 1); + int r = m_view->verticalHeader()->logicalIndex(visualRow); + int c = m_view->horizontalHeader()->logicalIndex(visualCol); + m_view->setCurrentCell(r, c); + return true; + } + } + } + + // --- Header mouse events: drag-to-move vs drag-to-select --- + QHeaderView *hHeader = m_view->horizontalHeader(); + QHeaderView *vHeader = m_view->verticalHeader(); + 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)); + + // Save selected sections NOW, before Qt clears the selection + bool isHorizontal = (header == m_view->horizontalHeader()); + 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); + } + + // Restore the full selection after Qt's default handler clears it + 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); + } + } + } + + // --- Child widget events (header drag handling) --- + if (w && (w == this || this->isAncestorOf(w))) { + if (event->type() == QEvent::ChildAdded) { + auto *ce = static_cast(event); + if (auto *childWidget = qobject_cast(ce->child())) { + if (!isInputWidget(childWidget)) + childWidget->setFocusPolicy(Qt::NoFocus); + } + } + + if (event->type() == QEvent::KeyPress && m_stackedWidget->currentWidget() == m_view) { + auto *ke = static_cast(event); + if (m_activeInput && w != m_textBrowser) { + if (ke->key() == Qt::Key_Escape) { + if (QTableWidgetItem *item = m_view->currentItem()) { + QVariant oldData = item->data(Qt::UserRole); + if (oldData.isValid()) { + m_isProgrammaticChange = true; + item->setText(oldData.toString()); + item->setData(Qt::UserRole, QVariant()); + m_isProgrammaticChange = false; + } + } + m_activeInput = nullptr; + restoreFocusToDC(); + 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, m_view->rowCount() - 1); + + if (QTableWidgetItem *item = m_view->currentItem()) { + QVariant oldData = item->data(Qt::UserRole); + if (oldData.isValid()) { + m_isProgrammaticChange = true; + item->setText(oldData.toString()); + item->setData(Qt::UserRole, QVariant()); + m_isProgrammaticChange = false; + } + } + m_view->closePersistentEditor(m_view->currentItem()); + m_activeInput = nullptr; + + m_view->setCurrentCell(r, c); + m_view->editItem(m_view->item(r, c)); + return true; + } + // Left/Right arrows: let them pass through to the cell editor for cursor movement + if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { + QModelIndex current = m_view->currentIndex(); + int r = current.row(), c = current.column(); + + // Commit the editor data via the delegate + QAbstractItemDelegate *delegate = m_view->itemDelegateForIndex(current); + if (delegate && m_activeInput) { + delegate->setModelData(m_activeInput, m_view->model(), current); + } + m_activeInput = nullptr; + + // Navigate to next cell (right, wrap to next row) + int visualCol = m_view->horizontalHeader()->visualIndex(c); + int visualRow = m_view->verticalHeader()->visualIndex(r); + visualCol++; + if (visualCol >= m_view->columnCount()) { + visualCol = 0; + visualRow++; + } + if (visualRow < m_view->rowCount()) { + int nr = m_view->verticalHeader()->logicalIndex(visualRow); + int nc = m_view->horizontalHeader()->logicalIndex(visualCol); + QTimer::singleShot(0, this, [this, nr, nc]() { + m_view->setCurrentCell(nr, nc); + }); + } + return true; + } + } else { + if (ke->key() == Qt::Key_Escape && m_findReplacePanel->isVisible()) { + showFindReplacePanel(false); + return true; + } + } + } + } + + return QWidget::eventFilter(obj, event); +} + +bool CsvViewerWidget::loadFile(const QString& filePath) +{ + m_isProgrammaticChange = true; + QWidget *fw = QApplication::focusWidget(); + if (fw && fw != this && !this->isAncestorOf(fw)) { + m_savedFocusWidget = fw; + } + m_currentFile = filePath; + m_activeInput = nullptr; + + 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)) + return false; + + if (gEnca) + { + if (gReadAll) + line = file.readAll(); + else + line = file.read(4096); + + EncaAnalyser analyser; + EncaEncoding encoding; + analyser = enca_analyser_alloc(gLang.toStdString().c_str()); + + if (analyser) + { + 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); + encoding = enca_analyse(analyser, (unsigned char*)line.data(), (size_t)line.size()); + + if (encoding.charset > 0 && encoding.charset != 27) + snprintf(m_encoding, sizeof(m_encoding), "%s", enca_charset_name(encoding.charset, ENCA_NAME_STYLE_ICONV)); + + enca_analyser_free(analyser); + } + + file.seek(0); + } + + 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_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, this); + 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) { + // Replace separator in the raw file, respecting quoted fields + char oldSep = m_separator; + char newSep = isCsvExt ? ',' : '\t'; + m_separator = newSep; + + file.seek(0); + QByteArray rawData = file.readAll(); + file.close(); + + // Walk through bytes, toggle inQuote on '"', replace oldSep with newSep only outside quotes + 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->setShowGrid(gGrid); + + if (gResize) + m_view->resizeColumnsToContents(); + + m_lastSortColumn = -1; + m_lastSortOrder = Qt::AscendingOrder; + m_undoStack->clear(); + m_isProgrammaticChange = false; + + if (m_lblStatus) m_lblStatus->clear(); + + if (!m_isActive) { + QTimer::singleShot(0, this, [this]() { restoreFocusToDC(); }); + } + return true; +} + +void CsvViewerWidget::onSave() +{ + if (m_activeInput) { + if (QWidget *fw = QApplication::focusWidget()) { + if (m_view->isAncestorOf(fw)) + fw->clearFocus(); + } + m_activeInput = nullptr; + } + saveFile(m_currentFile); + m_undoStack->setClean(); +} + +void CsvViewerWidget::onSaveAs() +{ + QString csvFilter = "CSV - Comma Separated (*.csv)"; + QString tsvFilter = "TSV - Tab Separated (*.tsv)"; + QString selectedFilter; + // Put the current format first + QString filter = (m_separator == '\t') ? (tsvFilter + ";;" + csvFilter) : (csvFilter + ";;" + tsvFilter); + QString path = QFileDialog::getSaveFileName(this, "Save As", m_currentFile, filter, &selectedFilter); + if (!path.isEmpty()) { + // Convert separator if the user chose a different format + char oldSep = m_separator; + if (selectedFilter == csvFilter) + m_separator = ','; + else if (selectedFilter == tsvFilter) + m_separator = '\t'; + saveFile(path); + m_currentFile = path; + m_undoStack->setClean(); + if (m_separator != oldSep) + updateTextView(); + } +} + +void CsvViewerWidget::onReload() +{ + if (m_currentFile.isEmpty()) return; + if (QWidget *fw = QApplication::focusWidget()) { + if (m_view->isAncestorOf(fw)) { + fw->clearFocus(); + } + } + loadFile(m_currentFile); +} + +void CsvViewerWidget::saveFile(const QString& filePath) +{ + QFile file(filePath); + if (!file.open(QFile::WriteOnly | QFile::Text)) { + QMessageBox::warning(this, "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') { + gsize len; + QByteArray utf8Text = outText.toUtf8(); + gchar *converted = g_convert_with_fallback(utf8Text.data(), utf8Text.size(), m_encoding, "UTF-8", NULL, NULL, &len, NULL); + if (converted) { + outBytes = QByteArray(converted, len); + g_free(converted); + } else { + outBytes = utf8Text; + } + } else { + outBytes = outText.toUtf8(); + } + + file.write(outBytes); + file.close(); + + m_currentFile = filePath; +} + +void CsvViewerWidget::copySelection(char separator) +{ + QString text = getSelectionAsText(separator); + if (!text.isEmpty()) { + QApplication::clipboard()->setText(text); + } +} + +QString CsvViewerWidget::getSelectionAsText(char separator) +{ + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (sel.isEmpty()) return QString(); + + // Find visual bounds of selection + int minVRow = m_view->rowCount(), maxVRow = -1; + int minVCol = m_view->columnCount(), maxVCol = -1; + for (const QModelIndex &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; + if (m_firstLineAsHeader) { + QStringList headerItems; + for (int vc = minVCol; vc <= maxVCol; ++vc) { + int c = m_view->horizontalHeader()->logicalIndex(vc); + QTableWidgetItem *hItem = m_view->horizontalHeaderItem(c); + QString headerText = hItem ? hItem->text() : ""; + bool wasQuoted = hItem && hItem->data(WasQuotedRole).toBool(); + if (wasQuoted || headerText.contains(separator)) { + headerText.replace("\"", "\"\""); + headerText = "\"" + headerText + "\""; + } + headerItems << headerText; + } + outText += headerItems.join(separator) + "\n"; + } + + 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); + QString cellText = ""; + QTableWidgetItem *item = nullptr; + QModelIndex idx = m_view->model()->index(r, c); + if (m_view->selectionModel()->isSelected(idx)) { + item = m_view->item(r, c); + cellText = item ? item->text() : ""; + } + bool wasQuoted = item && item->data(WasQuotedRole).toBool(); + if (wasQuoted || cellText.contains(separator)) { + cellText.replace("\"", "\"\""); + cellText = "\"" + cellText + "\""; + } + rowItems << cellText; + } + outText += rowItems.join(separator) + "\n"; + } + return outText; +} + +void CsvViewerWidget::pasteSelection() +{ + // Find the target VISUAL row position for the paste + int targetVisualRow = m_view->rowCount(); + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (!sel.isEmpty()) { + int minVRow = m_view->rowCount(); + for (const QModelIndex &index : sel) { + int vr = m_view->verticalHeader()->visualIndex(index.row()); + if (vr < minVRow) minVRow = vr; + } + targetVisualRow = minVRow; + } + + int endRow = m_view->rowCount(); + bool needsMove = (targetVisualRow < endRow); + + // Capture header order before paste (for undo of visual move) + QList beforeOrder; + QHeaderView *vh = m_view->verticalHeader(); + if (needsMove) { + for (int v = 0; v < vh->count(); ++v) + beforeOrder.append(vh->logicalIndex(v)); + } + + // Insert at end, then move to the visual target + if (needsMove) m_undoStack->beginMacro("Paste rows"); + + pasteSelectionAt(endRow); + + int rowsInserted = m_view->rowCount() - endRow; + if (rowsInserted > 0 && needsMove) { + // Capture order after insertion (new rows at end) + QList midOrder; + for (int v = 0; v < vh->count(); ++v) + midOrder.append(vh->logicalIndex(v)); + + // Move new rows from end to target visual position + 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 CsvViewerWidget::pasteSelectionAt(int atRow) +{ + int targetCols = m_view->columnCount(); + 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; + + char sep = '\t'; + QStringList testList = parse_line(lines.first().toUtf8(), m_encoding, '\t'); + if (testList.size() != targetCols && m_separator != '\t') { + testList = parse_line(lines.first().toUtf8(), m_encoding, m_separator); + if (testList.size() == targetCols) sep = m_separator; + else return; + } else if (testList.size() != targetCols) return; + + if (m_firstLineAsHeader && !lines.isEmpty()) { + QStringList firstLine = parse_line(lines.first().toUtf8(), m_encoding, sep); + bool matchesHeader = (firstLine.size() == targetCols); + for (int c = 0; c < targetCols && matchesHeader; ++c) { + QString headerText = m_view->horizontalHeaderItem(c) ? m_view->horizontalHeaderItem(c)->text() : ""; + if (firstLine.at(c).trimmed() != headerText) matchesHeader = false; + } + if (matchesHeader) lines.removeFirst(); + } + if (lines.isEmpty()) return; + + int rowsToInsert = lines.size(); + RowColCommand *cmd = new RowColCommand(m_view, atRow, rowsToInsert, true, true); + + m_isProgrammaticChange = true; + m_undoStack->push(cmd); + m_isProgrammaticChange = false; + + for (int i = 0; i < rowsToInsert; ++i) { + QList wasQuoted; + QStringList list = parse_line(lines.at(i).toUtf8(), m_encoding, sep, &wasQuoted); + // Map clipboard fields (in visual column order) to logical columns + for (int vc = 0; vc < targetCols; ++vc) { + int c = m_view->horizontalHeader()->logicalIndex(vc); + QString cellText = vc < list.size() ? list.at(vc).trimmed() : ""; + if (QTableWidgetItem *item = m_view->item(atRow + i, c)) { + m_isProgrammaticChange = true; + item->setText(cellText); + item->setData(WasQuotedRole, vc < wasQuoted.size() && wasQuoted[vc]); + m_isProgrammaticChange = false; + } + } + } +} + +void CsvViewerWidget::insertEmptyRows(int count, int atRow) +{ + if (m_view->columnCount() <= 0 || count <= 0) return; + m_undoStack->push(new RowColCommand(m_view, atRow, count, true, true)); +} + +void CsvViewerWidget::deleteSelection() +{ + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (sel.isEmpty()) return; + + QSet rowsSet; + for (const QModelIndex &index : sel) rowsSet.insert(index.row()); + QList rowsToDelete = rowsSet.values(); + std::sort(rowsToDelete.begin(), rowsToDelete.end(), std::greater()); + + m_undoStack->beginMacro("Delete rows"); + for (int r : rowsToDelete) { + m_undoStack->push(new RowColCommand(m_view, r, 1, true, false)); + } + m_undoStack->endMacro(); +} + +void CsvViewerWidget::copyColumnSelection(char separator) { + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (sel.isEmpty()) return; + + int minCol = m_view->columnCount(), maxCol = -1; + for (const QModelIndex &index : sel) { + if (index.column() < minCol) minCol = index.column(); + if (index.column() > maxCol) maxCol = index.column(); + } + + QString outText; + if (m_firstLineAsHeader) { + QStringList headerItems; + for (int c = minCol; c <= maxCol; ++c) { + QString headerText = m_view->horizontalHeaderItem(c) ? m_view->horizontalHeaderItem(c)->text() : ""; + if (headerText.contains(separator) || headerText.contains('"') || headerText.contains('\n')) { + headerText.replace("\"", "\"\""); + headerText = "\"" + headerText + "\""; + } + headerItems << headerText; + } + outText += headerItems.join(separator) + "\n"; + } + + for (int r = 0; r < m_view->rowCount(); ++r) { + QStringList rowItems; + for (int c = minCol; c <= maxCol; ++c) { + QString cellText = m_view->item(r, c) ? m_view->item(r, c)->text() : ""; + if (cellText.contains(separator) || cellText.contains('"') || cellText.contains('\n')) { + cellText.replace("\"", "\"\""); + cellText = "\"" + cellText + "\""; + } + rowItems << cellText; + } + outText += rowItems.join(separator) + "\n"; + } + QApplication::clipboard()->setText(outText); +} + +void CsvViewerWidget::pasteColumnSelectionAt(int atCol) { + 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; + + int expectedRows = m_view->rowCount(); + if (m_firstLineAsHeader) expectedRows += 1; + + if (lines.size() != expectedRows) { + QMessageBox::warning(this, "Paste Error", QString("Clipboard contains %1 rows, but table requires %2.").arg(lines.size()).arg(expectedRows)); + return; + } + + char sep = '\t'; + int colsToInsert = parse_line(lines.first().toUtf8(), m_encoding, '\t').size(); + if (colsToInsert <= 1 && m_separator != '\t') { + sep = m_separator; + colsToInsert = parse_line(lines.first().toUtf8(), m_encoding, m_separator).size(); + } + + m_isProgrammaticChange = true; + m_undoStack->push(new RowColCommand(m_view, atCol, colsToInsert, false, true)); + m_isProgrammaticChange = false; + + int startIdx = 0; + if (m_firstLineAsHeader) { + QStringList list = parse_line(lines.at(0).toUtf8(), m_encoding, sep); + for (int c = 0; c < colsToInsert; ++c) { + QString cellText = c < list.size() ? list.at(c).trimmed() : ""; + if (QTableWidgetItem *item = m_view->horizontalHeaderItem(atCol + c)) item->setText(cellText); + else m_view->setHorizontalHeaderItem(atCol + c, new QTableWidgetItem(cellText)); + } + startIdx = 1; + } + + for (int r = 0; r < m_view->rowCount(); ++r) { + QStringList list = parse_line(lines.at(startIdx + r).toUtf8(), m_encoding, sep); + for (int c = 0; c < colsToInsert; ++c) { + QString cellText = c < list.size() ? list.at(c).trimmed() : ""; + if (QTableWidgetItem *item = m_view->item(r, atCol + c)) { + m_isProgrammaticChange = true; + item->setText(cellText); + m_isProgrammaticChange = false; + } + } + } +} + +void CsvViewerWidget::insertEmptyColumns(int count, int atCol) { + if (m_view->rowCount() <= 0 || count <= 0) return; + m_undoStack->push(new RowColCommand(m_view, atCol, count, false, true)); +} + +void CsvViewerWidget::deleteColumnSelection() { + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (sel.isEmpty()) return; + + QSet colsSet; + for (const QModelIndex &index : sel) colsSet.insert(index.column()); + QList colsToDelete = colsSet.values(); + std::sort(colsToDelete.begin(), colsToDelete.end(), std::greater()); + + m_undoStack->beginMacro("Delete cols"); + for (int c : colsToDelete) { + m_undoStack->push(new RowColCommand(m_view, c, 1, false, false)); + } + m_undoStack->endMacro(); +} + +void CsvViewerWidget::showContextMenu(const QPoint &pos) +{ + QMenu menu(this); + QAction *actCopyTSV = nullptr; + QAction *actCopyCSV = nullptr; + QAction *actDelete = nullptr; + + QAction *actInsertAbove = nullptr; + QAction *actInsertBelow = nullptr; + QAction *actPasteAbove = nullptr; + QAction *actPasteBelow = nullptr; + + int minRow = m_view->rowCount(); + int maxRow = -1; + int numRows = 0; + + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (!sel.isEmpty()) { + QSet rows; + for (const QModelIndex &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)); + + QString clipboardText = QApplication::clipboard()->text(); + if (!clipboardText.isEmpty()) { + menu.addSeparator(); + actPasteAbove = menu.addAction("Insert from Clipboard above"); + actPasteBelow = menu.addAction("Insert from Clipboard below"); + } + } + + QAction *res = menu.exec(m_view->viewport()->mapToGlobal(pos)); + QTimer::singleShot(0, this, &CsvViewerWidget::restoreViewFocus); + if (!res) return; + + if (res == actCopyTSV) copySelection('\t'); + else if (res == actCopyCSV) copySelection(','); + else if (res == actDelete) deleteSelection(); + else if (res == actInsertAbove) insertEmptyRows(numRows, minRow); + else if (res == actInsertBelow) insertEmptyRows(numRows, maxRow + 1); + else if (res == actPasteAbove) pasteSelectionAt(minRow); + else if (res == actPasteBelow) pasteSelectionAt(maxRow + 1); +} + +void CsvViewerWidget::showColumnContextMenu(const QPoint &pos) +{ + QMenu menu(this); + QAction *actCopy = nullptr; + QAction *actDelete = nullptr; + QAction *actInsertLeft = nullptr; + QAction *actInsertRight = nullptr; + QAction *actPasteLeft = nullptr; + QAction *actPasteRight = nullptr; + + int minCol = m_view->columnCount(); + int maxCol = -1; + int numCols = 0; + + QModelIndexList sel = m_view->selectionModel()->selectedIndexes(); + if (!sel.isEmpty()) { + QSet cols; + for (const QModelIndex &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)); + + QString clipboardText = QApplication::clipboard()->text(); + if (!clipboardText.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, &CsvViewerWidget::restoreViewFocus); + if (!res) return; + + if (res == actCopy) copyColumnSelection('\t'); + else if (res == actDelete) deleteColumnSelection(); + else if (res == actInsertLeft) insertEmptyColumns(numCols, minCol); + else if (res == actInsertRight) insertEmptyColumns(numCols, maxCol + 1); + else if (res == actPasteLeft) pasteColumnSelectionAt(minCol); + else if (res == actPasteRight) pasteColumnSelectionAt(maxCol + 1); +} + + +HANDLE DCPCALL ListLoad(HANDLE ParentWin, char* FileToLoad, int 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->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->setActive(true); + view->setFocus(Qt::OtherFocusReason); + } else { + widget->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(widget, "", 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); + } +} + +void CsvViewerWidget::showFindReplacePanel(bool show) +{ + if (!m_findReplacePanel) return; + + m_findReplacePanel->setVisible(show); + if (m_actFindReplace) { + m_actFindReplace->blockSignals(true); + m_actFindReplace->setChecked(show); + m_actFindReplace->blockSignals(false); + } + + if (show) { + setFocusProxy(m_txtFind); + m_txtFind->setFocus(Qt::OtherFocusReason); + m_txtFind->selectAll(); + m_lblStatus->clear(); + } else { + setFocusProxy(m_view); + m_lblStatus->clear(); + restoreViewFocus(); + } +} + +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_txtFind->text(); + if (query.isEmpty()) { + m_lblStatus->setText("Search query is empty."); + return; + } + + bool matchCase = m_chkMatchCase->isChecked(); + bool entireCell = m_chkMatchEntire->isChecked(); + bool regexFlag = m_chkRegex->isChecked(); + QString scope = m_comboScope->currentText(); + + int rows = m_view->rowCount(); + int cols = m_view->columnCount(); + if (rows == 0 || cols == 0) { + m_lblStatus->setText("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_lblStatus->setText("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)) { + // Found match! + m_view->setCurrentCell(cell.first, cell.second); + m_view->scrollToItem(m_view->item(cell.first, cell.second)); + m_lblStatus->setText(QString("Found match at (%1, %2)").arg(cell.first + 1).arg(cell.second + 1)); + return; + } + } + + m_lblStatus->setText("No match found."); +} + +void CsvViewerWidget::doReplace() +{ + QString query = m_txtFind->text(); + QString replaceText = m_txtReplace->text(); + if (query.isEmpty()) { + m_lblStatus->setText("Search query is empty."); + return; + } + + QModelIndex current = m_view->currentIndex(); + if (!current.isValid()) { + doFind(true); + return; + } + + bool matchCase = m_chkMatchCase->isChecked(); + bool entireCell = m_chkMatchEntire->isChecked(); + bool regexFlag = m_chkRegex->isChecked(); + + 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_undoStack->push(new EditCellCommand(m_view, row, col, oldText, newText, oldQuoted, newQuoted)); + m_lblStatus->setText(QString("Replaced match at (%1, %2)").arg(row + 1).arg(col + 1)); + } + // Move to the next match + doFind(true); + } else { + // Not a match, find the next one + doFind(true); + } +} + +void CsvViewerWidget::doReplaceAll() +{ + QString query = m_txtFind->text(); + QString replaceText = m_txtReplace->text(); + if (query.isEmpty()) { + m_lblStatus->setText("Search query is empty."); + return; + } + + bool matchCase = m_chkMatchCase->isChecked(); + bool entireCell = m_chkMatchEntire->isChecked(); + bool regexFlag = m_chkRegex->isChecked(); + QString scope = m_comboScope->currentText(); + + int rows = m_view->rowCount(); + int cols = m_view->columnCount(); + if (rows == 0 || cols == 0) { + m_lblStatus->setText("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_lblStatus->setText("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_lblStatus->setText("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_undoStack->beginMacro(QString("Replace All: %1 -> %2").arg(query).arg(replaceText)); + for (const auto &rep : replacements) { + m_undoStack->push(new EditCellCommand(m_view, rep.row, rep.col, rep.oldText, rep.newText, rep.oldQuoted, rep.newQuoted)); + } + m_undoStack->endMacro(); + m_lblStatus->setText(QString("Replaced %1 occurrences.").arg(replacements.size())); + } else { + m_lblStatus->setText("No replacements made."); + } +}