Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97e5281c65 | ||
|
|
0af90b565d | ||
|
|
6e3fc16c9f | ||
|
|
21ec9f30a6 | ||
|
|
4dd1a87a2a | ||
|
|
6cda28fcdd | ||
|
|
0c4b6e390a | ||
|
|
d0bd86513e | ||
|
|
b5c803df87 | ||
|
|
983e6f7b1a | ||
|
|
68edcc0bfb | ||
|
|
b705c4e4a0 | ||
|
|
31d582d29e | ||
|
|
ad2fa2c92e | ||
|
|
796d03f8a9 | ||
|
|
be68b706f0 | ||
|
|
23a7c73331 | ||
|
|
82fe8ce3a1 | ||
|
|
00cd1262b4 | ||
|
|
7bc734a9a8 | ||
|
|
a19ccaf035 | ||
|
|
60625103f8 | ||
|
|
aa02f2cacb | ||
|
|
6a1e39efa9 | ||
|
|
7242c3f775 | ||
|
|
a43f6441b4 | ||
|
|
2f5ae373a1 | ||
|
|
b978361180 | ||
|
|
93b8055085 | ||
|
|
70ee338fc3 |
No files matched your search
@@ -54,6 +54,9 @@ command output.
|
||||
Before changing the file manager UI, read [FILES-UI.md](FILES-UI.md).
|
||||
Reuse the shared dialog rendering and layout helpers. Update the document
|
||||
when an agreed UI requirement changes.
|
||||
Verify visible changes in a rendered window. For rendering regressions,
|
||||
add an automated check that fails on the reported appearance and run it
|
||||
before handing off a new build.
|
||||
|
||||
## Link changes to Gitea issues
|
||||
|
||||
|
||||
+33
-2
@@ -1,11 +1,13 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.77 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.85 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
add_executable(KSwitcherFiles WIN32 src/files_main.cpp resources/files.rc)
|
||||
add_executable(KSwitcherFiles WIN32 src/files_main.cpp src/files_text_window.cpp
|
||||
src/files_text_document.cpp src/files_text_language.cpp resources/files.rc)
|
||||
include(cmake/FilesTextLibraries.cmake)
|
||||
set_target_properties(KSwitcherFiles PROPERTIES OUTPUT_NAME "KSwitcher.Files")
|
||||
target_compile_definitions(KSwitcherFiles PRIVATE UNICODE _UNICODE NOMINMAX _WIN32_WINNT=0x0A00)
|
||||
find_program(KSWITCHER_POWERSHELL NAMES pwsh powershell REQUIRED)
|
||||
@@ -14,6 +16,11 @@ add_custom_target(KSwitcherTerminalAssets
|
||||
-Destination "$<TARGET_FILE_DIR:KSwitcherFiles>"
|
||||
VERBATIM)
|
||||
add_dependencies(KSwitcherFiles KSwitcherTerminalAssets)
|
||||
add_custom_target(check-files-search-arrow
|
||||
COMMAND "${KSWITCHER_POWERSHELL}" -NoProfile -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/files_search_arrow.ps1"
|
||||
-Executable "$<TARGET_FILE:KSwitcherFiles>"
|
||||
DEPENDS KSwitcherFiles
|
||||
VERBATIM)
|
||||
target_include_directories(KSwitcherFiles PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/build/terminal-deps/sdk/build/native/include")
|
||||
target_link_libraries(KSwitcherFiles PRIVATE user32 gdi32 comctl32 shell32 comdlg32 advapi32 ole32 uuid)
|
||||
if(MSVC)
|
||||
@@ -106,6 +113,21 @@ else()
|
||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
enable_testing()
|
||||
add_executable(KSwitcherFilesTextTests tests/files_text_document.cpp src/files_text_document.cpp)
|
||||
target_include_directories(KSwitcherFilesTextTests PRIVATE src)
|
||||
target_compile_definitions(KSwitcherFilesTextTests PRIVATE UNICODE _UNICODE NOMINMAX WIN32_LEAN_AND_MEAN)
|
||||
target_link_libraries(KSwitcherFilesTextTests PRIVATE ole32)
|
||||
if(NOT MSVC)
|
||||
target_link_options(KSwitcherFilesTextTests PRIVATE -static)
|
||||
endif()
|
||||
add_test(NAME files-text-document COMMAND KSwitcherFilesTextTests)
|
||||
add_executable(KSwitcherFilesModalTests tests/files_modal_dialog.cpp)
|
||||
target_include_directories(KSwitcherFilesModalTests PRIVATE src)
|
||||
target_link_libraries(KSwitcherFilesModalTests PRIVATE user32 comctl32)
|
||||
if(NOT MSVC)
|
||||
target_link_options(KSwitcherFilesModalTests PRIVATE -static)
|
||||
endif()
|
||||
add_test(NAME files-modal-dialog COMMAND KSwitcherFilesModalTests)
|
||||
add_executable(KSwitcherLanTests tests/lan.cpp)
|
||||
target_link_libraries(KSwitcherLanTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
@@ -117,6 +139,15 @@ add_test(NAME lan-pairing-regressions COMMAND KSwitcherLanTests)
|
||||
add_executable(KSwitcherLanFilesTests tests/lan_files.cpp)
|
||||
target_link_libraries(KSwitcherLanFilesTests PRIVATE kscore)
|
||||
add_test(NAME lan-files-regressions COMMAND KSwitcherLanFilesTests)
|
||||
add_executable(KSwitcherFilesActivationFixture tests/files_activation_fixture.cpp)
|
||||
if(NOT MSVC)
|
||||
target_link_options(KSwitcherFilesActivationFixture PRIVATE -municode -static)
|
||||
endif()
|
||||
add_test(NAME files-activation-and-refresh
|
||||
COMMAND "${KSWITCHER_POWERSHELL}" -NoProfile -ExecutionPolicy Bypass
|
||||
-File "${CMAKE_CURRENT_SOURCE_DIR}/tests/files_activation.ps1"
|
||||
-Executable "$<TARGET_FILE:KSwitcherFiles>" -Fixture "$<TARGET_FILE:KSwitcherFilesActivationFixture>")
|
||||
set_tests_properties(files-activation-and-refresh PROPERTIES RUN_SERIAL TRUE TIMEOUT 120)
|
||||
add_executable(KSwitcherLanInputTests tests/lan_input.cpp)
|
||||
target_link_libraries(KSwitcherLanInputTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
|
||||
+148
-16
@@ -2,16 +2,41 @@
|
||||
|
||||
Far Manager є зразком для оформлення файлового менеджера. Основне вікно використовує системний заголовок Windows. Панелі, меню й діалоги операцій використовують моноширинний шрифт менеджера та оформлення в стилі Far.
|
||||
|
||||
Власні модальні діалоги панелей, редактора й переглядача використовують
|
||||
`FilesModalDialog`. Escape і клік поза діалогом у клієнтській області Files
|
||||
виконують Cancel, а фокус повертається до елемента, з якого відкрили діалог.
|
||||
Кліки на робочому столі та в інших застосунках залишають діалог відкритим.
|
||||
Заголовок і рамка вікна-власника доступні, а діалог рухається разом із
|
||||
власником. Діалог центрується відносно власника та звужується разом із ним
|
||||
до власної мінімальної ширини. Поки діалог відкритий, ширина власника
|
||||
обмежена цією мінімальною шириною з відступами.
|
||||
Це діє і для вкладених діалогів. Під час файлової операції Cancel спершу
|
||||
просить підтвердити переривання.
|
||||
|
||||
## Скорочення
|
||||
|
||||
- У командному рядку Ctrl+V і Shift+Insert вставляють текст із буфера обміну
|
||||
в позицію курсора або замінюють виділення. Якщо фокус на файловій панелі,
|
||||
ці клавіші переводять його до командного рядка й вставляють текст там.
|
||||
Enter запускає команду. Escape очищає поле й залишає фокус у командному рядку.
|
||||
- Enter і подвійний клік відкривають папку в поточній панелі, а локальний
|
||||
документ — у програмі, призначеній для його типу у Windows. EXE запускається
|
||||
окремим процесом. BAT і CMD виконуються в поточній сесії вбудованого
|
||||
термінала, а їхній вивід з’являється під панелями. Щоб відкрити файл із
|
||||
панелі LAN, спочатку скопіюй його до локальної папки.
|
||||
|
||||
- У головному вікні клавіатурний фокус залишається в командному рядку.
|
||||
Tab перемикає активну панель, клік вибирає рядок, а стрілки вгору й вниз,
|
||||
Page Up і Page Down пересувають курсор у панелі навіть під час набору команди.
|
||||
Меню й діалоги тимчасово приймають фокус. Ctrl+V і Shift+Insert вставляють
|
||||
текст із буфера обміну в позицію курсора або замінюють виділення.
|
||||
Enter запускає введену команду або відкриває поточний елемент, якщо рядок
|
||||
порожній. Escape очищає рядок.
|
||||
Промпт і текст команди вирівняні по спільній базовій лінії.
|
||||
|
||||
- Ctrl+E підставляє останню команду та переводить фокус із файлової панелі
|
||||
до командного рядка. Повторне натискання
|
||||
- Ctrl+F вставляє повний шлях елемента під курсором активної панелі в позицію
|
||||
курсора командного рядка або замінює виділення. Подвійні лапки додаються
|
||||
лише до шляхів із пробілами. Для `..` вставляється шлях батьківської папки.
|
||||
- Ctrl+C копіює весь введений у командний рядок текст, навіть коли виділено
|
||||
лише його частину.
|
||||
|
||||
- Ctrl+E підставляє останню команду. Повторне натискання
|
||||
підставляє старішу, доки не досягне початку історії. Alt+F8 відкриває історію
|
||||
з групуванням за датами. Enter або подвійний клік у списку підставляє вибрану
|
||||
команду в поле. Для запуску натисни Enter у самому полі.
|
||||
@@ -33,7 +58,7 @@ Far Manager є зразком для оформлення файлового м
|
||||
Вікно історії бірюзове, поле пошуку сіро-бірюзове. Поки історія відкрита,
|
||||
дії в панелях, меню й командному рядку заблоковано. Заголовок і рамка
|
||||
головного вікна доступні для переміщення, зміни розміру та керування вікном.
|
||||
Escape або клік поза діалогом закриває історію. Заголовок і рамка головного
|
||||
Escape або клік у Files поза діалогом закриває історію. Заголовок і рамка головного
|
||||
вікна залишають історію відкритою під час керування вікном.
|
||||
Історія центрується відносно головного вікна й рухається разом із ним.
|
||||
Tab циклічно переводить фокус між пошуком і списком.
|
||||
@@ -41,19 +66,91 @@ Far Manager є зразком для оформлення файлового м
|
||||
Висота списку залежить від кількості результатів. Коли місця бракує,
|
||||
прокручується список.
|
||||
|
||||
- Короткий Alt перемикає головне меню Files при відпусканні. F9 також перемикає меню. Після закриття фокус повертається до активної панелі. Alt з літерою верхнього рівня спрацьовує одразу при натисканні літери. Alt+O відкриває Options, а відпускання Alt після комбінації не перемикає меню.
|
||||
- Alt із символом шукає початок назви файлу чи папки. Після першого збігу
|
||||
внизу активної панелі відкривається компактне поле Search у подвійній рамці.
|
||||
Наступні символи можна набирати без Alt. Пошук враховує поточну розкладку
|
||||
та ігнорує регістр. Backspace прибирає останній символ, Escape закриває пошук,
|
||||
а Enter відкриває поточний елемент.
|
||||
- F9 перемикає головне меню Files. Після закриття фокус повертається в командний рядок. Коли меню відкрите, Alt з літерою верхнього рівня відкриває відповідний пункт.
|
||||
- Коли головне меню відкрите, L, F, C, O та R відкривають Left, Files, Commands, Options та Right. У підменю підсвічена літера викликає відповідну команду. Літери унікальні в межах поточного рівня. Усталені мнемоніки мають пріоритет, зокрема X для Exit.
|
||||
- Глобальне скорочення **Open KSwitcher Files** можна змінити або очистити в налаштуваннях. Для профілю без цього поля пропонується Ctrl+Alt+F, якщо Windows реєструє комбінацію і вона вільна серед прив’язок KSwitcher.
|
||||
- Глобальне скорочення повертає до відкритого вікна Files у поточній сесії Windows або запускає Files, якщо такого вікна немає. Пункт **Files** у треї відкриває нове вікно.
|
||||
|
||||
- Ctrl+Shift+0–9 відкриває діалог Title і Path із поточною папкою. OK додає запис без дублювання, Escape скасовує. Title необов’язковий, порожнє значення показує шлях. Записи LAN зберігають ID ПК, а редагування Path залишає запис на тому самому ПК.
|
||||
- Ctrl+0–9 одразу відкриває єдину папку. Для кількох папок відкривається компактний попап Far з одним заголовком Folder shortcuts у подвійній рамці та коротким рядом клавіш у нижній рамці. Ширина залежить від найдовшого запису й обмежується розміром вікна, а висота — кількістю видимих рядків. Нижня рамка показує Ins Del F4 Ctrl+Up Ctrl+Down. Insert додає поточну папку через Title і Path, Delete відв’язує, F4 редагує, Ctrl+Up і Ctrl+Down змінюють і зберігають порядок. Enter відкриває папку. Escape або клік поза попапом закриває його й повертає фокус до панелі. Commands → Folder shortcuts дає вибрати зайняту цифру й відкрити той самий попап навіть для єдиного запису. Brief і Wide доступні в меню Left/Right та Options.
|
||||
- Ctrl+0–9 одразу відкриває єдину папку. Для кількох папок відкривається компактний попап Far з одним заголовком Folder shortcuts у подвійній рамці та коротким рядом клавіш у нижній рамці. Ширина залежить від найдовшого запису й обмежується розміром вікна, а висота — кількістю видимих рядків. Нижня рамка показує Ins Del F4 Ctrl+Up Ctrl+Down. Insert додає поточну папку через Title і Path, Delete відв’язує, F4 редагує, Ctrl+Up і Ctrl+Down змінюють і зберігають порядок. Enter відкриває папку. Escape або клік у Files поза попапом закриває його й повертає фокус до панелі. Commands → Folder shortcuts дає вибрати зайняту цифру й відкрити той самий попап навіть для єдиного запису. Brief і Wide доступні в меню Left/Right та Options.
|
||||
|
||||
## Налаштування Files
|
||||
|
||||
- Options у головному меню одразу відкриває стандартний діалог Windows. Ліворуч є пошук за назвою категорії чи параметра й дерево результатів. Вибір дочірнього вузла відкриває лише його редактор праворуч. Пошук за назвою окремої властивості відкриває вузол, що її містить. Перемикання вузлів зберігає незастосовані зміни.
|
||||
### Переглядач і редактор
|
||||
|
||||
- F3 відкриває поточний локальний файл у вбудованому переглядачі лише
|
||||
для читання. F4 відкриває спільне вікно в режимі редагування. Alt+F3
|
||||
відкриває файл у програмі, призначеній Windows для цього типу, а Alt+F4
|
||||
викликає зареєстровану дію Edit. Ці команди також є в меню Commands.
|
||||
F10 або × закривають головне вікно Files. Вбудовані Viewer та Editor
|
||||
відкриваються в окремих немодальних вікнах із власними кнопками на панелі
|
||||
завдань Windows. При повторному відкритті Files пропонує перейти до вже
|
||||
відкритого вікна. Закриття менеджера залишає вікна файлів відкритими.
|
||||
- F3 і F4 працюють і на панелі LAN. Файл завантажується у тимчасову копію
|
||||
на диску у фоновому режимі. Збереження передає зміни назад на віддалений ПК.
|
||||
Якщо розмір або час зміни віддаленого файла відрізняється від відкритої
|
||||
копії, Files відхиляє запис і залишає зміни в редакторі. Для F4 оновіть
|
||||
KSwitcher також на віддаленому ПК. Alt+F3 і Alt+F4 потребують локальної копії.
|
||||
- Вікно використовує той самий шрифт і розмір, що й файлові панелі,
|
||||
синю палітру Far за замовчуванням,
|
||||
рядок стану згори й підказки клавіш унизу. Інформація про файл
|
||||
вирівняна праворуч. Режим (Edit або View) стоїть у дужках після імені,
|
||||
далі йдуть кодування, синтаксис, позиція курсора й сторінка великого файла.
|
||||
Номери рядків показані приглушеним кольором з невеликим відступом від тексту.
|
||||
Меню редактора й переглядача використовують моноширинний шрифт і кольори
|
||||
обраної схеми. У FAR (blue) написи верхнього меню чорні, поточний пункт
|
||||
має чорне тло, а літери доступу жовті. F9 відкриває меню, Alt з літерою
|
||||
доступу відкриває потрібний розділ. Редактор позначає незбережені зміни зірочкою.
|
||||
Esc і F10 закривають незмінений файл одразу. Якщо вміст змінився,
|
||||
діалог Far пропонує Save, Discard або Cancel.
|
||||
- Syntax → Automatic визначає синтаксис за іменем, розширенням або
|
||||
shebang. Відомі типи включають C#, C/C++, JavaScript/TypeScript,
|
||||
HTML, XML, CSS, SQL, Python, PowerShell, Bash, JSON, YAML, TOML і Markdown.
|
||||
У меню Syntax можна вибрати будь-який вбудований лексер Lexilla або Plain text.
|
||||
Стан багаторядкової конструкції починається заново на межі сторінки.
|
||||
- Ctrl+PgUp/PgDn перемикають сторінки, F5 переходить до заданої сторінки,
|
||||
Ctrl+Shift+Home/End відкривають першу й останню. Ctrl+F шукає в поточній
|
||||
сторінці, F3 повторює пошук. Ctrl+A виділяє поточну сторінку.
|
||||
Історія Undo/Redo діє в межах відкритої сторінки.
|
||||
- Початкова сторінка займає близько 1 МіБ файла. Для вставок і редагування
|
||||
сторінка може вирости до 8 МіБ у UTF-8. Зміни інших сторінок тимчасово
|
||||
зберігаються на диску. F2 або Ctrl+S потоково записують нову копію в
|
||||
папку файла, тож збереження потребує вільного місця для цієї копії.
|
||||
UTF-8 із BOM або без нього та UTF-16 LE/BE із BOM зберігають своє
|
||||
кодування. Нередаговані байти копіюються без перетворення.
|
||||
- Команда Options у панелях, редакторі й переглядачі відкриває один діалог
|
||||
KSwitcher Files Options. Розділи Viewer та Editor зберігають окремі профілі.
|
||||
З редактора або переглядача діалог відкриває відповідний розділ. Apply
|
||||
застосовує зміни до відкритих вікон, Cancel відкидає незастосовану чернетку.
|
||||
Доступні перенесення рядків, номери рядків, показ пробілів і
|
||||
кінців рядків, підсвічування поточного рядка, ширина табуляції від 1 до 16,
|
||||
схеми FAR (blue), Dark і Light та власні кольори. Save scheme у розділі
|
||||
Viewer або Editor додає поточну палітру до спільного списку схем. Apply
|
||||
зберігає її та робить доступною в обох розділах. У редакторі також можна
|
||||
ввімкнути вставлення пробілів замість табуляцій і автоматичні відступи,
|
||||
а також вибрати символи кінця для нових рядків.
|
||||
- Діалоги пошуку й переходу до сторінки використовують подвійні рамки та
|
||||
кнопки Files. Search має компактний розмір, сіре тло, бірюзове поле та окремі кнопки
|
||||
Find previous і Find next. Word і Selection підставляють у поле слово
|
||||
під курсором або однорядкове виділення. Поле має висоту одного рядка
|
||||
й вертикально центрований текст. Стрілка біля поля або Alt+Down
|
||||
відкриває історію з групуванням за датами. Вибір запису підставляє
|
||||
запит у поле. Editor і Viewer спільно зберігають до 100 останніх
|
||||
різних запитів між запусками. Спільний шрифт панелей, меню й тексту змінюється в
|
||||
Options → Appearance → Font. Редактор і переглядач отримують новий
|
||||
шрифт після Apply.
|
||||
|
||||
### Параметри файлових панелей
|
||||
|
||||
- Options у головному меню спочатку прибирає смугу меню й одразу відкриває стандартний діалог Windows. Ліворуч є пошук за назвою категорії чи параметра й дерево результатів. Вибір дочірнього вузла відкриває лише його редактор праворуч. Пошук за назвою окремої властивості відкриває вузол, що її містить. Перемикання вузлів зберігає незастосовані зміни.
|
||||
- File panel modes має вузли Panel layout, Wide panel columns і Status line columns. У Colors кожен колір має власний вузол. В Appearance шрифт редагується у вузлі Font. Діалог змінює розмір від розміру, який вміщує всі параметри. Ширина лівої навігації стала, дерево росте по висоті, а права область використовує додатковий простір. OK, Cancel та Apply лишаються внизу праворуч.
|
||||
- Reset selected to defaults скидає вибраний рівень дерева. Корінь All settings скидає всі параметри, навіть коли пошук приховує частину дерева. У редакторах із кількома параметрами Reset property скидає лише вибрану властивість. Скидання змінює чернетку до Apply або OK. Стандартний Panel background — `#000080`.
|
||||
- Apply доступна, коли чернетка відрізняється від застосованих налаштувань. Після Apply або повернення всіх значень до застосованих вона вимикається. Apply одразу зберігає зміни й перемальовує панелі за відкритим діалогом. OK робить те саме й закриває діалог. Cancel і Escape відкидають зміни після останнього Apply та повертають фокус до панелі.
|
||||
- Apply доступна, коли чернетка відрізняється від застосованих налаштувань. Після Apply або повернення всіх значень до застосованих вона вимикається. Apply одразу зберігає зміни й перемальовує панелі за відкритим діалогом. OK робить те саме й закриває діалог. Cancel і Escape відкидають зміни після останнього Apply та повертають фокус до командного рядка.
|
||||
|
||||
- Colors → File type styles налаштовує текст, фон, підкреслення й закреслення для правил за розширенням і групи System and hidden. Розширення можна вибрати зі списку Windows або додати вручну з крапкою чи без неї. Вони зберігаються в KSwitcher, незалежно від асоціацій Windows. Курсор, позначки й результат передачі зберігають свої сигнальні кольори. Кольори правила можна перевизначити окремо для звичайного рядка, позначки Insert, курсора у фокусі та курсора без фокуса. Позначка має пріоритет над курсором, а підсвічування передачі має пріоритет над кольорами курсора. Без перевизначення стан зберігає звичайний вигляд панелі. Мініпанель унизу редактора одразу показує всі чотири стани на синтетичних рядках до Apply.
|
||||
|
||||
@@ -65,6 +162,32 @@ Far Manager є зразком для оформлення файлового м
|
||||
|
||||
## Панелі
|
||||
|
||||
- Обидві панелі автоматично підхоплюють створення, зміну й видалення файлів
|
||||
у відкритих локальних папках. Фокус залишається там, де був, а курсор
|
||||
лишається на поточному елементі, якщо той ще існує.
|
||||
|
||||
- Ctrl+8 відкриває список усіх ярликів папок. Кожен рядок показує групу
|
||||
й назву або шлях. Enter відкриває папку в активній панелі, F4 редагує
|
||||
ярлик, а Delete прибирає його зі списку.
|
||||
|
||||
- У Change drive повідомлення No paired computers з’являється після
|
||||
успішної відповіді з порожнім списком. Збої локального запиту до
|
||||
KSwitcher відображаються окремо.
|
||||
|
||||
- Якщо команда у вбудованому PowerShell змінює поточну папку,
|
||||
панель, з якої надіслано команду, переходить до цієї папки.
|
||||
|
||||
- Коли список LAN-дисків змінює розмір меню Change drive, рамка й список
|
||||
змінюють позицію разом. Рамка повністю перемальовується після зміни
|
||||
розміру, а список має однаковий внутрішній відступ зліва і справа.
|
||||
- Тайм-аут запиту дисків відображається як Connection timed out. Якщо
|
||||
ввімкнено Write log, Files записує помилки запитів пар і дисків в окремий
|
||||
журнал `data/kswitcher-files.log`. Системні помилки містять код Windows.
|
||||
|
||||
- Застосовані налаштування Files одразу підхоплюють інші відкриті вікна.
|
||||
Apply зберігає поля, змінені в поточній чернетці. Якщо два вікна одночасно
|
||||
редагували набір стилів файлів, відкрий Options заново, щоб побачити свіжі правила.
|
||||
|
||||
- Типовий колір тексту для ZIP-архівів — світлий коралово-рожевий.
|
||||
|
||||
- Меню Change drive показує літеру, тип, загальний обсяг і вільне місце. Відкриття меню запускає нове LAN-виявлення. Секція KSwitcher LAN показує збережені пари, зокрема офлайн. Після кожного відкриття меню асинхронно запитує диски й показує їх під назвою відповідного ПК. Поки триває запит, видно стан завантаження. Якщо запит не вдався, видно помилку. Повторне відкриття запускає новий запит. Кожна панель має власний диск і шлях, а в заголовку віддаленої панелі перед шляхом показано назву ПК.
|
||||
@@ -73,11 +196,13 @@ Far Manager є зразком для оформлення файлового м
|
||||
- F5, F6, F8 і Shift+Delete у віддалених панелях використовують спільні підтвердження, попередження й прогрес. F5/F6 працюють із файлами та деревами папок між локальними й віддаленими панелями, зокрема між двома різними ПК. Між шляхами одного віддаленого ПК вміст копіюється на ньому. F8 використовує кошик того ПК, на якому лежить файл.
|
||||
- Після розриву з’єднання повтори F5 або F6 до того самого призначення. Віддалене копіювання відновлюється з підтвердженої позиції, якщо збережені байти відповідають початку вихідного файла. Підтверджене скасування видаляє частковий запис поточного файла. Завершені файли лишаються на місці. Наявний файл призначення замінюється після повного запису, а при переміщенні джерело видаляється після підтвердження запису призначення.
|
||||
|
||||
- F7, пункт Files → Make folder і кнопка F7 Mkdir відкривають модальний діалог Far. Стрілка біля імені та Alt+Down відкривають історію успішних введень. Link type має варіанти none, directory junction і symbolic link. Поле Target активне для посилань і приймає абсолютний шлях або шлях від поточної папки. У віддаленій панелі посилання створюється на віддаленому ПК, а symbolic link залежить від дозволів Windows на цьому ПК.
|
||||
- Process multiple names дозволяє розділити імена комами або крапками з комою. Якщо такий символ є частиною імені, візьми ім’я в подвійні лапки. Діалог створює елементи у поточному каталозі, оновлює панель і ставить курсор на останній створений елемент. При помилці в середині списку вже створені елементи зберігаються.
|
||||
- Після F5 або F6 успішно оброблені елементи коротко підсвічуються в панелі, що показує папку призначення. Підсвітка тексту відрізняється від курсора й не переносить фокус.
|
||||
|
||||
- У широкому режимі ширина під вирівняні розширення залежить від найдовшого розширення в поточній панелі. За вузької панелі залишай місце для імені та колонки Size.
|
||||
|
||||
- Бірюзовий курсор поточного рядка видно лише в активній панелі, зокрема коли клавіатурний фокус переходить у командний рядок. Кожна панель зберігає свою позицію курсора. Коли панелі видимі, F5 і F6 з командного рядка діють на активну панель.
|
||||
- Бірюзовий курсор поточного рядка видно в активній панелі. Кожна панель зберігає свою позицію курсора. Коли панелі видимі, F5 і F6 з командного рядка діють на активну панель.
|
||||
- Клік у панелі нижче списку ставить курсор на останній рядок, а клік вище списку — на перший.
|
||||
|
||||
- Поточний шлях відображається по центру верхньої подвійної лінії панелі. Лінію видно з обох боків напису. Бірюзове тло активної панелі охоплює лише напис шляху з невеликими відступами. Довгий шлях скорочується в межах панелі.
|
||||
@@ -85,7 +210,7 @@ Far Manager є зразком для оформлення файлового м
|
||||
## Спільні елементи діалогів
|
||||
|
||||
- Власні діалоги Files блокують дії панелей і командного рядка, залишаючи
|
||||
системний заголовок і рамку головного вікна доступними. Клік поза діалогом
|
||||
системний заголовок і рамку головного вікна доступними. Клік у Files поза діалогом
|
||||
діє як Cancel. Для прогресу копіювання, переміщення й видалення це відкриває
|
||||
підтвердження скасування.
|
||||
|
||||
@@ -95,10 +220,17 @@ Far Manager є зразком для оформлення файлового м
|
||||
- Для власних діалогів і списків Far використовуй спільні метрики та малювання подвійної рамки. Еталон Delete задає 8 px від краю тла до першої лінії та 14 px від внутрішньої лінії до бічної межі вмісту при 96 DPI. Обидва відступи масштабуються за DPI. Вертикальний відступ враховує висоту заголовка та нижніх підказок. Діалоги й списки зберігають свою палітру та поведінку.
|
||||
- Appearance → Dialog frame налаштовує зовнішній і внутрішній відступи від 0 до 24 px при 96 DPI. Row text padding додає від 0 до 4 ширин символу з кожного боку тексту в рядках Folder shortcuts. Inner padding задає відстань від рамки до краю списку. Ширина попапа враховує обидва текстові відступи, а довгі шляхи скорочуються в межах доступної ширини. Живий зразок показує чернетку. Apply зберігає значення, Cancel відкидає незастосовані зміни, Reset selected to defaults повертає 8 px, 14 px і 0 символів. Профілі без цих параметрів використовують ті самі значення.
|
||||
- У ресурсі чи виклику задавай назву кнопки й клавішу доступу. Спільна функція `paintDialogButton` центрує текст, підсвічує фокус і додає фігурні дужки для дії за замовчуванням та квадратні для решти кнопок.
|
||||
- Модальні діалоги редактора й переглядача використовують той самий шрифт, що й діалоги операцій Files. Розміри полів і кнопок враховують метрики цього шрифту.
|
||||
- Нижні секції з кнопками у Copy, Warning і вікні прогресу мають однакову висоту, прив’язану до висоти рядка шрифту. Використовуй `layoutDialogButtons` для такої секції та `centerDialogRow` для інших рядків між роздільниками. Межі секції визначають положення рядка.
|
||||
- Для текстових полів Far використовуй ресурс `FAR_EDIT` і спільну підготовку та палітру діалогів. Поля призначення Copy/Rename і Title/Path використовують нативний Win32 Edit без рамки, моноширинний шрифт і вертикальне центрування. Кольори Text і Popup background діють на всі ці поля, зокрема на поле Copy лише для читання. У відповідних розділах Options → Colors зміни видно на зразку поля. Existing files у Copy є списком без редагування. Поля та редагований список Windows Options мають системне оформлення.
|
||||
- Search і спільні нижні секції Far використовують компактні проміжки між рядками й кнопками, прив’язані до метрик моноширинного шрифту. У Search висота кнопки дорівнює висоті одного рядка.
|
||||
- Нижні кнопки Far групуються по центру. Їхня ширина залежить від напису й дужок, а проміжок між ними становить приблизно половину символу.
|
||||
- Прапорці Far у Search, Copy і попередженнях використовують спільний компонент. У фокусі короткий системний курсор блимає між дужками, а тло рядка зберігає колір діалогу.
|
||||
- У Copy і Rename/Move помилки шляху показує окремий червоний модальний діалог Far. Після його закриття фокус повертається до поля шляху.
|
||||
- Короткі червоні повідомлення використовують звичайний текст без смуги прокрутки. Висота діалогу залежить від кількості рядків повідомлення.
|
||||
- Для однорядкових полів Far використовуй спільний компонент `FAR_EDIT` і спільну підготовку та палітру діалогів. Компонент використовує нативний Win32 Edit без рамки й центрує текст вертикально. Його використовують Search, Copy/Rename, Title/Path і пошук в історії. Кольори Text і Popup background діють на всі ці поля, зокрема на поле Copy лише для читання. У відповідних розділах Options → Colors зміни видно на зразку поля. Existing files у Copy є списком без редагування. Поля та редагований список Windows Options мають системне оформлення.
|
||||
- Для позиціонування роздільника використовуй невидимий маркер, якщо межу не задає інший елемент секції. Спільний обробник малює лінію.
|
||||
- Випадаючі списки Far використовують бірюзове тло палітри менеджера. Текст і стрілку вирівнюй вертикально по центру. Прапорці в діалогах операцій використовують позначки `[ ]` та `[x]` і видимий клавіатурний фокус.
|
||||
- Випадаючі списки й поля Far мають висоту одного рядка шрифту. Бірюзове поле прилягає до сірої ділянки завширшки один символ із темною, вертикально центрованою стрілкою. Make folder, Search і Copy використовують спільне малювання стрілки. Прапорці в діалогах операцій використовують позначки `[ ]` та `[x]` і видимий клавіатурний фокус.
|
||||
- Розкриті списки й історія Far використовують спільний попап із бірюзовим тлом, світлою рамкою та чорним вибраним рядком. Попап прилягає до обох країв бірюзового поля, стрілка лишається праворуч за межами списку. Історія групується за датами. Довгі списки прокручуються, Enter вибирає рядок, Escape закриває список і повертає фокус до поля.
|
||||
- Повторно використовуй спільне оформлення. Текст, дії та стан операції передавай із коду конкретної операції. Наприклад, Warning повертає відповідь і стан прапорця, а копіювання вирішує, чи застосувати відповідь до наступних конфліктів.
|
||||
|
||||
## Діалог Copy
|
||||
@@ -129,7 +261,7 @@ Far Manager є зразком для оформлення файлового м
|
||||
## Видалення
|
||||
|
||||
- F8 показує сіре підтвердження з дією Move. Shift+Delete і підтвердження непорожньої папки мають темно-червоне тло. Вікно прогресу видалення має сіре тло, як у Copy.
|
||||
- Escape у початковому підтвердженні повертає фокус до панелі, з якої викликано видалення. Поточний рядок зберігається, а стрілки одразу працюють.
|
||||
- Escape у початковому підтвердженні повертає фокус до командного рядка. Поточний рядок активної панелі зберігається, а стрілки одразу працюють.
|
||||
- F8 переміщує підтверджені елементи в кошик Windows. Shift+Delete видаляє підтверджене дерево від вкладених елементів до батьківської папки. Прогрес і підсумок окремо показують успішні, пропущені й невдалі дії.
|
||||
- Cancel і Esc у вікні прогресу відкривають спільне червоне підтвердження скасування. Yes зупиняє операцію, а No або Esc у підтвердженні повертає до прогресу. Початковий фокус стоїть на No.
|
||||
|
||||
|
||||
@@ -925,6 +925,12 @@ Run `./package.ps1 -PublishRelease` after building and testing to refresh the tr
|
||||
|
||||
## Verification
|
||||
|
||||
The Files build uses CMake 3.24 or later and Ninja, both included in the local
|
||||
w64devkit toolchain. CMake downloads pinned Scintilla and Lexilla source
|
||||
archives for the built-in viewer and editor on the first configure. The
|
||||
[Scintilla and Lexilla license](resources/files-text-license.txt) is also
|
||||
embedded in KSwitcher.Files.exe.
|
||||
|
||||
```powershell
|
||||
.\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard
|
||||
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,65 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.85 — 2026-11-15
|
||||
|
||||
Files viewer and editor windows now run independently with separate Windows taskbar buttons. Reopening a file offers to switch to its existing window. F3 and F4 work in LAN panes. The editor downloads files in the background and checks the remote file before replacing it on save. Update KSwitcher on the remote PC for writeback.
|
||||
|
||||
**Українською.** Переглядач і редактор Files працюють в окремих вікнах із власними кнопками на панелі завдань Windows. При повторному відкритті Files пропонує перейти до наявного вікна. F3 і F4 працюють у панелях LAN. Редактор завантажує файл у фоні та перевіряє віддалений файл перед записом. Для збереження оновіть KSwitcher також на віддаленому ПК.
|
||||
|
||||
## v0.6.84 — 2026-11-15
|
||||
|
||||
KSwitcher Files adds an internal viewer on F3 and editor on F4 with syntax highlighting, paged access to large text files, and shared search history. Alt+F3 and Alt+F4 open external applications. Files Options now includes viewer and editor settings, including tab width and the FAR (blue) color scheme. F7 creates folders and directory links, supports multiple names, and remembers successful inputs. Far-style dialogs share focus, resizing and keyboard behavior.
|
||||
|
||||
**Українською.** У KSwitcher Files з’явилися вбудований переглядач по F3 і редактор по F4 з підсвічуванням синтаксису, посторінковою роботою з великими текстовими файлами та спільною історією пошуку. Alt+F3 і Alt+F4 відкривають зовнішні програми. Спільні Options містять параметри переглядача й редактора, зокрема ширину табуляції та схему FAR (blue). F7 створює папки й посилання на каталоги, підтримує кілька імен і запам’ятовує успішні введення. Модальні діалоги Far мають спільну поведінку фокуса, зміни розміру й клавіатурної навігації.
|
||||
|
||||
## v0.6.83 — 2026-11-15
|
||||
|
||||
In KSwitcher Files, the command line keeps keyboard focus while Tab switches panes and Up, Down, Page Up, and Page Down move the active pane's cursor. Ctrl+F inserts the full path under the cursor, using double quotes only for paths with spaces. Ctrl+C copies the entire command line. Alt plus a character starts a filename or folder search. After the first match, type without Alt in the Search popup at the bottom of the active pane. F9 opens the menu.
|
||||
|
||||
**Українською.** У KSwitcher Files клавіатурний фокус залишається в командному рядку. Tab перемикає панелі, а стрілки вгору й вниз, Page Up і Page Down пересувають курсор активної панелі. Ctrl+F вставляє повний шлях під курсором і додає подвійні лапки лише до шляхів із пробілами. Ctrl+C копіює весь командний рядок. Alt із символом починає пошук файлу чи папки за назвою. Після першого збігу уточнюйте запит без Alt у полі Search внизу активної панелі. F9 відкриває меню.
|
||||
|
||||
## v0.6.82 — 2026-11-14
|
||||
|
||||
Enter and double-click open folders in the current pane, open local documents with their Windows default apps, and launch EXE files in separate processes. BAT and CMD files run in the existing embedded PowerShell session, and their output appears below the panes. Both local panes refresh when files are created, modified, or deleted externally and preserve focus.
|
||||
|
||||
**Українською.** Enter і подвійний клік відкривають папки в поточній панелі та локальні документи у стандартних програмах Windows. EXE запускаються окремими процесами, а BAT і CMD виконуються в поточній сесії вбудованого PowerShell із виводом під панелями. Обидві локальні панелі підхоплюють створення, зміну й видалення файлів ззовні та зберігають фокус.
|
||||
|
||||
## v0.6.81 — 2026-11-14
|
||||
|
||||
Ctrl+8 lists all folder shortcuts. Press Enter to open a folder, F4 to edit the shortcut, or Delete to remove it. Ctrl+Alt+F brings an open Files window forward.
|
||||
|
||||
**Українською.** Ctrl+8 показує всі ярлики папок. Enter відкриває папку, F4 редагує ярлик, Delete видаляє його. Ctrl+Alt+F повертає до відкритого вікна Files.
|
||||
|
||||
## v0.6.80 — 2026-11-14
|
||||
|
||||
Includes the Files updates from v0.6.79. Change drive now explains why the local peer lookup failed and shows No paired computers only when the response is empty.
|
||||
|
||||
**Українською.** Випуск включає зміни Files із v0.6.79. Change drive показує конкретну причину збою запиту пар. No paired computers з’являється лише після успішної порожньої відповіді.
|
||||
|
||||
## v0.6.79 — 2026-11-14
|
||||
|
||||
In KSwitcher Files, changing directory in PowerShell updates the panel
|
||||
that sent the command. The global Files shortcut brings an open window to
|
||||
the foreground. Change drive redraws its frame when LAN drives arrive and
|
||||
shows a short timeout message. Files writes LAN lookup errors to its own log.
|
||||
|
||||
**Українською.** Зміна каталогу в PowerShell оновлює панель, з якої
|
||||
надіслано команду. Глобальна клавіша Files повертає до відкритого вікна.
|
||||
Change drive перемальовує рамку після появи LAN-дисків і показує коротке
|
||||
повідомлення про тайм-аут. Files записує помилки LAN у власний журнал.
|
||||
|
||||
## v0.6.78 — 2026-11-14
|
||||
|
||||
The Windows package includes KSwitcher Files, a two-panel file manager with
|
||||
local and paired LAN drives, folder shortcuts, an embedded PowerShell terminal,
|
||||
and searchable command history. Applied Files settings update other open
|
||||
instances immediately.
|
||||
|
||||
**Українською.** До пакета для Windows входить KSwitcher Files, двопанельний
|
||||
файловий менеджер із локальними дисками й дисками спарених ПК, ярликами папок,
|
||||
вбудованим PowerShell та пошуком в історії команд. Застосовані налаштування
|
||||
Files одразу підхоплюють інші відкриті вікна.
|
||||
|
||||
## v0.6.77 — 2026-11-13
|
||||
|
||||
The screenshot editor remembers the local pencil smoothing default across
|
||||
|
||||
@@ -1 +1 @@
|
||||
51d95639527590b36453a5ef485532a7d2135950222fa197eb431a5309f1a45c KSwitcher-win-x64.zip
|
||||
658c9098a51408a680156534f9083491f9b9e53d330547a1003cefce23f34f49 KSwitcher-win-x64.zip
|
||||
+85
-35
@@ -1,86 +1,136 @@
|
||||
{
|
||||
"version": "0.6.77",
|
||||
"version": "0.6.85",
|
||||
"schema": 1,
|
||||
"files": [
|
||||
{
|
||||
"sha256": "e0b471ca3aef6bf59c76b0554800cc7c0fde74f3aade3b82c0d5df906d7d8b0d",
|
||||
"path": "KSwitcher.exe",
|
||||
"size": 2249216,
|
||||
"sha256": "f1c79d337d746efcda03946cc833b7b5588ef4e739f1dff7d3afa15d0465469f"
|
||||
"size": 2454528
|
||||
},
|
||||
{
|
||||
"sha256": "5cd5277464d37d8d4ea9141b5283010c3d480422d4c8fecea9b9e7346f9d72ad",
|
||||
"path": "KSwitcher.Config.exe",
|
||||
"size": 2251776,
|
||||
"sha256": "016edc389007e0d15ee365fff7115fc91a820b46080cb39a3fb362f3b463bae7"
|
||||
"size": 2431488
|
||||
},
|
||||
{
|
||||
"sha256": "0a3db52490fb1e368ff859f01dd2fd7f49d254b78fd72cae0c79a197e345413d",
|
||||
"path": "KSwitcher.Screenshot.exe",
|
||||
"size": 3247616,
|
||||
"sha256": "c8ef070ad01acc410802c62e0df6a96cb299fe036fe8e99f0cf6a398b7324ec6"
|
||||
"size": 3050496
|
||||
},
|
||||
{
|
||||
"sha256": "53fa251679a827a000f3133fff27f26744c57336ab457b4db341221e523b45d5",
|
||||
"path": "KSwitcher.Files.exe",
|
||||
"size": 3635200
|
||||
},
|
||||
{
|
||||
"sha256": "d3fce90bdeecb08596e377dea6a316354dd8949e32ff09b89d51be00c14e21b6",
|
||||
"path": "WebView2Loader.dll",
|
||||
"size": 194912
|
||||
},
|
||||
{
|
||||
"sha256": "e256f01188af527e4d06d21d06fbf785ae9c50d4b328bf03cbe0ba7f0aa4228f",
|
||||
"path": "terminal/addon-fit-LICENSE.txt",
|
||||
"size": 1103
|
||||
},
|
||||
{
|
||||
"sha256": "ba3ea256ce0620a0992a197d6c9baea64823fc93d8da07a9e366ca9943c18527",
|
||||
"path": "terminal/addon-fit.js",
|
||||
"size": 1521
|
||||
},
|
||||
{
|
||||
"sha256": "084d97a727b778fb10d31adcd557ee7840a38377ed60e0ef0f3ece93b349b1ef",
|
||||
"path": "terminal/index.html",
|
||||
"size": 527
|
||||
},
|
||||
{
|
||||
"sha256": "d60e6ff8948b6616695297695b05ef6e74b497a8e32e6031fc2e4fe2cca39ca7",
|
||||
"path": "terminal/terminal.js",
|
||||
"size": 4167
|
||||
},
|
||||
{
|
||||
"sha256": "b569f629d00f2626a8100df2a1798210535621e42164dfd426a6fe5aac7b0ccd",
|
||||
"path": "terminal/xterm-LICENSE.txt",
|
||||
"size": 1261
|
||||
},
|
||||
{
|
||||
"sha256": "854a7c0fb70e8b1a083c16797ab827299fb18744f5ad34f227b48337e33293c6",
|
||||
"path": "terminal/xterm.css",
|
||||
"size": 7112
|
||||
},
|
||||
{
|
||||
"sha256": "14903579ff54664cd72f8e8699e6961a6272c21863ec1c3b118cdc8af5d4a972",
|
||||
"path": "terminal/xterm.js",
|
||||
"size": 488663
|
||||
},
|
||||
{
|
||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
|
||||
"path": "LICENSE",
|
||||
"size": 4401,
|
||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
|
||||
"size": 4401
|
||||
},
|
||||
{
|
||||
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86",
|
||||
"path": "json-LICENSE.txt",
|
||||
"size": 1075,
|
||||
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86"
|
||||
"size": 1075
|
||||
},
|
||||
{
|
||||
"sha256": "185c5a37521316e940ead89cbf0b1ba5862d810c7c966e6c6a14a4a515b23625",
|
||||
"path": "files-text-license.txt",
|
||||
"size": 883
|
||||
},
|
||||
{
|
||||
"sha256": "7b34f2af7341f57d7bac1b5b7e55a40595a6bc20f461f98c0c6320336d0deba7",
|
||||
"path": "README.md",
|
||||
"size": 93885,
|
||||
"sha256": "df6d40d0eb66ad7da8db1cffe6bd211de70dfee474cc228e041c821b3940718b"
|
||||
"size": 94223
|
||||
},
|
||||
{
|
||||
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada",
|
||||
"path": "LAN-PREVIEW.md",
|
||||
"size": 11418,
|
||||
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada"
|
||||
"size": 11418
|
||||
},
|
||||
{
|
||||
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21",
|
||||
"path": "dictionaries/en.txt",
|
||||
"size": 682867,
|
||||
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21"
|
||||
"size": 682867
|
||||
},
|
||||
{
|
||||
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5",
|
||||
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
|
||||
"size": 20138,
|
||||
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
|
||||
"size": 20138
|
||||
},
|
||||
{
|
||||
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311",
|
||||
"path": "dictionaries/README.md",
|
||||
"size": 2230,
|
||||
"sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec"
|
||||
"size": 2265
|
||||
},
|
||||
{
|
||||
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b",
|
||||
"path": "dictionaries/ru.txt",
|
||||
"size": 1060364,
|
||||
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b"
|
||||
"size": 1060364
|
||||
},
|
||||
{
|
||||
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408",
|
||||
"path": "dictionaries/sources.json",
|
||||
"size": 1279,
|
||||
"sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86"
|
||||
"size": 1280
|
||||
},
|
||||
{
|
||||
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf",
|
||||
"path": "dictionaries/uk.txt",
|
||||
"size": 898248,
|
||||
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86"
|
||||
"size": 940799
|
||||
},
|
||||
{
|
||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
|
||||
"path": "tools/README.txt",
|
||||
"size": 993,
|
||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
|
||||
"size": 993
|
||||
},
|
||||
{
|
||||
"sha256": "5513e63e0a9c2a0d3bed65dd8add2c46588093e562d26c5fa2a24a421bd31fcb",
|
||||
"path": "tools/StayHere.exe",
|
||||
"size": 134656,
|
||||
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
|
||||
"size": 133120
|
||||
},
|
||||
{
|
||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
|
||||
"path": "tools/StayHere-README.md",
|
||||
"size": 2796,
|
||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
|
||||
"size": 2796
|
||||
}
|
||||
],
|
||||
"schema": 1
|
||||
]
|
||||
}
|
||||
@@ -47,14 +47,22 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||
$filesPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Files.exe'
|
||||
$terminalIncludes = & "$root/tools/stage-files-terminal.ps1" -Destination (Split-Path $filesPath)
|
||||
& "$root/tools/stage-files-terminal.ps1" -Destination build/release | Out-Null
|
||||
& windres resources/files.rc -O coff -o build/files-res.o
|
||||
if ($LASTEXITCODE) { throw 'File manager resource compilation failed' }
|
||||
& $compiler @common "-I$terminalIncludes" -mwindows -municode -static -s src/files_main.cpp build/files-res.o build/lan_files.o build/lan_channel.o build/lan_pairing.o build/platform.o build/core.o '-Wl,--gc-sections' @libs -o $filesPath
|
||||
$filesBuild = Join-Path $outDir 'files-cmake'
|
||||
& cmake -S $root -B $filesBuild -G Ninja "-DCMAKE_CXX_COMPILER=$compiler" -DCMAKE_BUILD_TYPE=MinSizeRel
|
||||
if ($LASTEXITCODE) { throw 'File manager configuration failed' }
|
||||
& cmake --build $filesBuild --target KSwitcherFiles --parallel 8
|
||||
if ($LASTEXITCODE) { throw 'File manager build failed' }
|
||||
Copy-Item -LiteralPath (Join-Path $filesBuild 'KSwitcher.Files.exe') -Destination $filesPath -Force
|
||||
& strip $filesPath
|
||||
if ($LASTEXITCODE) { throw 'File manager stripping failed' }
|
||||
& "$root/tools/stage-files-terminal.ps1" -Destination (Split-Path $filesPath) | Out-Null
|
||||
& "$root/tools/stage-files-terminal.ps1" -Destination build/release | Out-Null
|
||||
Copy-Item -LiteralPath $filesPath -Destination build/release/KSwitcher.Files.exe -Force
|
||||
if ($Test) {
|
||||
& cmake --build $filesBuild --target KSwitcherFilesTextTests
|
||||
if ($LASTEXITCODE) { throw 'Files text test build failed' }
|
||||
& (Join-Path $filesBuild 'KSwitcherFilesTextTests.exe')
|
||||
if ($LASTEXITCODE) { throw 'Files text tests failed' }
|
||||
& $compiler @common -Werror tests/settings_merge.cpp build/sync.o build/storage.o build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherSettingsMergeTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Settings merge test build failed' }
|
||||
& .\build\KSwitcherSettingsMergeTests.exe
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(scintilla
|
||||
URL https://www.scintilla.org/scintilla569.zip
|
||||
URL_HASH SHA256=fddc637afa6e3a9205a5ad67c777d711a921ed2797d792f3034010c1be1539d6
|
||||
DOWNLOAD_EXTRACT_TIMESTAMP FALSE)
|
||||
FetchContent_Declare(lexilla
|
||||
URL https://www.scintilla.org/lexilla555.zip
|
||||
URL_HASH SHA256=713ada097b7da0594638e2c5723d8dea486653c23ec2e0c12631df7e5b7f0991
|
||||
DOWNLOAD_EXTRACT_TIMESTAMP FALSE)
|
||||
FetchContent_MakeAvailable(scintilla lexilla)
|
||||
|
||||
file(GLOB scintilla_sources "${scintilla_SOURCE_DIR}/src/*.cxx" "${scintilla_SOURCE_DIR}/win32/*.cxx")
|
||||
list(FILTER scintilla_sources EXCLUDE REGEX "/ScintillaDLL\\.cxx$")
|
||||
file(GLOB lexilla_sources "${lexilla_SOURCE_DIR}/lexlib/*.cxx" "${lexilla_SOURCE_DIR}/lexers/*.cxx")
|
||||
add_library(KSwitcherFilesText STATIC ${scintilla_sources} ${lexilla_sources}
|
||||
"${lexilla_SOURCE_DIR}/src/Lexilla.cxx")
|
||||
target_include_directories(KSwitcherFilesText
|
||||
PUBLIC "${scintilla_SOURCE_DIR}/include" "${lexilla_SOURCE_DIR}/include"
|
||||
PRIVATE "${scintilla_SOURCE_DIR}/src" "${lexilla_SOURCE_DIR}/lexlib")
|
||||
target_compile_definitions(KSwitcherFilesText PRIVATE UNICODE _UNICODE NOMINMAX LEXILLA_NO_EXPORT)
|
||||
target_link_libraries(KSwitcherFilesText PRIVATE imm32 ole32 oleaut32 msimg32)
|
||||
target_link_libraries(KSwitcherFiles PRIVATE KSwitcherFilesText)
|
||||
if(MSVC)
|
||||
set_property(TARGET KSwitcherFilesText KSwitcherFiles PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
target_compile_options(KSwitcherFilesText PRIVATE /utf-8)
|
||||
endif()
|
||||
+2
-1
@@ -11,6 +11,7 @@ Copy-Item -LiteralPath (Join-Path $root 'build\release\WebView2Loader.dll') -Des
|
||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\terminal') -Destination $destination -Recurse -Force
|
||||
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'resources\files-text-license.txt') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'LAN-PREVIEW.md') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
||||
@@ -19,7 +20,7 @@ Copy-Item -LiteralPath (Join-Path $root 'resources\portable-tools-README.txt') -
|
||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\tools\StayHere.exe') -Destination (Join-Path $destination 'tools\StayHere.exe') -Force
|
||||
Copy-Item -LiteralPath (Join-Path $root 'utilities\StayHere\README.md') -Destination (Join-Path $destination 'tools\StayHere-README.md') -Force
|
||||
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
||||
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','KSwitcher.Files.exe','WebView2Loader.dll','terminal','LICENSE','json-LICENSE.txt','README.md','LAN-PREVIEW.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
|
||||
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','KSwitcher.Files.exe','WebView2Loader.dll','terminal','LICENSE','json-LICENSE.txt','files-text-license.txt','README.md','LAN-PREVIEW.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
|
||||
$entries = foreach ($path in $files) {
|
||||
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
|
||||
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,77,0
|
||||
PRODUCTVERSION 0,6,77,0
|
||||
FILEVERSION 0,6,85,0
|
||||
PRODUCTVERSION 0,6,85,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -20,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.6.77"
|
||||
VALUE "FileVersion", "0.6.85"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
License for Lexilla, Scintilla, and SciTE
|
||||
|
||||
Copyright 1998-2021 by Neil Hodgson <[email protected]>
|
||||
|
||||
All Rights Reserved
|
||||
|
||||
Permission to use, copy, modify, and distribute this software and its
|
||||
documentation for any purpose and without fee is hereby granted,
|
||||
provided that the above copyright notice appear in all copies and that
|
||||
both that copyright notice and this permission notice appear in
|
||||
supporting documentation.
|
||||
|
||||
NEIL HODGSON DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS
|
||||
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS, IN NO EVENT SHALL NEIL HODGSON BE LIABLE FOR ANY
|
||||
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
|
||||
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE
|
||||
OR PERFORMANCE OF THIS SOFTWARE.
|
||||
+116
-11
@@ -1,11 +1,82 @@
|
||||
#pragma code_page(65001)
|
||||
#include <windows.h>
|
||||
#include <commctrl.h>
|
||||
|
||||
#define FAR_EDIT(id, mode, x, y, width, height) \
|
||||
CONTROL "", id, "Edit", ES_AUTOHSCROLL | WS_TABSTOP | mode, x, y, width, height
|
||||
CONTROL "", id, "KSwitcher.Files.FarEdit", ES_MULTILINE | ES_AUTOHSCROLL | WS_TABSTOP | mode, x, y, width, height
|
||||
|
||||
1 ICON "kswitcher-files.ico"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
601 RCDATA "files-text-license.txt"
|
||||
|
||||
602 DIALOGEX 0, 0, 230, 85
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
LTEXT "&Page number", 101, 18, 21, 194, 12
|
||||
FAR_EDIT(100, ES_NUMBER, 18, 38, 194, 16)
|
||||
CONTROL "&Go", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 53, 59, 60, 17
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 117, 59, 60, 17
|
||||
END
|
||||
|
||||
604 DIALOGEX 0, 0, 268, 101
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP | WS_CLIPCHILDREN
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
CONTROL "&Search for", 109, "Static", SS_OWNERDRAW, 18, 17, 130, 12
|
||||
FAR_EDIT(100, 0, 18, 30, 241, 14)
|
||||
CONTROL "↓", 108, "Button", BS_OWNERDRAW | WS_TABSTOP, 259, 30, 11, 14
|
||||
CONTROL "W&ord", 106, "Button", BS_OWNERDRAW | WS_TABSTOP, 160, 14, 49, 15
|
||||
CONTROL "Se&lection", 107, "Button", BS_OWNERDRAW | WS_TABSTOP, 212, 14, 58, 15
|
||||
CONTROL "", 104, "Static", NOT WS_VISIBLE, 18, 51, 1, 1
|
||||
AUTOCHECKBOX "&Case sensitive", 101, 18, 52, 110, 12
|
||||
AUTOCHECKBOX "&Whole words", 102, 18, 64, 110, 12
|
||||
CONTROL "", 105, "Static", NOT WS_VISIBLE, 18, 81, 1, 1
|
||||
CONTROL "Find pre&vious", 103, "Button", BS_OWNERDRAW | WS_TABSTOP, 33, 80, 83, 15
|
||||
CONTROL "Find &next", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 121, 80, 76, 15
|
||||
CONTROL "Canc&el", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 202, 80, 53, 15
|
||||
END
|
||||
|
||||
603 DIALOGEX 0, 0, 230, 72
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
LTEXT "Save changes to this file?", -1, 18, 23, 194, 12, SS_CENTER
|
||||
CONTROL "&Save", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 17, 42, 60, 17
|
||||
CONTROL "&Discard", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 82, 42, 66, 17
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 153, 42, 60, 17
|
||||
END
|
||||
|
||||
607 DIALOGEX 0, 0, 300, 106
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
CONTROL "", 100, "Edit", ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL | WS_TABSTOP, 18, 22, 264, 52
|
||||
CONTROL "&OK", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 120, 80, 60, 17
|
||||
CONTROL "&Yes", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP | NOT WS_VISIBLE, 85, 80, 60, 17
|
||||
CONTROL "&No", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP | NOT WS_VISIBLE, 155, 80, 60, 17
|
||||
AUTOCHECKBOX "&Remember for this session", 101, 18, 63, 264, 13, NOT WS_VISIBLE
|
||||
END
|
||||
|
||||
609 DIALOGEX 0, 0, 185, 57
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP | WS_CLIPCHILDREN
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
LTEXT "", 100, 14, 16, 157, 13, SS_NOPREFIX
|
||||
CONTROL "", 102, "Static", NOT WS_VISIBLE, 14, 35, 1, 1
|
||||
CONTROL "&OK", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 67, 38, 51, 15
|
||||
END
|
||||
|
||||
608 DIALOGEX 0, 0, 220, 67
|
||||
STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU
|
||||
CAPTION "Save color scheme"
|
||||
FONT 9, "Segoe UI"
|
||||
BEGIN
|
||||
LTEXT "Scheme &name", -1, 8, 8, 204, 10
|
||||
EDITTEXT 100, 8, 21, 204, 14, ES_AUTOHSCROLL
|
||||
DEFPUSHBUTTON "Save", IDOK, 105, 44, 50, 14
|
||||
PUSHBUTTON "Cancel", IDCANCEL, 162, 44, 50, 14
|
||||
END
|
||||
|
||||
101 DIALOGEX 0, 0, 397, 310
|
||||
STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | WS_CLIPCHILDREN
|
||||
@@ -61,24 +132,40 @@ BEGIN
|
||||
AUTOCHECKBOX "&Underline", 297, 131, 166, 80, 12
|
||||
AUTOCHECKBOX "Stri&kethrough", 298, 218, 166, 100, 12
|
||||
CONTROL "Settings preview", 299, "Static", SS_OWNERDRAW | WS_BORDER, 131, 202, 253, 81
|
||||
AUTOCHECKBOX "&Wrap lines", 350, 131, 28, 118, 12
|
||||
AUTOCHECKBOX "&Line numbers", 351, 257, 28, 125, 12
|
||||
AUTOCHECKBOX "Show &whitespace and line endings", 352, 131, 45, 118, 12
|
||||
AUTOCHECKBOX "&Highlight current line", 353, 257, 45, 125, 12
|
||||
AUTOCHECKBOX "Insert &spaces for tabs", 354, 131, 62, 118, 12
|
||||
AUTOCHECKBOX "Auto &indent", 355, 257, 62, 125, 12
|
||||
LTEXT "&Tab width", 356, 131, 84, 118, 10
|
||||
COMBOBOX 357, 257, 81, 125, 100, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
|
||||
LTEXT "Line &endings for new lines", 358, 131, 104, 118, 10
|
||||
COMBOBOX 359, 257, 101, 125, 77, CBS_DROPDOWNLIST | WS_TABSTOP
|
||||
LTEXT "Color sc&heme", 362, 131, 125, 118, 10
|
||||
COMBOBOX 363, 257, 122, 125, 67, CBS_DROPDOWNLIST | WS_TABSTOP
|
||||
LTEXT "Customize &color", 364, 131, 145, 118, 10
|
||||
COMBOBOX 365, 257, 142, 125, 116, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
|
||||
PUSHBUTTON "Change color…", 366, 257, 181, 82, 15
|
||||
LTEXT "", 367, 345, 184, 45, 12
|
||||
PUSHBUTTON "&Save scheme…", 368, 131, 163, 118, 15
|
||||
DEFPUSHBUTTON "OK", IDOK, 222, 289, 50, 14
|
||||
PUSHBUTTON "Cancel", IDCANCEL, 280, 289, 50, 14
|
||||
PUSHBUTTON "&Apply", 283, 338, 289, 50, 14
|
||||
END
|
||||
|
||||
102 DIALOGEX 0, 0, 350, 145
|
||||
102 DIALOGEX 0, 0, 350, 110
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
|
||||
CAPTION "Copy"
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
LTEXT "Copy to", 210, 14, 15, 322, 12, SS_PATHELLIPSIS
|
||||
FAR_EDIT(211, ES_MULTILINE, 14, 29, 322, 18)
|
||||
LTEXT "", 328, 14, 49, 322, 35, SS_NOPREFIX
|
||||
LTEXT "Existing files:", 212, 14, 92, 122, 14, SS_CENTERIMAGE
|
||||
COMBOBOX 213, 140, 92, 196, 100, CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS | WS_VSCROLL | WS_TABSTOP
|
||||
CONTROL "Also ask on read-only files", 214, "Button", BS_OWNERDRAW | WS_TABSTOP, 14, 112, 322, 14
|
||||
CONTROL "&Copy", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 87, 127, 86, 18
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 177, 127, 86, 18
|
||||
FAR_EDIT(211, 0, 14, 29, 322, 18)
|
||||
LTEXT "Existing files:", 212, 14, 57, 122, 14, SS_CENTERIMAGE
|
||||
COMBOBOX 213, 140, 57, 196, 100, CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS | WS_VSCROLL | WS_TABSTOP
|
||||
AUTOCHECKBOX "Also ask on read-only files", 214, 14, 77, 322, 14
|
||||
CONTROL "&Copy", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 87, 92, 86, 18
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 177, 92, 86, 18
|
||||
END
|
||||
|
||||
103 DIALOGEX 0, 0, 460, 126
|
||||
@@ -111,7 +198,7 @@ BEGIN
|
||||
RTEXT "", 332, 78, 72, 270, 13
|
||||
LTEXT "", 333, 12, 88, 1, 1
|
||||
LTEXT "", 234, 12, 114, 1, 1
|
||||
CONTROL "Remember choice", 232, "Button", BS_OWNERDRAW | WS_TABSTOP, 12, 90, 336, 18
|
||||
AUTOCHECKBOX "Remember choice", 232, 12, 90, 336, 18
|
||||
CONTROL "&Overwrite", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 42, 122, 110, 18
|
||||
CONTROL "&Skip", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 158, 122, 68, 18
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 232, 122, 86, 18
|
||||
@@ -154,6 +241,24 @@ BEGIN
|
||||
LTEXT "", 248, 14, 73, 1, 1
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 157, 74, 86, 18
|
||||
END
|
||||
108 DIALOGEX 0, 0, 264, 110
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP | WS_CLIPCHILDREN
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
CONTROL "Create the &folder:", 360, "Static", SS_OWNERDRAW, 14, 15, 236, 12
|
||||
FAR_EDIT(361, 0, 14, 27, 225, 16)
|
||||
CONTROL "↓", 363, "Button", BS_OWNERDRAW | WS_TABSTOP, 239, 27, 11, 16
|
||||
CONTROL "", 362, "Static", NOT WS_VISIBLE, 14, 48, 1, 1
|
||||
CONTROL "&Link type:", 364, "Static", SS_OWNERDRAW, 14, 51, 73, 12
|
||||
COMBOBOX 365, 90, 48, 160, 64, CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS | WS_TABSTOP
|
||||
CONTROL "&Target:", 366, "Static", SS_OWNERDRAW, 14, 65, 73, 12
|
||||
FAR_EDIT(367, 0, 90, 63, 160, 16)
|
||||
AUTOCHECKBOX "Process &multiple names", 368, 14, 79, 236, 13
|
||||
CONTROL "", 369, "Static", NOT WS_VISIBLE, 14, 96, 1, 1
|
||||
CONTROL "&OK", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 72, 97, 54, 15
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 132, 97, 60, 15
|
||||
END
|
||||
|
||||
109 DIALOGEX 0, 0, 250, 85
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP | WS_CLIPCHILDREN
|
||||
CAPTION "Folder shortcuts"
|
||||
@@ -181,6 +286,6 @@ STYLE DS_SETFONT | DS_CENTER | WS_POPUP
|
||||
FONT 9, "Consolas"
|
||||
BEGIN
|
||||
LTEXT "&Search commands or topics", 326, 12, 14, 361, 12
|
||||
FAR_EDIT(325, ES_MULTILINE, 12, 28, 361, 16)
|
||||
FAR_EDIT(325, 0, 12, 28, 361, 16)
|
||||
LISTBOX 324, 12, 49, 361, 155, LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | WS_TABSTOP
|
||||
END
|
||||
@@ -7,6 +7,16 @@ const terminal = new Terminal({
|
||||
theme: { background: '#181818', foreground: '#e8e8e8' },
|
||||
});
|
||||
const fit = new FitAddon.FitAddon();
|
||||
terminal.parser.registerOscHandler(633, data => {
|
||||
const prefix = 'ks-directory:';
|
||||
if (!data.startsWith(prefix)) return false;
|
||||
try {
|
||||
bridge.postMessage('directory:' + decodeURIComponent(data.slice(prefix.length)));
|
||||
} catch {
|
||||
bridge.postMessage('directory:');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
terminal.loadAddon(fit);
|
||||
const host = document.getElementById('terminal');
|
||||
terminal.open(host);
|
||||
@@ -14,6 +24,8 @@ host.addEventListener('pointerup', () => bridge.postMessage('focus-input'), true
|
||||
const screen = host.querySelector('.xterm-screen');
|
||||
let traceEnabled = false;
|
||||
let traceBudget = 4096;
|
||||
let outputTraceBudget = 256;
|
||||
let traceOutput = false;
|
||||
let currentOffset = 0;
|
||||
function trace(phase) {
|
||||
if (!traceEnabled || traceBudget <= 0) return;
|
||||
@@ -39,10 +51,13 @@ function anchorOutput() {
|
||||
trace(2);
|
||||
}
|
||||
terminal.attachCustomKeyEventHandler(event => {
|
||||
if (event.type !== 'keydown' || !event.ctrlKey || event.altKey || event.shiftKey) return true;
|
||||
if (event.type !== 'keydown' || !event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) return true;
|
||||
if (event.code === 'KeyO') bridge.postMessage('toggle');
|
||||
else if (event.code === 'ArrowUp') bridge.postMessage('grow');
|
||||
else if (event.code === 'ArrowDown') bridge.postMessage('shrink');
|
||||
else if (event.code === 'KeyF' || event.code === 'KeyC') {
|
||||
if (!event.repeat) bridge.postMessage(event.code === 'KeyF' ? 'insert-path' : 'copy-command-line');
|
||||
}
|
||||
else return true;
|
||||
event.preventDefault();
|
||||
return false;
|
||||
@@ -54,8 +69,9 @@ terminal.onScroll(() => {
|
||||
anchorOutput();
|
||||
});
|
||||
bridge.addEventListener('message', ({ data }) => {
|
||||
if (data === 'trace:on') {
|
||||
if (data === 'trace:on' || data === 'trace:output') {
|
||||
traceEnabled = true;
|
||||
traceOutput = data === 'trace:output';
|
||||
trace(2);
|
||||
return;
|
||||
}
|
||||
@@ -73,7 +89,16 @@ bridge.addEventListener('message', ({ data }) => {
|
||||
}
|
||||
const bytes = new Uint8Array(data.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(data.slice(2*i, 2*i+2), 16);
|
||||
terminal.write(bytes, anchorOutput);
|
||||
terminal.write(bytes, () => {
|
||||
anchorOutput();
|
||||
if (traceOutput && outputTraceBudget > 0) {
|
||||
outputTraceBudget--;
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines = Array.from({ length: terminal.rows }, (_, row) =>
|
||||
buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '');
|
||||
bridge.postMessage('output:' + lines.join('\n').trimEnd());
|
||||
}
|
||||
});
|
||||
});
|
||||
new ResizeObserver(() => {
|
||||
const buffer = terminal.buffer.active;
|
||||
|
||||
+23
-1
@@ -143,6 +143,28 @@ ULONG_PTR snippetReplayMarker()
|
||||
}
|
||||
constexpr int OpenFiles = 25, FilesHotkeyFirst = 0x4B10, FilesHotkeySecond = 0x4B11;
|
||||
|
||||
bool activateFilesWindow()
|
||||
{
|
||||
HWND files{};
|
||||
EnumWindows([](HWND candidate, LPARAM context) -> BOOL
|
||||
{
|
||||
wchar_t name[32]{};
|
||||
if(!IsWindowVisible(candidate) || !GetClassNameW(candidate,name,std::size(name)) ||
|
||||
wcscmp(name,L"KSwitcher.Files"))
|
||||
return TRUE;
|
||||
*reinterpret_cast<HWND *>(context)=candidate;
|
||||
return FALSE;
|
||||
},reinterpret_cast<LPARAM>(&files));
|
||||
if(!files) return false;
|
||||
if(IsIconic(files)) ShowWindowAsync(files,SW_RESTORE);
|
||||
if(!SetForegroundWindow(files))
|
||||
{
|
||||
FLASHWINFO flash{sizeof(flash),files,FLASHW_TRAY,3,0};
|
||||
FlashWindowEx(&flash);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
||||
{
|
||||
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
||||
@@ -3037,7 +3059,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case WM_HOTKEY:
|
||||
if (app->filesHotkeyId && wp == static_cast<WPARAM>(app->filesHotkeyId))
|
||||
{
|
||||
app->dispatch(OpenFiles);
|
||||
if(!activateFilesWindow()) app->dispatch(OpenFiles);
|
||||
}
|
||||
else if (wp != FilesHotkeyFirst && wp != FilesHotkeySecond) app->dispatch((int)wp);
|
||||
return 0;
|
||||
|
||||
@@ -11,7 +11,8 @@ bool validHistoryKeys(const std::array<WORD,2> &keys)
|
||||
const bool letter=key>='A' && key<='Z';
|
||||
const bool function=key>=VK_F1 && key<=VK_F12;
|
||||
if(!letter && !function) return false;
|
||||
if(modifiers==HOTKEYF_CONTROL) return (letter && key!='C' && key!='V' && key!='X' && key!='A' && key!='Z' && key!='Y') || function;
|
||||
if(modifiers==HOTKEYF_CONTROL)
|
||||
return (letter && key!='C' && key!='F' && key!='V' && key!='X' && key!='A' && key!='Z' && key!='Y') || function;
|
||||
if(modifiers==HOTKEYF_ALT) return function && key!=VK_F1 && key!=VK_F2 && key!=VK_F4 && key!=VK_F10;
|
||||
return false;
|
||||
});
|
||||
@@ -29,11 +30,15 @@ void setOptionHistoryKeys(HWND dialog,const std::array<WORD,2> &keys)
|
||||
SendDlgItemMessageW(dialog,323,HKM_SETHOTKEY,keys[1],0);
|
||||
}
|
||||
|
||||
void loadCommandHistory()
|
||||
void loadCommandHistoryKeys()
|
||||
{
|
||||
const std::array<WORD,2> keys{static_cast<WORD>(preference(L"PreviousCommandKey",defaultHistoryKeys[0])),
|
||||
static_cast<WORD>(preference(L"CommandHistoryKey",defaultHistoryKeys[1]))};
|
||||
if(validHistoryKeys(keys)) commandHistoryKeys=keys;
|
||||
commandHistoryKeys=validHistoryKeys(keys)?keys:defaultHistoryKeys;
|
||||
}
|
||||
|
||||
void loadCommandHistory()
|
||||
{
|
||||
|
||||
DWORD bytes{};
|
||||
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,L"CommandHistory",RRF_RT_REG_BINARY,nullptr,nullptr,&bytes)
|
||||
@@ -56,11 +61,7 @@ void loadCommandHistory()
|
||||
|
||||
void rememberCommand(const std::wstring &command)
|
||||
{
|
||||
SYSTEMTIME now{};
|
||||
GetLocalTime(&now);
|
||||
wchar_t date[24]{};
|
||||
GetDateFormatEx(LOCALE_NAME_USER_DEFAULT,0,&now,L"dd.MM.yyyy",date,std::size(date),nullptr);
|
||||
terminalHistory.push_back({date,command});
|
||||
terminalHistory.push_back({ks::files::historyDate(),command});
|
||||
if(terminalHistory.size()>commandHistoryLimit) terminalHistory.erase(terminalHistory.begin());
|
||||
std::wstring data;
|
||||
for(const auto &entry:terminalHistory)
|
||||
|
||||
@@ -4,36 +4,6 @@ constexpr int historySearch=325;
|
||||
constexpr COLORREF historySearchSurface=RGB(132,177,177);
|
||||
constexpr int historySearchLabel=326;
|
||||
HWND commandHistoryWindow{};
|
||||
HHOOK historyMouseHook{};
|
||||
|
||||
LRESULT CALLBACK dismissHistoryOnOutsideClick(int code,WPARAM message,LPARAM data)
|
||||
{
|
||||
const auto dialog=commandHistoryWindow;
|
||||
if(code==HC_ACTION && dialog && IsWindowVisible(dialog) &&
|
||||
(message==WM_LBUTTONDOWN || message==WM_RBUTTONDOWN ||
|
||||
message==WM_MBUTTONDOWN || message==WM_XBUTTONDOWN))
|
||||
{
|
||||
const auto point=reinterpret_cast<const MSLLHOOKSTRUCT*>(data)->pt;
|
||||
const auto clicked=WindowFromPoint(point);
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
if(clicked!=dialog && !IsChild(dialog,clicked))
|
||||
{
|
||||
POINT local=point;
|
||||
RECT client{};
|
||||
GetClientRect(owner,&client);
|
||||
ScreenToClient(owner,&local);
|
||||
const bool ownerClient=clicked==owner || IsChild(owner,clicked);
|
||||
if(!ownerClient || PtInRect(&client,local))
|
||||
{
|
||||
const auto focusPanel=clicked==panels[0].list?panels[0].list:
|
||||
clicked==panels[1].list?panels[1].list:panels[active].list;
|
||||
if(PostMessageW(dialog,WM_CLOSE,reinterpret_cast<WPARAM>(ownerClient?focusPanel:nullptr),0)
|
||||
&& ownerClient) return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return CallNextHookEx(nullptr,code,message,data);
|
||||
}
|
||||
|
||||
std::wstring historyLower(std::wstring value)
|
||||
{
|
||||
@@ -136,9 +106,15 @@ LRESULT CALLBACK historySearchKeys(HWND search,UINT message,WPARAM key,LPARAM fl
|
||||
if(message==WM_GETDLGCODE)
|
||||
{
|
||||
const auto code=DefSubclassProc(search,message,key,flags);
|
||||
if(key==VK_RETURN || key==VK_TAB || key==VK_ESCAPE) return code&~DLGC_WANTALLKEYS;
|
||||
if(key==VK_TAB || key==VK_ESCAPE) return code&~DLGC_WANTALLKEYS;
|
||||
if(key==VK_RETURN) return code|DLGC_WANTMESSAGE;
|
||||
return code|DLGC_WANTARROWS;
|
||||
}
|
||||
if(message==WM_KEYDOWN && key==VK_RETURN)
|
||||
{
|
||||
SendMessageW(GetParent(search),WM_COMMAND,IDOK,0);
|
||||
return 0;
|
||||
}
|
||||
if(message==WM_KEYDOWN && (key==VK_UP || key==VK_DOWN))
|
||||
{
|
||||
const auto list=GetDlgItem(GetParent(search),historyList);
|
||||
@@ -157,6 +133,13 @@ int historyRowInset(UINT dpi)
|
||||
|
||||
LRESULT CALLBACK historyListScroll(HWND list,UINT message,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||
{
|
||||
if(message==WM_GETDLGCODE && wp==VK_RETURN)
|
||||
return DefSubclassProc(list,message,wp,lp)|DLGC_WANTMESSAGE;
|
||||
if(message==WM_KEYDOWN && wp==VK_RETURN)
|
||||
{
|
||||
SendMessageW(GetParent(list),WM_COMMAND,IDOK,0);
|
||||
return 0;
|
||||
}
|
||||
if(message==LB_GETTOPINDEX || message==WM_NCDESTROY)
|
||||
return DefSubclassProc(list,message,wp,lp);
|
||||
const auto top=SendMessageW(list,LB_GETTOPINDEX,0,0);
|
||||
@@ -166,7 +149,7 @@ LRESULT CALLBACK historyListScroll(HWND list,UINT message,WPARAM wp,LPARAM lp,UI
|
||||
return result;
|
||||
}
|
||||
|
||||
void sizeCommandHistory(HWND dialog,HWND list,bool movingOwner=false)
|
||||
void sizeCommandHistory(HWND dialog,HWND list)
|
||||
{
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
RECT ownerBounds{};
|
||||
@@ -207,14 +190,6 @@ void sizeCommandHistory(HWND dialog,HWND list,bool movingOwner=false)
|
||||
area.bottom-area.top);
|
||||
const auto left=std::clamp((ownerBounds.left+ownerBounds.right-target)/2,area.left,area.right-target);
|
||||
const auto y=std::clamp((ownerBounds.top+ownerBounds.bottom-height)/2,area.top,area.bottom-height);
|
||||
RECT current{};
|
||||
if(movingOwner && GetWindowRect(dialog,¤t) &&
|
||||
current.right-current.left==target && current.bottom-current.top==height)
|
||||
{
|
||||
if(current.left!=left || current.top!=y)
|
||||
SetWindowPos(dialog,nullptr,left,y,0,0,SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
return;
|
||||
}
|
||||
SetWindowPos(dialog,nullptr,left,y,target,height,SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
const auto place=[&](HWND control,int rowTop,int rowHeight)
|
||||
{
|
||||
@@ -228,18 +203,21 @@ void sizeCommandHistory(HWND dialog,HWND list,bool movingOwner=false)
|
||||
GetClientRect(list,&listClient);
|
||||
if(rows*textHeight<=listClient.bottom) SendMessageW(list,LB_SETTOPINDEX,0,0);
|
||||
InvalidateRect(dialog,nullptr,FALSE);
|
||||
FilesModalDialog::refreshLayout(dialog);
|
||||
}
|
||||
|
||||
void layoutOpenCommandHistory(bool movingOwner=false)
|
||||
void layoutOpenCommandHistory()
|
||||
{
|
||||
if(commandHistoryWindow)
|
||||
sizeCommandHistory(commandHistoryWindow,GetDlgItem(commandHistoryWindow,historyList),movingOwner);
|
||||
sizeCommandHistory(commandHistoryWindow,GetDlgItem(commandHistoryWindow,historyList));
|
||||
}
|
||||
|
||||
INT_PTR CALLBACK commandHistoryDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
if(message==WM_INITDIALOG)
|
||||
{
|
||||
commandHistoryWindow=dialog;
|
||||
SetWindowLongPtrW(dialog,DWLP_USER,lp);
|
||||
prepareCopyDialog(dialog);
|
||||
const auto list=GetDlgItem(dialog,historyList);
|
||||
SetWindowSubclass(list,historyListScroll,1,0);
|
||||
@@ -260,11 +238,7 @@ INT_PTR CALLBACK commandHistoryDialog(HWND dialog,UINT message,WPARAM wp,LPARAM
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_NCDESTROY && commandHistoryWindow==dialog)
|
||||
{
|
||||
if(historyMouseHook) UnhookWindowsHookEx(historyMouseHook);
|
||||
historyMouseHook=nullptr;
|
||||
commandHistoryWindow=nullptr;
|
||||
}
|
||||
if(message==WM_CTLCOLORLISTBOX || message==WM_CTLCOLOREDIT)
|
||||
{
|
||||
const auto dc=reinterpret_cast<HDC>(wp);
|
||||
@@ -320,14 +294,7 @@ INT_PTR CALLBACK commandHistoryDialog(HWND dialog,UINT message,WPARAM wp,LPARAM
|
||||
}
|
||||
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
|
||||
{
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
const auto focusPanel=message==WM_CLOSE?reinterpret_cast<HWND>(wp):panels[active].list;
|
||||
DestroyWindow(dialog);
|
||||
if(focusPanel && IsWindow(owner) && !IsIconic(owner))
|
||||
{
|
||||
SetActiveWindow(owner);
|
||||
SetFocus(focusPanel);
|
||||
}
|
||||
FilesModalDialog::finish(dialog,IDCANCEL);
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_COMMAND && (LOWORD(wp)==IDOK || (LOWORD(wp)==historyList && HIWORD(wp)==LBN_DBLCLK)))
|
||||
@@ -336,10 +303,9 @@ INT_PTR CALLBACK commandHistoryDialog(HWND dialog,UINT message,WPARAM wp,LPARAM
|
||||
const auto index=SendDlgItemMessageW(dialog,historyList,LB_GETITEMDATA,row,0);
|
||||
if(index>=0 && static_cast<size_t>(index)<terminalHistory.size())
|
||||
{
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
DestroyWindow(dialog);
|
||||
SetActiveWindow(owner);
|
||||
recallCommand(static_cast<size_t>(index));
|
||||
auto &choice=*reinterpret_cast<size_t *>(GetWindowLongPtrW(dialog,DWLP_USER));
|
||||
choice=static_cast<size_t>(index);
|
||||
FilesModalDialog::finish(dialog,IDOK);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
@@ -354,12 +320,9 @@ void refreshCommandHistory()
|
||||
|
||||
void showCommandHistory(HWND owner)
|
||||
{
|
||||
if(!commandHistoryWindow)
|
||||
commandHistoryWindow=CreateDialogParamW(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(111),owner,commandHistoryDialog,0);
|
||||
if(!commandHistoryWindow) return;
|
||||
if(!historyMouseHook)
|
||||
historyMouseHook=SetWindowsHookExW(WH_MOUSE_LL,dismissHistoryOnOutsideClick,GetModuleHandleW(nullptr),0);
|
||||
ShowWindow(commandHistoryWindow,SW_SHOW);
|
||||
SetActiveWindow(commandHistoryWindow);
|
||||
SetFocus(GetDlgItem(commandHistoryWindow,historySearch));
|
||||
if(commandHistoryWindow) return;
|
||||
size_t choice{};
|
||||
if(FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(111),owner,commandHistoryDialog,
|
||||
reinterpret_cast<LPARAM>(&choice))==IDOK)
|
||||
recallCommand(choice);
|
||||
}
|
||||
+299
-97
@@ -7,122 +7,324 @@ bool filesMouseDown(UINT message)
|
||||
message==WM_MBUTTONDOWN || message==WM_XBUTTONDOWN;
|
||||
}
|
||||
|
||||
bool handleFilesDialogClick(HWND dialog,HWND clicked,POINT point)
|
||||
{
|
||||
if(!clicked || clicked==dialog || IsChild(dialog,clicked) ||
|
||||
GetAncestor(clicked,GA_ROOTOWNER)!=GetAncestor(dialog,GA_ROOTOWNER))
|
||||
return false;
|
||||
|
||||
wchar_t windowClass[16]{};
|
||||
if(GetClassNameW(clicked,windowClass,16) && std::wstring_view(windowClass)==L"#32768")
|
||||
{
|
||||
GUITHREADINFO thread{sizeof(thread)};
|
||||
if(GetGUIThreadInfo(GetWindowThreadProcessId(dialog,nullptr),&thread) &&
|
||||
(thread.hwndMenuOwner==dialog || IsChild(dialog,thread.hwndMenuOwner)))
|
||||
return false;
|
||||
}
|
||||
// System pickers and dropdowns owned by this dialog handle their own input.
|
||||
for(auto popup=GetAncestor(clicked,GA_ROOT);popup;popup=GetWindow(popup,GW_OWNER))
|
||||
if(popup==dialog || IsChild(dialog,popup)) return false;
|
||||
for(auto owner=GetWindow(dialog,GW_OWNER);owner;owner=GetWindow(owner,GW_OWNER))
|
||||
{
|
||||
if(clicked!=owner) continue;
|
||||
RECT client{};
|
||||
GetClientRect(owner,&client);
|
||||
ScreenToClient(owner,&point);
|
||||
if(!PtInRect(&client,point)) return false;
|
||||
break;
|
||||
}
|
||||
if(const auto dropdown=static_cast<HWND>(GetPropW(dialog,ks::files::farDropDownProperty)))
|
||||
return PostMessageW(dropdown,WM_CLOSE,0,0)!=FALSE;
|
||||
return PostMessageW(dialog,WM_COMMAND,IDCANCEL,0)!=FALSE;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK filesDialogMouse(int code,WPARAM message,LPARAM data)
|
||||
{
|
||||
const auto dialog=activeFilesDialog;
|
||||
if(code==HC_ACTION && dialog && IsWindowVisible(dialog) && filesMouseDown(static_cast<UINT>(message)))
|
||||
{
|
||||
const auto point=reinterpret_cast<const MSLLHOOKSTRUCT*>(data)->pt;
|
||||
const auto clicked=WindowFromPoint(point);
|
||||
const auto root=GetAncestor(dialog,GA_ROOTOWNER);
|
||||
if(clicked!=dialog && !IsChild(dialog,clicked))
|
||||
{
|
||||
RECT client{};
|
||||
POINT local=point;
|
||||
GetClientRect(root,&client);
|
||||
ScreenToClient(root,&local);
|
||||
if(clicked!=root || PtInRect(&client,local))
|
||||
{
|
||||
const bool internal=clicked==root || IsChild(root,clicked) ||
|
||||
(clicked && GetAncestor(clicked,GA_ROOTOWNER)==root);
|
||||
if(PostMessageW(dialog,WM_COMMAND,IDCANCEL,0) && internal) return 1;
|
||||
}
|
||||
}
|
||||
if(handleFilesDialogClick(dialog,WindowFromPoint(point),point)) return 1;
|
||||
}
|
||||
return CallNextHookEx(nullptr,code,message,data);
|
||||
}
|
||||
|
||||
struct FilesDialogInput
|
||||
class FilesModalDialog
|
||||
{
|
||||
HWND previous=activeFilesDialog;
|
||||
explicit FilesDialogInput(HWND dialog)
|
||||
public:
|
||||
FilesModalDialog(HINSTANCE instance,LPCWSTR resource,HWND owner,DLGPROC procedure,LPARAM argument)
|
||||
: owner_(owner),focus_(GetFocus()),procedure_(procedure),argument_(argument)
|
||||
{
|
||||
activeFilesDialog=dialog;
|
||||
if(!filesDialogMouseHook)
|
||||
filesDialogMouseHook=SetWindowsHookExW(WH_MOUSE_LL,filesDialogMouse,GetModuleHandleW(nullptr),0);
|
||||
window_=CreateDialogParamW(instance,resource,owner,dispatchDialog,reinterpret_cast<LPARAM>(this));
|
||||
}
|
||||
|
||||
~FilesDialogInput()
|
||||
~FilesModalDialog() { close(); }
|
||||
FilesModalDialog(const FilesModalDialog &)=delete;
|
||||
FilesModalDialog &operator=(const FilesModalDialog &)=delete;
|
||||
|
||||
HWND window() const { return window_; }
|
||||
|
||||
static HWND activeFor(HWND owner)
|
||||
{
|
||||
activeFilesDialog=previous;
|
||||
if(!previous && filesDialogMouseHook)
|
||||
const auto root=GetAncestor(owner,GA_ROOTOWNER);
|
||||
for(auto dialog=activeFilesDialog;dialog;)
|
||||
{
|
||||
if(GetAncestor(dialog,GA_ROOTOWNER)==root) return dialog;
|
||||
const auto *state=reinterpret_cast<const FilesModalDialog *>(GetPropW(dialog,stateProperty_));
|
||||
dialog=state?state->previous_:nullptr;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static void fitWidthToOwner(HWND dialog,int minimum,void (*layout)(HWND))
|
||||
{
|
||||
auto *state=reinterpret_cast<FilesModalDialog *>(GetPropW(dialog,stateProperty_));
|
||||
RECT bounds{};
|
||||
if(!state || !GetWindowRect(dialog,&bounds)) return;
|
||||
state->preferredWidth_=bounds.right-bounds.left;
|
||||
state->minimumWidth_=minimum;
|
||||
state->layout_=layout;
|
||||
}
|
||||
|
||||
static void refreshLayout(HWND dialog)
|
||||
{
|
||||
auto *state=reinterpret_cast<FilesModalDialog *>(GetPropW(dialog,stateProperty_));
|
||||
if(!state || !state->entered_ || state->layout_) return;
|
||||
state->captureLayout();
|
||||
state->fitWidth(dialog,state->ownerBounds_);
|
||||
}
|
||||
|
||||
static bool dismissOwned(HWND owner)
|
||||
{
|
||||
const auto dialog=activeFor(owner);
|
||||
for(auto parent=GetWindow(dialog,GW_OWNER);parent;parent=GetWindow(parent,GW_OWNER))
|
||||
if(parent==owner) return PostMessageW(dialog,WM_CLOSE,0,0)!=FALSE;
|
||||
return false;
|
||||
}
|
||||
|
||||
void show()
|
||||
{
|
||||
if(!window_ || entered_) return;
|
||||
previous_=activeFilesDialog;
|
||||
activeFilesDialog=window_;
|
||||
entered_=true;
|
||||
if(!filesDialogMouseHook)
|
||||
filesDialogMouseHook=SetWindowsHookExW(WH_MOUSE_LL,filesDialogMouse,GetModuleHandleW(nullptr),0);
|
||||
if(GetWindowRect(owner_,&ownerBounds_))
|
||||
{
|
||||
if(!layout_) captureLayout();
|
||||
SetWindowSubclass(owner_,trackOwner,reinterpret_cast<UINT_PTR>(this),reinterpret_cast<DWORD_PTR>(this));
|
||||
if(ownerBounds_.right-ownerBounds_.left<minimumOwnerWidth() && !IsZoomed(owner_))
|
||||
SetWindowPos(owner_,nullptr,0,0,minimumOwnerWidth(),ownerBounds_.bottom-ownerBounds_.top,
|
||||
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
fitWidth(window_,ownerBounds_,true);
|
||||
}
|
||||
ShowWindow(window_,SW_SHOW);
|
||||
SetActiveWindow(window_);
|
||||
}
|
||||
|
||||
void close()
|
||||
{
|
||||
if(entered_ && IsWindow(owner_))
|
||||
RemoveWindowSubclass(owner_,trackOwner,reinterpret_cast<UINT_PTR>(this));
|
||||
if(window_ && IsWindow(window_)) DestroyWindow(window_);
|
||||
window_=nullptr;
|
||||
if(!entered_) return;
|
||||
entered_=false;
|
||||
activeFilesDialog=previous_;
|
||||
if(!previous_ && filesDialogMouseHook)
|
||||
{
|
||||
UnhookWindowsHookEx(filesDialogMouseHook);
|
||||
filesDialogMouseHook=nullptr;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
bool filesDialogHandlesMessage(HWND dialog,MSG &message)
|
||||
{
|
||||
if(message.hwnd==dialog || IsChild(dialog,message.hwnd)) return IsDialogMessageW(dialog,&message);
|
||||
if(!message.hwnd || GetAncestor(message.hwnd,GA_ROOTOWNER)!=GetAncestor(dialog,GA_ROOTOWNER)) return false;
|
||||
const bool input=(message.message>=WM_KEYFIRST && message.message<=WM_KEYLAST) ||
|
||||
(message.message>=WM_MOUSEFIRST && message.message<=WM_MOUSELAST) ||
|
||||
message.message==WM_CONTEXTMENU;
|
||||
if(input && message.message==WM_KEYDOWN && message.wParam==VK_ESCAPE)
|
||||
PostMessageW(dialog,WM_COMMAND,IDCANCEL,0);
|
||||
return input;
|
||||
}
|
||||
|
||||
struct FilesDialogResult
|
||||
{
|
||||
DLGPROC procedure;
|
||||
LPARAM argument;
|
||||
INT_PTR result=IDCANCEL;
|
||||
bool finished=false;
|
||||
};
|
||||
|
||||
constexpr wchar_t filesDialogResultProperty[]=L"KSwitcher.Files.DialogResult";
|
||||
|
||||
INT_PTR CALLBACK filesDialogProcedure(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
auto *result=reinterpret_cast<FilesDialogResult*>(GetPropW(dialog,filesDialogResultProperty));
|
||||
if(message==WM_INITDIALOG)
|
||||
{
|
||||
result=reinterpret_cast<FilesDialogResult*>(lp);
|
||||
if(!SetPropW(dialog,filesDialogResultProperty,result)) return FALSE;
|
||||
lp=result->argument;
|
||||
}
|
||||
if(!result) return FALSE;
|
||||
const auto handled=result->procedure(dialog,message,wp,lp);
|
||||
if(message==WM_NCDESTROY) RemovePropW(dialog,filesDialogResultProperty);
|
||||
return handled;
|
||||
}
|
||||
|
||||
void finishFilesDialog(HWND dialog,INT_PTR answer)
|
||||
{
|
||||
auto *result=reinterpret_cast<FilesDialogResult*>(GetPropW(dialog,filesDialogResultProperty));
|
||||
if(!result) return;
|
||||
result->result=answer;
|
||||
result->finished=true;
|
||||
ShowWindow(dialog,SW_HIDE);
|
||||
}
|
||||
|
||||
INT_PTR runFilesDialog(HINSTANCE instance,LPCWSTR resource,HWND owner,DLGPROC procedure,LPARAM argument)
|
||||
{
|
||||
FilesDialogResult result{procedure,argument};
|
||||
const auto dialog=CreateDialogParamW(instance,resource,owner,filesDialogProcedure,
|
||||
reinterpret_cast<LPARAM>(&result));
|
||||
if(!dialog) return -1;
|
||||
GetWindowRect(GetAncestor(owner,GA_ROOTOWNER),&previousOwnerBounds);
|
||||
FilesDialogInput input(dialog);
|
||||
ShowWindow(dialog,SW_SHOW);
|
||||
SetActiveWindow(dialog);
|
||||
MSG message{};
|
||||
while(!result.finished && IsWindow(dialog))
|
||||
{
|
||||
const auto status=GetMessageW(&message,nullptr,0,0);
|
||||
if(status<=0)
|
||||
if(IsWindow(owner_) && !IsIconic(GetAncestor(owner_,GA_ROOTOWNER)))
|
||||
{
|
||||
if(status==0) PostQuitMessage(static_cast<int>(message.wParam));
|
||||
break;
|
||||
SetActiveWindow(owner_);
|
||||
if(IsWindow(focus_) && IsWindowEnabled(focus_) && (focus_==owner_ || IsChild(owner_,focus_)))
|
||||
SetFocus(focus_);
|
||||
}
|
||||
if(filesDialogHandlesMessage(dialog,message)) continue;
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
if(IsWindow(dialog)) DestroyWindow(dialog);
|
||||
if(IsWindow(owner) && !IsIconic(GetAncestor(owner,GA_ROOTOWNER))) SetActiveWindow(owner);
|
||||
return result.result;
|
||||
}
|
||||
|
||||
static bool handleMessage(HWND dialog,MSG &message)
|
||||
{
|
||||
if(message.hwnd==dialog || IsChild(dialog,message.hwnd))
|
||||
{
|
||||
if(message.message==WM_KEYDOWN && message.wParam==VK_ESCAPE)
|
||||
{
|
||||
SendMessageW(dialog,WM_COMMAND,IDCANCEL,0);
|
||||
return true;
|
||||
}
|
||||
return IsDialogMessageW(dialog,&message)!=FALSE;
|
||||
}
|
||||
if(!message.hwnd || GetAncestor(message.hwnd,GA_ROOTOWNER)!=GetAncestor(dialog,GA_ROOTOWNER))
|
||||
return false;
|
||||
const bool input=(message.message>=WM_KEYFIRST && message.message<=WM_KEYLAST) ||
|
||||
(message.message>=WM_MOUSEFIRST && message.message<=WM_MOUSELAST) ||
|
||||
message.message==WM_CONTEXTMENU;
|
||||
if(input && (filesMouseDown(message.message) ||
|
||||
(message.message==WM_KEYDOWN && message.wParam==VK_ESCAPE)))
|
||||
PostMessageW(dialog,WM_COMMAND,IDCANCEL,0);
|
||||
return input;
|
||||
}
|
||||
|
||||
static void finish(HWND dialog,INT_PTR answer)
|
||||
{
|
||||
auto *state=reinterpret_cast<FilesModalDialog *>(GetPropW(dialog,stateProperty_));
|
||||
if(!state) return;
|
||||
state->result_=answer;
|
||||
state->finished_=true;
|
||||
ShowWindow(dialog,SW_HIDE);
|
||||
}
|
||||
|
||||
static INT_PTR run(HINSTANCE instance,LPCWSTR resource,HWND owner,DLGPROC procedure,LPARAM argument)
|
||||
{
|
||||
FilesModalDialog dialog(instance,resource,owner,procedure,argument);
|
||||
if(!dialog.window()) return -1;
|
||||
dialog.show();
|
||||
MSG message{};
|
||||
while(!dialog.finished_ && IsWindow(dialog.window()))
|
||||
{
|
||||
const auto status=GetMessageW(&message,nullptr,0,0);
|
||||
if(status<=0)
|
||||
{
|
||||
if(status==0) PostQuitMessage(static_cast<int>(message.wParam));
|
||||
break;
|
||||
}
|
||||
if(handleMessage(dialog.window(),message)) continue;
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
return dialog.result_;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr wchar_t stateProperty_[]=L"KSwitcher.Files.ModalDialog";
|
||||
HWND window_{},owner_{},focus_{},previous_{};
|
||||
RECT ownerBounds_{};
|
||||
int preferredWidth_{},minimumWidth_{};
|
||||
std::vector<std::pair<HWND,RECT>> controls_;
|
||||
void (*layout_)(HWND){};
|
||||
DLGPROC procedure_;
|
||||
LPARAM argument_;
|
||||
INT_PTR result_=IDCANCEL;
|
||||
bool entered_{},finished_{};
|
||||
|
||||
void captureLayout()
|
||||
{
|
||||
RECT bounds{};
|
||||
GetWindowRect(window_,&bounds);
|
||||
preferredWidth_=bounds.right-bounds.left;
|
||||
minimumWidth_=MulDiv(preferredWidth_,3,4);
|
||||
controls_.clear();
|
||||
if(GetWindowLongPtrW(window_,GWL_STYLE)&WS_THICKFRAME)
|
||||
{
|
||||
MINMAXINFO limits{};
|
||||
SendMessageW(window_,WM_GETMINMAXINFO,0,reinterpret_cast<LPARAM>(&limits));
|
||||
minimumWidth_=std::max<int>(minimumWidth_,limits.ptMinTrackSize.x);
|
||||
return;
|
||||
}
|
||||
const auto dc=GetDC(window_);
|
||||
if(!dc) return;
|
||||
for(auto field=GetWindow(window_,GW_CHILD);field;field=GetWindow(field,GW_HWNDNEXT))
|
||||
{
|
||||
RECT rect{};
|
||||
GetWindowRect(field,&rect);
|
||||
MapWindowPoints(nullptr,window_,reinterpret_cast<POINT *>(&rect),2);
|
||||
wchar_t type[16]{},label[128]{};
|
||||
GetClassNameW(field,type,16);
|
||||
RECT dropdown{};
|
||||
if(lstrcmpiW(type,L"ComboBox")==0 &&
|
||||
SendMessageW(field,CB_GETDROPPEDCONTROLRECT,0,reinterpret_cast<LPARAM>(&dropdown)))
|
||||
rect.bottom=rect.top+dropdown.bottom-dropdown.top;
|
||||
controls_.emplace_back(field,rect);
|
||||
const auto fieldWidth=rect.right-rect.left;
|
||||
if(!(GetWindowLongPtrW(field,GWL_STYLE)&WS_VISIBLE) || fieldWidth<=0) continue;
|
||||
if(lstrcmpiW(type,L"Button")!=0 || !GetWindowTextW(field,label,128)) continue;
|
||||
const auto font=reinterpret_cast<HFONT>(SendMessageW(field,WM_GETFONT,0,0));
|
||||
const auto previous=font?SelectObject(dc,font):nullptr;
|
||||
RECT text{};
|
||||
DrawTextW(dc,label,-1,&text,DT_SINGLELINE|DT_CALCRECT);
|
||||
TEXTMETRICW metrics{};
|
||||
GetTextMetricsW(dc,&metrics);
|
||||
if(previous) SelectObject(dc,previous);
|
||||
const auto needed=std::min(fieldWidth,text.right+4*metrics.tmAveCharWidth);
|
||||
minimumWidth_=std::max(minimumWidth_,static_cast<int>(
|
||||
(preferredWidth_*needed+fieldWidth-1)/fieldWidth));
|
||||
}
|
||||
ReleaseDC(window_,dc);
|
||||
}
|
||||
|
||||
int ownerMargin() const { return MulDiv(12,GetDpiForWindow(owner_),96); }
|
||||
int minimumOwnerWidth() const { return minimumWidth_+2*ownerMargin(); }
|
||||
|
||||
void fitWidth(HWND window,const RECT &owner,bool initial=false)
|
||||
{
|
||||
RECT bounds{};
|
||||
if(!GetWindowRect(window,&bounds)) return;
|
||||
const int width=std::clamp(static_cast<int>(owner.right-owner.left)-2*ownerMargin(),
|
||||
minimumWidth_,std::max(minimumWidth_,preferredWidth_));
|
||||
const int height=bounds.bottom-bounds.top;
|
||||
const int x=(owner.left+owner.right-width)/2;
|
||||
const int y=(owner.top+owner.bottom-height)/2;
|
||||
const bool resized=width!=bounds.right-bounds.left;
|
||||
if(x!=bounds.left || y!=bounds.top || resized)
|
||||
SetWindowPos(window,nullptr,x,y,width,height,SWP_NOZORDER|SWP_NOACTIVATE|
|
||||
(resized?SWP_NOCOPYBITS:0));
|
||||
if(layout_ && (initial || resized)) layout_(window);
|
||||
else if(resized && preferredWidth_>0)
|
||||
for(const auto &[field,rect]:controls_)
|
||||
{
|
||||
const auto left=MulDiv(rect.left,width,preferredWidth_);
|
||||
const auto right=MulDiv(rect.right,width,preferredWidth_);
|
||||
SetWindowPos(field,nullptr,left,rect.top,right-left,rect.bottom-rect.top,
|
||||
SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS);
|
||||
}
|
||||
if(initial || resized) RedrawWindow(window,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN);
|
||||
}
|
||||
|
||||
static LRESULT CALLBACK trackOwner(HWND owner,UINT message,WPARAM wp,LPARAM lp,UINT_PTR id,DWORD_PTR data)
|
||||
{
|
||||
auto &dialog=*reinterpret_cast<FilesModalDialog *>(data);
|
||||
if(message==WM_WINDOWPOSCHANGING && !IsIconic(owner))
|
||||
{
|
||||
auto &position=*reinterpret_cast<WINDOWPOS *>(lp);
|
||||
if(!(position.flags&SWP_NOSIZE) && position.cx>0)
|
||||
position.cx=std::max(position.cx,dialog.minimumOwnerWidth());
|
||||
}
|
||||
if(message==WM_NCDESTROY) RemoveWindowSubclass(owner,trackOwner,id);
|
||||
const auto result=DefSubclassProc(owner,message,wp,lp);
|
||||
if(message==WM_GETMINMAXINFO)
|
||||
{
|
||||
auto &limits=*reinterpret_cast<MINMAXINFO *>(lp);
|
||||
limits.ptMinTrackSize.x=std::max<LONG>(limits.ptMinTrackSize.x,dialog.minimumOwnerWidth());
|
||||
return result;
|
||||
}
|
||||
if((message!=WM_MOVE && message!=WM_SIZE) || IsIconic(owner)) return result;
|
||||
RECT ownerBounds{};
|
||||
if(!GetWindowRect(owner,&ownerBounds)) return result;
|
||||
if(EqualRect(&ownerBounds,&dialog.ownerBounds_)) return result;
|
||||
dialog.ownerBounds_=ownerBounds;
|
||||
dialog.fitWidth(dialog.window_,ownerBounds);
|
||||
return result;
|
||||
}
|
||||
|
||||
static INT_PTR CALLBACK dispatchDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
auto *state=reinterpret_cast<FilesModalDialog *>(GetPropW(dialog,stateProperty_));
|
||||
if(message==WM_INITDIALOG)
|
||||
{
|
||||
state=reinterpret_cast<FilesModalDialog *>(lp);
|
||||
if(!SetPropW(dialog,stateProperty_,state)) return FALSE;
|
||||
lp=state->argument_;
|
||||
}
|
||||
if(!state) return FALSE;
|
||||
const auto handled=state->procedure_(dialog,message,wp,lp);
|
||||
if(message==WM_NCDESTROY) RemovePropW(dialog,stateProperty_);
|
||||
if(!handled && message==WM_CLOSE)
|
||||
{
|
||||
SendMessageW(dialog,WM_COMMAND,IDCANCEL,0);
|
||||
return TRUE;
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
#include <algorithm>
|
||||
|
||||
namespace ks::files
|
||||
{
|
||||
inline void paintFarDropDownArrow(HDC dc, const RECT &bounds, HBRUSH surface, COLORREF ink, UINT dpi)
|
||||
{
|
||||
FillRect(dc, &bounds, surface);
|
||||
// Center the drawn pixels. A font's arrow glyph can sit below the center of its line box.
|
||||
const auto halfWidth = std::min((bounds.right - bounds.left - 1) / 2,
|
||||
static_cast<LONG>(MulDiv(3, dpi, 96)));
|
||||
const auto halfHeight = std::min((bounds.bottom - bounds.top - 1) / 2,
|
||||
static_cast<LONG>(MulDiv(5, dpi, 96)));
|
||||
if (halfWidth < 1 || halfHeight < 1) return;
|
||||
const auto x = (bounds.left + bounds.right - 1) / 2;
|
||||
const auto y = (bounds.top + bounds.bottom - 1) / 2;
|
||||
const auto tip = y + halfHeight;
|
||||
const auto head = std::min(halfWidth, halfHeight);
|
||||
for (LONG row = y - halfHeight; row <= tip; ++row)
|
||||
SetPixelV(dc, x, row, ink);
|
||||
for (LONG offset = 1; offset <= head; ++offset)
|
||||
{
|
||||
SetPixelV(dc, x - offset, tip - offset, ink);
|
||||
SetPixelV(dc, x + offset, tip - offset, ink);
|
||||
}
|
||||
}
|
||||
|
||||
inline void paintFarFieldArrow(HDC dc, const RECT &bounds, bool enabled, UINT dpi)
|
||||
{
|
||||
paintFarDropDownArrow(dc, bounds, static_cast<HBRUSH>(GetStockObject(LTGRAY_BRUSH)),
|
||||
enabled ? RGB(0,0,0) : RGB(48,48,48), dpi);
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ INT_PTR CALLBACK folderShortcutEditDialog(HWND dialog,UINT message,WPARAM wp,LPA
|
||||
}
|
||||
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
|
||||
{
|
||||
finishFilesDialog(dialog,IDCANCEL);
|
||||
FilesModalDialog::finish(dialog,IDCANCEL);
|
||||
return TRUE;
|
||||
}
|
||||
if(message!=WM_COMMAND || LOWORD(wp)!=IDOK) return FALSE;
|
||||
@@ -116,7 +116,7 @@ INT_PTR CALLBACK folderShortcutEditDialog(HWND dialog,UINT message,WPARAM wp,LPA
|
||||
((root[0]>=L'A' && root[0]<=L'Z') || (root[0]>=L'a' && root[0]<=L'z'));
|
||||
if(!path.is_absolute() || (!edit.entry.peerId.empty() && !lanDrive))
|
||||
{
|
||||
MessageBoxW(dialog,L"Enter an absolute folder path on the original computer.",L"Folder shortcuts",MB_OK|MB_ICONWARNING);
|
||||
showFilesNotice(dialog,L"Enter an absolute folder path on the original computer.",L"Folder shortcuts");
|
||||
SetFocus(GetDlgItem(dialog,shortcutPath));
|
||||
return TRUE;
|
||||
}
|
||||
@@ -127,7 +127,7 @@ INT_PTR CALLBACK folderShortcutEditDialog(HWND dialog,UINT message,WPARAM wp,LPA
|
||||
for(size_t i=0;i<group.size();++i)
|
||||
if((!edit.index || i!=*edit.index) && sameFolderShortcut(group[i],entry))
|
||||
{
|
||||
MessageBoxW(dialog,L"This folder is already assigned to this digit.",L"Folder shortcuts",MB_OK|MB_ICONINFORMATION);
|
||||
showFilesNotice(dialog,L"This folder is already assigned to this digit.",L"Folder shortcuts");
|
||||
return TRUE;
|
||||
}
|
||||
auto updated=folderShortcuts;
|
||||
@@ -135,17 +135,18 @@ INT_PTR CALLBACK folderShortcutEditDialog(HWND dialog,UINT message,WPARAM wp,LPA
|
||||
else updated[edit.digit].push_back(std::move(entry));
|
||||
if(!saveFolderShortcuts(updated))
|
||||
{
|
||||
MessageBoxW(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts",MB_OK|MB_ICONWARNING);
|
||||
showFilesNotice(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts");
|
||||
return TRUE;
|
||||
}
|
||||
folderShortcuts=std::move(updated);
|
||||
finishFilesDialog(dialog,IDOK);
|
||||
notifyFilesPreferences(GetAncestor(dialog,GA_ROOTOWNER));
|
||||
FilesModalDialog::finish(dialog,IDOK);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
bool editFolderShortcut(HWND owner,FolderShortcutEdit &edit)
|
||||
{
|
||||
return runFilesDialog(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(110),owner,
|
||||
return FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(110),owner,
|
||||
folderShortcutEditDialog,reinterpret_cast<LPARAM>(&edit))==IDOK;
|
||||
}
|
||||
|
||||
@@ -186,13 +187,21 @@ struct FolderShortcutView
|
||||
std::vector<int> digits;
|
||||
int rowHeight{};
|
||||
Panel *panel{};
|
||||
bool all{};
|
||||
std::vector<std::pair<int,size_t>> entries;
|
||||
};
|
||||
|
||||
std::wstring shortcutLabel(const FolderShortcut &entry)
|
||||
{
|
||||
return entry.title.empty()?(entry.peerId.empty()?L"":entry.peerName+L" · ")+entry.path.native():entry.title;
|
||||
}
|
||||
|
||||
void layoutQuickFolders(HWND dialog,FolderShortcutView &view,int selection)
|
||||
{
|
||||
const auto list=GetDlgItem(dialog,shortcutList);
|
||||
SendMessageW(list,LB_RESETCONTENT,0,0);
|
||||
view.digits.clear();
|
||||
view.entries.clear();
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
const auto dc=GetDC(dialog);
|
||||
const auto font=SelectObject(dc,mono);
|
||||
@@ -210,14 +219,23 @@ void layoutQuickFolders(HWND dialog,FolderShortcutView &view,int selection)
|
||||
GetTextExtentPoint32W(dc,label.data(),static_cast<int>(label.size()),&size);
|
||||
return static_cast<int>(size.cx);
|
||||
};
|
||||
int width=std::max(measure(L"Folder shortcuts"),measure(view.digit<0?L"Enter Esc":quickFolderHints))+3*cellWidth;
|
||||
int width=std::max(measure(L"Folder shortcuts"),measure(view.all?L"Del F4":view.digit<0?L"Enter Esc":quickFolderHints))+3*cellWidth;
|
||||
const int textInset=dialogRowTextInset(cellWidth);
|
||||
const auto add=[&](const std::wstring &label)
|
||||
{
|
||||
width=std::max(width,measure(label)+2*textInset);
|
||||
SendMessageW(list,LB_ADDSTRING,0,reinterpret_cast<LPARAM>(label.c_str()));
|
||||
};
|
||||
if(view.digit<0)
|
||||
if(view.all)
|
||||
{
|
||||
for(int digit=0;digit<static_cast<int>(folderShortcuts.size());++digit)
|
||||
for(size_t index=0;index<folderShortcuts[digit].size();++index)
|
||||
{
|
||||
view.entries.emplace_back(digit,index);
|
||||
add(std::to_wstring(digit)+L" "+shortcutLabel(folderShortcuts[digit][index]));
|
||||
}
|
||||
}
|
||||
else if(view.digit<0)
|
||||
{
|
||||
for(int digit=0;digit<10;++digit)
|
||||
if(!folderShortcuts[digit].empty())
|
||||
@@ -227,7 +245,7 @@ void layoutQuickFolders(HWND dialog,FolderShortcutView &view,int selection)
|
||||
}
|
||||
}
|
||||
else for(const auto &entry:folderShortcuts[view.digit])
|
||||
add(entry.title.empty()?(entry.peerId.empty()?L"":entry.peerName+L" · ")+entry.path.native():entry.title);
|
||||
add(shortcutLabel(entry));
|
||||
SelectObject(dc,font);
|
||||
ReleaseDC(dialog,dc);
|
||||
MONITORINFO monitor{sizeof(monitor)};
|
||||
@@ -252,6 +270,7 @@ void layoutQuickFolders(HWND dialog,FolderShortcutView &view,int selection)
|
||||
SetWindowPos(list,nullptr,inset,listTop,width,listHeight,SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
SendMessageW(list,LB_SETCURSEL,std::clamp(selection,0,std::max(0,count-1)),0);
|
||||
InvalidateRect(dialog,nullptr,TRUE);
|
||||
FilesModalDialog::refreshLayout(dialog);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK quickFolderKeys(HWND list,UINT message,WPARAM key,LPARAM flags,UINT_PTR,DWORD_PTR)
|
||||
@@ -303,7 +322,7 @@ INT_PTR CALLBACK quickFolderDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
const auto bounds=paintDialogFrame(dc,client,dialogFrameMetrics(GetDpiForWindow(dialog)),teal,frame);
|
||||
DeleteObject(frame);
|
||||
paintDialogFrameCaptions(dc,client,bounds,L"Folder shortcuts",teal,colors[Text],mono,
|
||||
view->digit<0?L"Enter Esc":quickFolderHints);
|
||||
view->all?L"Del F4":view->digit<0?L"Enter Esc":quickFolderHints);
|
||||
EndPaint(dialog,&ps);
|
||||
return TRUE;
|
||||
}
|
||||
@@ -322,7 +341,7 @@ INT_PTR CALLBACK quickFolderDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
}
|
||||
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
|
||||
{
|
||||
finishFilesDialog(dialog,IDCANCEL);
|
||||
FilesModalDialog::finish(dialog,IDCANCEL);
|
||||
return TRUE;
|
||||
}
|
||||
if(message!=WM_COMMAND) return FALSE;
|
||||
@@ -330,7 +349,7 @@ INT_PTR CALLBACK quickFolderDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
if(action!=quickRemove && action!=quickEdit && action!=quickInsert && action!=quickMoveUp &&
|
||||
action!=quickMoveDown && action!=IDOK && !(action==shortcutList && HIWORD(wp)==LBN_DBLCLK)) return FALSE;
|
||||
const auto row=SendDlgItemMessageW(dialog,shortcutList,LB_GETCURSEL,0,0);
|
||||
if(view->digit<0)
|
||||
if(!view->all && view->digit<0)
|
||||
{
|
||||
if((action==IDOK || action==shortcutList) && row>=0 && static_cast<size_t>(row)<view->digits.size())
|
||||
{
|
||||
@@ -339,7 +358,17 @@ INT_PTR CALLBACK quickFolderDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
const auto &group=folderShortcuts[view->digit];
|
||||
int digit=view->digit;
|
||||
size_t index=static_cast<size_t>(row);
|
||||
if(view->all)
|
||||
{
|
||||
if(index>=view->entries.size() || action==quickInsert ||
|
||||
action==quickMoveUp || action==quickMoveDown) return TRUE;
|
||||
const auto &entry=view->entries[index];
|
||||
digit=entry.first;
|
||||
index=entry.second;
|
||||
}
|
||||
const auto &group=folderShortcuts[digit];
|
||||
if(action==quickInsert)
|
||||
{
|
||||
const auto &panel=*view->panel;
|
||||
@@ -350,7 +379,7 @@ INT_PTR CALLBACK quickFolderDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
SetFocus(GetDlgItem(dialog,shortcutList));
|
||||
return TRUE;
|
||||
}
|
||||
if(row<0 || static_cast<size_t>(row)>=group.size()) return TRUE;
|
||||
if(index>=group.size()) return TRUE;
|
||||
if(action==quickMoveUp || action==quickMoveDown)
|
||||
{
|
||||
const auto target=row+(action==quickMoveUp?-1:1);
|
||||
@@ -358,17 +387,18 @@ INT_PTR CALLBACK quickFolderDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
auto updated=folderShortcuts;
|
||||
std::swap(updated[view->digit][row],updated[view->digit][target]);
|
||||
if(!saveFolderShortcuts(updated))
|
||||
MessageBoxW(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts",MB_OK|MB_ICONWARNING);
|
||||
showFilesNotice(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts");
|
||||
else
|
||||
{
|
||||
folderShortcuts=std::move(updated);
|
||||
notifyFilesPreferences(GetAncestor(dialog,GA_ROOTOWNER));
|
||||
layoutQuickFolders(dialog,*view,static_cast<int>(target));
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
if(action==quickEdit)
|
||||
{
|
||||
FolderShortcutEdit edit{group[row],view->digit,static_cast<size_t>(row)};
|
||||
FolderShortcutEdit edit{group[index],digit,index};
|
||||
if(editFolderShortcut(dialog,edit)) layoutQuickFolders(dialog,*view,static_cast<int>(row));
|
||||
SetFocus(GetDlgItem(dialog,shortcutList));
|
||||
return TRUE;
|
||||
@@ -376,24 +406,25 @@ INT_PTR CALLBACK quickFolderDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
if(action==quickRemove)
|
||||
{
|
||||
auto updated=folderShortcuts;
|
||||
updated[view->digit].erase(updated[view->digit].begin()+row);
|
||||
updated[digit].erase(updated[digit].begin()+index);
|
||||
if(!saveFolderShortcuts(updated))
|
||||
{
|
||||
MessageBoxW(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts",MB_OK|MB_ICONWARNING);
|
||||
showFilesNotice(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts");
|
||||
return TRUE;
|
||||
}
|
||||
folderShortcuts=std::move(updated);
|
||||
if(folderShortcuts[view->digit].empty()) finishFilesDialog(dialog,IDCANCEL);
|
||||
notifyFilesPreferences(GetAncestor(dialog,GA_ROOTOWNER));
|
||||
if(view->all?view->entries.size()==1:folderShortcuts[digit].empty()) FilesModalDialog::finish(dialog,IDCANCEL);
|
||||
else layoutQuickFolders(dialog,*view,static_cast<int>(row));
|
||||
return TRUE;
|
||||
}
|
||||
view->chosen=group[row];
|
||||
finishFilesDialog(dialog,IDOK);
|
||||
view->chosen=group[index];
|
||||
FilesModalDialog::finish(dialog,IDOK);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
|
||||
void showFolderShortcuts(Panel &panel,int digit)
|
||||
void showFolderShortcuts(Panel &panel,int digit,bool all)
|
||||
{
|
||||
if(digit>=0)
|
||||
{
|
||||
@@ -416,7 +447,8 @@ void showFolderShortcuts(Panel &panel,int digit)
|
||||
}
|
||||
FolderShortcutView view{digit};
|
||||
view.panel=&panel;
|
||||
const auto result=runFilesDialog(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(109),GetParent(panel.list),
|
||||
view.all=all;
|
||||
const auto result=FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(109),GetParent(panel.list),
|
||||
quickFolderDialog,reinterpret_cast<LPARAM>(&view));
|
||||
SetFocus(panel.list);
|
||||
if(result==IDOK && view.chosen) openFolderShortcut(panel,*view.chosen);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
#include <string>
|
||||
|
||||
namespace ks::files
|
||||
{
|
||||
inline std::wstring historyDate()
|
||||
{
|
||||
SYSTEMTIME now{};
|
||||
GetLocalTime(&now);
|
||||
wchar_t date[24]{};
|
||||
GetDateFormatEx(LOCALE_NAME_USER_DEFAULT,0,&now,L"dd.MM.yyyy",date,24,nullptr);
|
||||
return date;
|
||||
}
|
||||
}
|
||||
+1452
-347
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
#include <string_view>
|
||||
|
||||
namespace ks::files
|
||||
{
|
||||
inline void paintMenuAccessKey(HDC dc, const RECT &clip, POINT origin, std::wstring_view title,
|
||||
size_t access, COLORREF ink)
|
||||
{
|
||||
if (access >= title.size()) return;
|
||||
SIZE prefix{};
|
||||
GetTextExtentPoint32W(dc, title.data(), static_cast<int>(access), &prefix);
|
||||
const auto background = SetBkMode(dc, TRANSPARENT);
|
||||
const auto text = SetTextColor(dc, ink);
|
||||
const auto alignment = SetTextAlign(dc, TA_LEFT | TA_TOP);
|
||||
ExtTextOutW(dc, origin.x + prefix.cx, origin.y, ETO_CLIPPED, &clip, title.data() + access, 1, nullptr);
|
||||
SetTextAlign(dc, alignment);
|
||||
SetTextColor(dc, text);
|
||||
SetBkMode(dc, background);
|
||||
}
|
||||
}
|
||||
+370
-61
@@ -1,7 +1,6 @@
|
||||
constexpr int optionsSearch=270,optionsTree=271,optionsTitle=272,optionsApply=283,optionsReset=284,optionsProperty=285,optionsResetProperty=286;
|
||||
constexpr int allOptions=4;
|
||||
enum OptionsPage { PanelModes, PanelColors, Appearance, CommandLine };
|
||||
constexpr const wchar_t *optionsTitles[]{L"File panel modes",L"Colors",L"Appearance",L"Command line"};
|
||||
enum OptionsPage { PanelModes, PanelColors, Appearance, CommandLine, TextViewer, TextEditor, allOptions };
|
||||
constexpr const wchar_t *optionsTitles[]{L"File panel modes",L"Colors",L"Appearance",L"Command line",L"Viewer",L"Editor"};
|
||||
constexpr const wchar_t *colorNames[]{L"Panel background",L"Text",L"Frame",L"Popup background",
|
||||
L"Menu selection",L"Command bar",L"File cursor"};
|
||||
enum PanelSection { PanelLayout, WideColumns, StatusColumns };
|
||||
@@ -14,6 +13,25 @@ constexpr int appearanceControls[]{281,282};
|
||||
enum AppearanceSection { Font, DialogFrame };
|
||||
constexpr int outerFramePadding=303,innerFramePadding=305,rowTextPadding=307;
|
||||
constexpr int dialogFrameControls[]{302,outerFramePadding,304,innerFramePadding,306,rowTextPadding};
|
||||
constexpr int textTabWidth=357,textLineEnding=359,
|
||||
textTheme=363,textColor=365,textChangeColor=366,textColorValue=367,textSaveScheme=368;
|
||||
constexpr int textControls[]{350,351,352,353,356,textTabWidth,
|
||||
362,textTheme,364,textColor,textChangeColor,textColorValue,textSaveScheme};
|
||||
constexpr int editingControls[]{354,355,358,textLineEnding};
|
||||
constexpr struct
|
||||
{
|
||||
int id;
|
||||
bool ks::files::TextPreferences::*value;
|
||||
const wchar_t *name;
|
||||
} textChecks[]{
|
||||
{350,&ks::files::TextPreferences::wrap,L"Wrap lines"},
|
||||
{351,&ks::files::TextPreferences::numbers,L"Line numbers"},
|
||||
{352,&ks::files::TextPreferences::whitespace,L"Show whitespace and line endings"},
|
||||
{353,&ks::files::TextPreferences::caretLine,L"Highlight current line"},
|
||||
{354,&ks::files::TextPreferences::tabsAsSpaces,L"Insert spaces for tabs"},
|
||||
{355,&ks::files::TextPreferences::autoIndent,L"Auto indent"}};
|
||||
|
||||
bool textOptionsPage(int page) { return page==TextViewer || page==TextEditor; }
|
||||
constexpr struct
|
||||
{
|
||||
int id;
|
||||
@@ -52,8 +70,25 @@ void resetPanelProperty(HWND dialog,const PanelProperty &property)
|
||||
else CheckDlgButton(dialog,property.control,property.defaultValue);
|
||||
}
|
||||
|
||||
struct OptionsBaseline
|
||||
{
|
||||
LOGFONTW font=displayFont;
|
||||
int points=fontPoints;
|
||||
DialogFramePadding padding=dialogFramePadding;
|
||||
std::array<COLORREF,ColorCount> palette=colors;
|
||||
std::vector<FileStyle> styles=fileStyles;
|
||||
std::array<WORD,2> keys=commandHistoryKeys;
|
||||
DWORD width=static_cast<DWORD>(sizeWidth), fields=statusFields;
|
||||
bool extensions=alignExtensions, folders=alignFolders, details=showDetails;
|
||||
std::array<bool,2> wide{panels[0].wide,panels[1].wide};
|
||||
ks::files::TextSettings text=ks::files::loadTextPreferences();
|
||||
};
|
||||
|
||||
struct OptionsDraft
|
||||
{
|
||||
OptionsBaseline baseline;
|
||||
ks::files::TextSettings text=baseline.text;
|
||||
HWND filesWindow{};
|
||||
LOGFONTW font=displayFont;
|
||||
int points=fontPoints;
|
||||
DialogFramePadding framePadding=dialogFramePadding;
|
||||
@@ -81,15 +116,28 @@ struct OptionsDraft
|
||||
|
||||
bool optionsFontChanged(const OptionsDraft &draft)
|
||||
{
|
||||
auto pending=draft.font, applied=displayFont;
|
||||
auto pending=draft.font, applied=draft.baseline.font;
|
||||
// Height follows the selected point size and DPI. applyFont always uses natural width.
|
||||
pending.lfHeight=applied.lfHeight=0;
|
||||
pending.lfWidth=applied.lfWidth=0;
|
||||
return draft.points!=fontPoints ||
|
||||
return draft.points!=draft.baseline.points ||
|
||||
memcmp(&pending,&applied,offsetof(LOGFONTW,lfFaceName))!=0 ||
|
||||
_wcsicmp(pending.lfFaceName,applied.lfFaceName)!=0;
|
||||
}
|
||||
|
||||
struct OptionsWriteLock
|
||||
{
|
||||
HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Files.OptionsWrite");
|
||||
DWORD result=mutex?WaitForSingleObject(mutex,2000):WAIT_FAILED;
|
||||
|
||||
bool acquired() const { return result==WAIT_OBJECT_0 || result==WAIT_ABANDONED; }
|
||||
~OptionsWriteLock()
|
||||
{
|
||||
if(acquired()) ReleaseMutex(mutex);
|
||||
if(mutex) CloseHandle(mutex);
|
||||
}
|
||||
};
|
||||
|
||||
void updateOptionsApply(HWND dialog,const OptionsDraft &draft)
|
||||
{
|
||||
BOOL valid{};
|
||||
@@ -97,12 +145,13 @@ void updateOptionsApply(HWND dialog,const OptionsDraft &draft)
|
||||
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
|
||||
DWORD fields=0;
|
||||
for(int i=0;i<4;++i) if(checked(205+i)) fields|=1u<<i;
|
||||
const bool dirty=!valid || width!=static_cast<UINT>(sizeWidth) ||
|
||||
checked(273)!=panels[0].wide || checked(274)!=panels[1].wide ||
|
||||
checked(202)!=alignExtensions || checked(204)!=alignFolders || checked(203)!=showDetails ||
|
||||
fields!=statusFields || draft.palette!=colors || draft.styles!=fileStyles ||
|
||||
draft.framePadding!=dialogFramePadding || optionsFontChanged(draft) ||
|
||||
optionHistoryKeys(dialog)!=commandHistoryKeys;
|
||||
const auto &base=draft.baseline;
|
||||
const bool dirty=!valid || width!=base.width ||
|
||||
checked(273)!=base.wide[0] || checked(274)!=base.wide[1] ||
|
||||
checked(202)!=base.extensions || checked(204)!=base.folders || checked(203)!=base.details ||
|
||||
fields!=base.fields || draft.palette!=base.palette || draft.styles!=base.styles ||
|
||||
draft.framePadding!=base.padding || optionsFontChanged(draft) ||
|
||||
optionHistoryKeys(dialog)!=base.keys || draft.text!=base.text;
|
||||
EnableWindow(GetDlgItem(dialog,optionsApply),dirty);
|
||||
InvalidateRect(GetDlgItem(dialog,fileStylePreview),nullptr,FALSE);
|
||||
}
|
||||
@@ -164,12 +213,167 @@ void layoutOptions(HWND dialog,const OptionsDraft &draft)
|
||||
SetWindowPos(GetDlgItem(dialog,control.id),nullptr,x,y,width,height,
|
||||
SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS|SWP_NOREDRAW);
|
||||
}
|
||||
groupDialogButtons(dialog,{IDOK,IDCANCEL,optionsApply},true);
|
||||
if(draft.page==PanelColors && draft.section==fileStyleSection) layoutFileStyleFields(dialog,false);
|
||||
layoutOptionsPreview(dialog);
|
||||
// Paint the final layout together, including the areas vacated by moved controls.
|
||||
RedrawWindow(dialog,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN|RDW_UPDATENOW);
|
||||
}
|
||||
|
||||
void refreshTextColor(HWND dialog,const ks::files::TextPreferences &profile)
|
||||
{
|
||||
const auto index=SendDlgItemMessageW(dialog,textColor,CB_GETCURSEL,0,0);
|
||||
if(index<0 || static_cast<size_t>(index)>=profile.colors.size()) return;
|
||||
const auto color=profile.colors[index];
|
||||
wchar_t hex[8]{};
|
||||
swprintf_s(hex,L"#%02X%02X%02X",unsigned(GetRValue(color)),unsigned(GetGValue(color)),unsigned(GetBValue(color)));
|
||||
SetDlgItemTextW(dialog,textColorValue,hex);
|
||||
}
|
||||
|
||||
bool sameSchemeName(const std::wstring &left,const wchar_t *right)
|
||||
{
|
||||
return CompareStringOrdinal(left.c_str(),-1,right,-1,TRUE)==CSTR_EQUAL;
|
||||
}
|
||||
|
||||
void populateTextSchemes(HWND dialog,const ks::files::TextSettings &settings)
|
||||
{
|
||||
SendDlgItemMessageW(dialog,textTheme,CB_RESETCONTENT,0,0);
|
||||
const auto add=[&](const wchar_t *name)
|
||||
{
|
||||
SendDlgItemMessageW(dialog,textTheme,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(name));
|
||||
};
|
||||
add(L"Custom");
|
||||
for(const auto name:ks::files::textPaletteNames) add(name);
|
||||
for(const auto &scheme:settings.schemes) add(scheme.name.c_str());
|
||||
}
|
||||
|
||||
size_t currentTextScheme(const ks::files::TextSettings &settings,const ks::files::TextPreferences &profile)
|
||||
{
|
||||
for(size_t index=0;index<std::size(ks::files::textPalettes);++index)
|
||||
if(profile.scheme==ks::files::textPaletteNames[index] && profile.colors==ks::files::textPalettes[index])
|
||||
return index+1;
|
||||
for(size_t index=0;index<settings.schemes.size();++index)
|
||||
{
|
||||
const auto &scheme=settings.schemes[index];
|
||||
if(profile.scheme==scheme.name && profile.colors==scheme.colors)
|
||||
return std::size(ks::files::textPalettes)+index+1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
INT_PTR CALLBACK schemeNameDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
if(message==WM_INITDIALOG)
|
||||
{
|
||||
SetWindowLongPtrW(dialog,DWLP_USER,lp);
|
||||
SendDlgItemMessageW(dialog,100,EM_SETLIMITTEXT,ks::files::maxTextPaletteName,0);
|
||||
groupDialogButtons(dialog,{IDOK,IDCANCEL},true);
|
||||
return TRUE;
|
||||
}
|
||||
if(message!=WM_COMMAND) return FALSE;
|
||||
if(LOWORD(wp)==IDCANCEL) { FilesModalDialog::finish(dialog,IDCANCEL); return TRUE; }
|
||||
if(LOWORD(wp)!=IDOK) return FALSE;
|
||||
wchar_t text[ks::files::maxTextPaletteName+1]{};
|
||||
GetDlgItemTextW(dialog,100,text,static_cast<int>(std::size(text)));
|
||||
std::wstring name=text;
|
||||
const auto first=name.find_first_not_of(L" \t");
|
||||
if(first==name.npos) { MessageBeep(MB_ICONWARNING); return TRUE; }
|
||||
*reinterpret_cast<std::wstring *>(GetWindowLongPtrW(dialog,DWLP_USER))=
|
||||
name.substr(first,name.find_last_not_of(L" \t")-first+1);
|
||||
FilesModalDialog::finish(dialog,IDOK);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
void saveTextSchemeDraft(HWND dialog,ks::files::TextSettings &settings,ks::files::TextPreferences &profile)
|
||||
{
|
||||
std::wstring name;
|
||||
if(FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(608),dialog,schemeNameDialog,
|
||||
reinterpret_cast<LPARAM>(&name))!=IDOK) return;
|
||||
if(sameSchemeName(name,L"Custom") || std::any_of(std::begin(ks::files::textPaletteNames),
|
||||
std::end(ks::files::textPaletteNames),[&](const wchar_t *preset){return sameSchemeName(name,preset);}))
|
||||
{
|
||||
showFilesNotice(dialog,L"Choose a different name for your color scheme.",L"Save color scheme");
|
||||
return;
|
||||
}
|
||||
const auto existing=std::find_if(settings.schemes.begin(),settings.schemes.end(),
|
||||
[&](const auto &scheme){return sameSchemeName(name,scheme.name.c_str());});
|
||||
if(existing!=settings.schemes.end())
|
||||
{
|
||||
if(showFilesNotice(dialog,L"Replace the saved scheme with the current colors?",L"Save color scheme",true)!=IDYES) return;
|
||||
existing->colors=profile.colors;
|
||||
profile.scheme=existing->name;
|
||||
}
|
||||
else
|
||||
{
|
||||
if(settings.schemes.size()>=ks::files::maxTextPalettes)
|
||||
{
|
||||
showFilesNotice(dialog,L"The color scheme list is full.",L"Save color scheme");
|
||||
return;
|
||||
}
|
||||
settings.schemes.push_back({name,profile.colors});
|
||||
profile.scheme=std::move(name);
|
||||
}
|
||||
populateTextSchemes(dialog,settings);
|
||||
}
|
||||
|
||||
void showTextOptions(HWND dialog,const ks::files::TextSettings &settings,const ks::files::TextPreferences &profile)
|
||||
{
|
||||
for(const auto &check:textChecks) CheckDlgButton(dialog,check.id,profile.*check.value?BST_CHECKED:BST_UNCHECKED);
|
||||
SendDlgItemMessageW(dialog,textTabWidth,CB_SETCURSEL,profile.tabWidth-1,0);
|
||||
SendDlgItemMessageW(dialog,textLineEnding,CB_SETCURSEL,profile.lineEnding,0);
|
||||
SendDlgItemMessageW(dialog,textTheme,CB_SETCURSEL,currentTextScheme(settings,profile),0);
|
||||
refreshTextColor(dialog,profile);
|
||||
}
|
||||
|
||||
bool changeTextOption(HWND dialog,OptionsDraft &draft,WPARAM command)
|
||||
{
|
||||
if(!textOptionsPage(draft.page)) return false;
|
||||
auto &profile=draft.text.profiles[draft.page==TextEditor];
|
||||
const auto id=LOWORD(command);
|
||||
const auto row=[&](int control){return SendDlgItemMessageW(dialog,control,CB_GETCURSEL,0,0);};
|
||||
if(const auto check=std::find_if(std::begin(textChecks),std::end(textChecks),
|
||||
[id](const auto &entry){return entry.id==id;});check!=std::end(textChecks))
|
||||
profile.*check->value=IsDlgButtonChecked(dialog,id)==BST_CHECKED;
|
||||
else if(id==textSaveScheme) saveTextSchemeDraft(dialog,draft.text,profile);
|
||||
else if(id==textChangeColor)
|
||||
{
|
||||
const auto index=row(textColor);
|
||||
if(index>=0 && static_cast<size_t>(index)<profile.colors.size())
|
||||
choosePanelColor(dialog,profile.colors[index],draft.custom.data());
|
||||
}
|
||||
else if(HIWORD(command)==CBN_SELCHANGE)
|
||||
{
|
||||
const auto selected=row(id);
|
||||
if(selected<0) return false;
|
||||
switch(id)
|
||||
{
|
||||
case textColor: break;
|
||||
case textTabWidth: profile.tabWidth=static_cast<int>(selected)+1; break;
|
||||
case textLineEnding: profile.lineEnding=static_cast<int>(selected); break;
|
||||
case textTheme:
|
||||
if(selected==0) profile.scheme.clear();
|
||||
else if(const auto scheme=static_cast<size_t>(selected-1);scheme<std::size(ks::files::textPalettes))
|
||||
{
|
||||
profile.scheme=ks::files::textPaletteNames[scheme];
|
||||
profile.colors=ks::files::textPalettes[scheme];
|
||||
}
|
||||
else
|
||||
{
|
||||
const auto index=scheme-std::size(ks::files::textPalettes);
|
||||
if(index>=draft.text.schemes.size()) return false;
|
||||
profile.scheme=draft.text.schemes[index].name;
|
||||
profile.colors=draft.text.schemes[index].colors;
|
||||
}
|
||||
break;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
else return false;
|
||||
showTextOptions(dialog,draft.text,profile);
|
||||
updateOptionsApply(dialog,draft);
|
||||
return true;
|
||||
}
|
||||
|
||||
void showOptionsPage(HWND dialog,OptionsDraft &draft,int page,int section)
|
||||
{
|
||||
draft.page=page;
|
||||
@@ -179,7 +383,7 @@ void showOptionsPage(HWND dialog,OptionsDraft &draft,int page,int section)
|
||||
else if(page>=0)
|
||||
{
|
||||
title=optionsTitles[page];
|
||||
if(section>=0)
|
||||
if(section>=0 && !textOptionsPage(page))
|
||||
title=page==PanelModes?panelSectionNames[section]:page==PanelColors?
|
||||
(section==fileStyleSection?L"File type styles":colorNames[section]):
|
||||
page==CommandLine?L"Command history":section==DialogFrame?L"Dialog frame":L"Font";
|
||||
@@ -218,6 +422,9 @@ void showOptionsPage(HWND dialog,OptionsDraft &draft,int page,int section)
|
||||
}
|
||||
constexpr int commandLineControls[]{320,321,322,323};
|
||||
show(commandLineControls,page==CommandLine && section==0);
|
||||
show(textControls,textOptionsPage(page));
|
||||
show(editingControls,page==TextEditor);
|
||||
if(textOptionsPage(page)) showTextOptions(dialog,draft.text,draft.text.profiles[page==TextEditor]);
|
||||
const bool fileTypes=page==PanelColors && section==fileStyleSection;
|
||||
constexpr int fileStyleControls[]{287,288,289,290,291,292,293,294,295,296,297,298,300,fileStyleState};
|
||||
show(fileStyleControls,fileTypes);
|
||||
@@ -278,7 +485,7 @@ void filterOptions(HWND dialog,OptionsDraft &draft)
|
||||
draft.wideColumnsNode=nullptr;
|
||||
const auto root=add(L"All settings",allOptions,-1,TVI_ROOT);
|
||||
HTREEITEM first{},selected{};
|
||||
for(int page=PanelModes;page<=CommandLine;++page)
|
||||
for(int page=PanelModes;page<allOptions;++page)
|
||||
{
|
||||
HTREEITEM category{};
|
||||
const bool wholeCategory=matches(optionsTitles[page]) || (!query.empty() && matches(L"All settings"));
|
||||
@@ -309,6 +516,16 @@ void filterOptions(HWND dialog,OptionsDraft &draft)
|
||||
matches(L"Underline") || matches(L"Strikethrough") || matches(L"Foreground"));
|
||||
}
|
||||
else if(page==CommandLine) addSection(L"Command history",0,matches(L"Previous command"));
|
||||
else if(textOptionsPage(page))
|
||||
{
|
||||
const bool propertyMatches=matches(L"Tab width") ||
|
||||
matches(L"Color scheme") || matches(L"Save scheme") || matches(L"Customize colors") ||
|
||||
(page==TextEditor && matches(L"Line endings for new lines")) ||
|
||||
std::any_of(std::begin(textChecks),std::end(textChecks),[&](const auto &check)
|
||||
{return (page==TextEditor || check.id<354) && matches(check.name);}) ||
|
||||
std::any_of(std::begin(ks::files::textColorNames),std::end(ks::files::textColorNames),matches);
|
||||
addSection(page==TextEditor?L"Editing and appearance":L"Viewing and appearance",0,propertyMatches);
|
||||
}
|
||||
else
|
||||
{
|
||||
addSection(L"Font",Font,matches(L"Display font"));
|
||||
@@ -347,6 +564,14 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
for(DWORD value=0;value<=option.maximum;++value)
|
||||
SendDlgItemMessageW(dialog,option.id,CB_ADDSTRING,0,
|
||||
reinterpret_cast<LPARAM>(std::to_wstring(value).c_str()));
|
||||
for(int width=1;width<=16;++width)
|
||||
SendDlgItemMessageW(dialog,textTabWidth,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(std::to_wstring(width).c_str()));
|
||||
for(const auto label:{L"Automatic",L"Windows (CRLF)",L"Unix (LF)",L"Classic Mac (CR)"})
|
||||
SendDlgItemMessageW(dialog,textLineEnding,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(label));
|
||||
populateTextSchemes(dialog,draft.text);
|
||||
for(const auto label:ks::files::textColorNames)
|
||||
SendDlgItemMessageW(dialog,textColor,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(label));
|
||||
SendDlgItemMessageW(dialog,textColor,CB_SETCURSEL,0,0);
|
||||
CheckDlgButton(dialog,202,alignExtensions);
|
||||
CheckDlgButton(dialog,203,showDetails);
|
||||
CheckDlgButton(dialog,204,alignFolders);
|
||||
@@ -393,10 +618,11 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
}
|
||||
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
|
||||
{
|
||||
finishFilesDialog(dialog,IDCANCEL);
|
||||
FilesModalDialog::finish(dialog,IDCANCEL);
|
||||
return TRUE;
|
||||
}
|
||||
if(message!=WM_COMMAND) return FALSE;
|
||||
if(changeTextOption(dialog,*draft,wp)) return TRUE;
|
||||
switch(LOWORD(wp))
|
||||
{
|
||||
case optionsSearch:
|
||||
@@ -484,6 +710,10 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
}
|
||||
if(draft->page==allOptions || (draft->page==Appearance && draft->section!=Font))
|
||||
draft->framePadding=defaultDialogFramePadding;
|
||||
if(draft->page==allOptions || textOptionsPage(draft->page))
|
||||
for(int page=TextViewer;page<=TextEditor;++page)
|
||||
if(draft->page==allOptions || draft->page==page)
|
||||
draft->text.profiles[page==TextEditor]=ks::files::TextPreferences{};
|
||||
showOptionsPage(dialog,*draft,draft->page,draft->section);
|
||||
updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
@@ -516,76 +746,143 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
SetDlgItemTextW(dialog,optionsSearch,L"");
|
||||
TreeView_SelectItem(GetDlgItem(dialog,optionsTree),draft->wideColumnsNode);
|
||||
MessageBoxW(dialog,L"Enter a width from 6 to 16 characters.",optionsTitles[PanelModes],MB_OK|MB_ICONWARNING);
|
||||
showFilesNotice(dialog,L"Enter a width from 6 to 16 characters.",optionsTitles[PanelModes]);
|
||||
SetFocus(GetDlgItem(dialog,201));
|
||||
SendDlgItemMessageW(dialog,201,EM_SETSEL,0,-1);
|
||||
return TRUE;
|
||||
}
|
||||
const auto keys=optionHistoryKeys(dialog);
|
||||
if(!validHistoryKeys(keys))
|
||||
const auto owner=draft->filesWindow;
|
||||
const OptionsWriteLock writeLock;
|
||||
if(!writeLock.acquired())
|
||||
{
|
||||
MessageBoxW(dialog,L"Choose distinct shortcuts using Ctrl or Alt that are free in Files.",L"Command history",MB_OK|MB_ICONWARNING);
|
||||
setStatus(L"Another window is saving Files settings. Try Apply again.");
|
||||
return TRUE;
|
||||
}
|
||||
const auto &base=draft->baseline;
|
||||
const bool textChanged=draft->text!=base.text;
|
||||
auto textSettings=ks::files::loadTextPreferences();
|
||||
for(size_t i=0;i<textSettings.profiles.size();++i)
|
||||
{
|
||||
if(draft->text.profiles[i]==base.text.profiles[i]) continue;
|
||||
if(textSettings.profiles[i]!=base.text.profiles[i] && textSettings.profiles[i]!=draft->text.profiles[i])
|
||||
{
|
||||
showFilesNotice(dialog,L"Text settings changed in another window. Reopen Options before editing them again.",
|
||||
L"KSwitcher Files Options");
|
||||
return TRUE;
|
||||
}
|
||||
textSettings.profiles[i]=draft->text.profiles[i];
|
||||
}
|
||||
if(draft->text.schemes!=base.text.schemes)
|
||||
{
|
||||
if(textSettings.schemes!=base.text.schemes && textSettings.schemes!=draft->text.schemes)
|
||||
{
|
||||
showFilesNotice(dialog,L"Color schemes changed in another window. Reopen Options before editing them again.",
|
||||
L"KSwitcher Files Options");
|
||||
return TRUE;
|
||||
}
|
||||
textSettings.schemes=draft->text.schemes;
|
||||
}
|
||||
auto keys=optionHistoryKeys(dialog);
|
||||
loadCommandHistoryKeys();
|
||||
for(size_t i=0;i<keys.size();++i)
|
||||
if(keys[i]==base.keys[i]) keys[i]=commandHistoryKeys[i];
|
||||
if(!validHistoryKeys(keys))
|
||||
{
|
||||
showFilesNotice(dialog,L"Choose distinct shortcuts using Ctrl or Alt that are free in Files.",L"Command history");
|
||||
return TRUE;
|
||||
}
|
||||
const bool stylesChanged=draft->styles!=base.styles;
|
||||
loadFileStyles();
|
||||
if(stylesChanged && fileStyles!=base.styles && fileStyles!=draft->styles)
|
||||
{
|
||||
showFilesNotice(dialog,L"File type styles changed in another window. Reopen Options before editing them again.",
|
||||
L"KSwitcher Files Options");
|
||||
return TRUE;
|
||||
}
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
const bool stylesChanged=draft->styles!=fileStyles;
|
||||
if(stylesChanged && !saveFileStyles(draft->styles))
|
||||
{
|
||||
MessageBoxW(dialog,L"Could not save the file type styles.",L"KSwitcher Files Options",MB_OK|MB_ICONWARNING);
|
||||
showFilesNotice(dialog,L"Could not save the file type styles.",L"KSwitcher Files Options");
|
||||
return TRUE;
|
||||
}
|
||||
const bool fontChanged=optionsFontChanged(*draft);
|
||||
if(fontChanged && !applyFont(owner,draft->font,draft->points))
|
||||
{
|
||||
if(stylesChanged) saveFileStyles(fileStyles);
|
||||
MessageBoxW(dialog,L"Could not apply the selected font.",optionsTitles[Appearance],MB_OK|MB_ICONWARNING);
|
||||
showFilesNotice(dialog,L"Could not apply the selected font.",optionsTitles[Appearance]);
|
||||
return TRUE;
|
||||
}
|
||||
commandHistoryKeys=keys;
|
||||
savePreference(L"PreviousCommandKey",keys[0]);
|
||||
savePreference(L"CommandHistoryKey",keys[1]);
|
||||
if(stylesChanged) fileStyles=draft->styles;
|
||||
sizeWidth=static_cast<int>(width);
|
||||
alignExtensions=IsDlgButtonChecked(dialog,202)==BST_CHECKED;
|
||||
showDetails=IsDlgButtonChecked(dialog,203)==BST_CHECKED;
|
||||
alignFolders=IsDlgButtonChecked(dialog,204)==BST_CHECKED;
|
||||
statusFields=0;
|
||||
for(int i=0;i<4;++i)
|
||||
if(IsDlgButtonChecked(dialog,205+i)==BST_CHECKED) statusFields|=1u<<i;
|
||||
savePreference(L"SizeWidth",sizeWidth);
|
||||
savePreference(L"AlignExtensions",alignExtensions);
|
||||
savePreference(L"ShowDetails",showDetails);
|
||||
savePreference(L"AlignFolders",alignFolders);
|
||||
savePreference(L"StatusFields",statusFields);
|
||||
for(int i=0;i<ColorCount;++i)
|
||||
if(colors[i]!=draft->palette[i])
|
||||
{
|
||||
colors[i]=draft->palette[i];
|
||||
savePreference(std::to_wstring(i).c_str(),colors[i]);
|
||||
}
|
||||
refreshColors();
|
||||
if(draft->framePadding!=dialogFramePadding)
|
||||
if(textChanged)
|
||||
{
|
||||
dialogFramePadding=draft->framePadding;
|
||||
savePreference(dialogFrameOuterKey,dialogFramePadding.outer);
|
||||
savePreference(dialogFrameInnerKey,dialogFramePadding.inner);
|
||||
savePreference(dialogRowTextKey,dialogFramePadding.rowText);
|
||||
try { ks::files::saveTextPreferences(textSettings); }
|
||||
catch(const std::exception &error)
|
||||
{
|
||||
if(fontChanged) applyFont(owner,base.font,base.points);
|
||||
if(stylesChanged) saveFileStyles(fileStyles);
|
||||
const auto message=L"Could not save text settings. "+ks::wide(error.what());
|
||||
showFilesNotice(dialog,message.c_str(),L"KSwitcher Files Options");
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
const auto saveChanged=[](const wchar_t *name,DWORD value,DWORD initial)
|
||||
{
|
||||
if(value!=initial) savePreference(name,value);
|
||||
};
|
||||
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
|
||||
const auto inputKeys=optionHistoryKeys(dialog);
|
||||
saveChanged(L"PreviousCommandKey",inputKeys[0],base.keys[0]);
|
||||
saveChanged(L"CommandHistoryKey",inputKeys[1],base.keys[1]);
|
||||
saveChanged(L"SizeWidth",width,base.width);
|
||||
saveChanged(L"AlignExtensions",checked(202),base.extensions);
|
||||
saveChanged(L"AlignFolders",checked(204),base.folders);
|
||||
saveChanged(L"ShowDetails",checked(203),base.details);
|
||||
DWORD fields=preference(L"StatusFields",defaultStatusFields)&15;
|
||||
const auto initialFields=fields;
|
||||
for(int i=0;i<4;++i)
|
||||
{
|
||||
const DWORD bit=1u<<i;
|
||||
if(checked(205+i)!=((base.fields&bit)!=0)) fields=checked(205+i)?fields|bit:fields&~bit;
|
||||
}
|
||||
saveChanged(L"StatusFields",fields,initialFields);
|
||||
for(int i=0;i<ColorCount;++i)
|
||||
saveChanged(std::to_wstring(i).c_str(),draft->palette[i],base.palette[i]);
|
||||
saveChanged(dialogFrameOuterKey,draft->framePadding.outer,base.padding.outer);
|
||||
saveChanged(dialogFrameInnerKey,draft->framePadding.inner,base.padding.inner);
|
||||
saveChanged(dialogRowTextKey,draft->framePadding.rowText,base.padding.rowText);
|
||||
if(fontChanged)
|
||||
{
|
||||
savePreference(L"FontPoints",fontPoints);
|
||||
RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,L"DisplayFont",REG_BINARY,&displayFont,sizeof(displayFont));
|
||||
draft->font=displayFont;
|
||||
}
|
||||
for(int i=0;i<2;++i)
|
||||
{
|
||||
const bool wide=IsDlgButtonChecked(dialog,273+i)==BST_CHECKED;
|
||||
if(panels[i].wide!=wide) setMode(i,wide);
|
||||
}
|
||||
saveChanged(L"LeftWide",checked(273),base.wide[0]);
|
||||
saveChanged(L"RightWide",checked(274),base.wide[1]);
|
||||
loadFilesPreferences(owner);
|
||||
notifyFilesPreferences(owner);
|
||||
const auto dialogOwner=GetWindow(dialog,GW_OWNER);
|
||||
if(dialogOwner!=owner)
|
||||
SendMessageW(dialogOwner,filesPreferencesChanged,reinterpret_cast<WPARAM>(owner),0);
|
||||
draft->baseline=OptionsBaseline{};
|
||||
draft->text=draft->baseline.text;
|
||||
populateTextSchemes(dialog,draft->text);
|
||||
draft->font=displayFont;
|
||||
draft->points=fontPoints;
|
||||
draft->styles=fileStyles;
|
||||
draft->palette=colors;
|
||||
draft->framePadding=dialogFramePadding;
|
||||
SetDlgItemInt(dialog,201,sizeWidth,FALSE);
|
||||
CheckDlgButton(dialog,202,alignExtensions);
|
||||
CheckDlgButton(dialog,203,showDetails);
|
||||
CheckDlgButton(dialog,204,alignFolders);
|
||||
CheckDlgButton(dialog,273,panels[0].wide);
|
||||
CheckDlgButton(dialog,274,panels[1].wide);
|
||||
for(int i=0;i<4;++i) CheckDlgButton(dialog,205+i,(statusFields>>i)&1u);
|
||||
setOptionHistoryKeys(dialog,commandHistoryKeys);
|
||||
if(draft->page==PanelColors && draft->section==fileStyleSection)
|
||||
updateFileStyleEditor(dialog,*draft,true);
|
||||
showOptionsPage(dialog,*draft,draft->page,draft->section);
|
||||
layout(owner);
|
||||
showFileDetails(panels[active]);
|
||||
RedrawWindow(owner,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN|RDW_UPDATENOW);
|
||||
if(LOWORD(wp)==IDOK) finishFilesDialog(dialog,IDOK);
|
||||
if(LOWORD(wp)==IDOK) FilesModalDialog::finish(dialog,IDOK);
|
||||
else updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
}
|
||||
@@ -593,13 +890,25 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void editOptions(HWND owner)
|
||||
void runOptions(HWND owner,int page)
|
||||
{
|
||||
const auto panel=panels[active].list;
|
||||
OptionsDraft draft;
|
||||
draft.filesWindow=GetAncestor(owner,GA_ROOTOWNER);
|
||||
draft.page=page;
|
||||
draft.section=textOptionsPage(page)?0:PanelLayout;
|
||||
std::copy(std::begin(colors),std::end(colors),draft.palette.begin());
|
||||
const auto result=runFilesDialog(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(101),owner,optionsDialog,
|
||||
const auto result=FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(101),owner,optionsDialog,
|
||||
reinterpret_cast<LPARAM>(&draft));
|
||||
SetFocus(panel);
|
||||
if(result==-1) setStatus(L"Could not open Files Options.");
|
||||
}
|
||||
|
||||
void editOptions(HWND owner)
|
||||
{
|
||||
runOptions(owner,PanelModes);
|
||||
SetFocus(panels[active].list);
|
||||
}
|
||||
|
||||
void editTextOptions(HWND owner,bool edit)
|
||||
{
|
||||
runOptions(owner,edit?TextEditor:TextViewer);
|
||||
}
|
||||
@@ -127,7 +127,7 @@ bool addFileStyle(HWND dialog,OptionsDraft &draft)
|
||||
const auto extension=fileStyleExtension(input);
|
||||
if(extension.empty())
|
||||
{
|
||||
MessageBoxW(dialog,L"Enter a file extension such as .txt.",L"File type styles",MB_OK|MB_ICONWARNING);
|
||||
showFilesNotice(dialog,L"Enter a file extension such as .txt.",L"File type styles");
|
||||
SetFocus(GetDlgItem(dialog,fileStyleSuggestion));
|
||||
return false;
|
||||
}
|
||||
@@ -138,7 +138,7 @@ bool addFileStyle(HWND dialog,OptionsDraft &draft)
|
||||
{
|
||||
if(draft.styles.size()>=64)
|
||||
{
|
||||
MessageBoxW(dialog,L"The profile can contain up to 64 file type rules.",L"File type styles",MB_OK|MB_ICONWARNING);
|
||||
showFilesNotice(dialog,L"The profile can contain up to 64 file type rules.",L"File type styles");
|
||||
return false;
|
||||
}
|
||||
draft.styles.push_back({extension});
|
||||
|
||||
@@ -49,7 +49,7 @@ public:
|
||||
startup.StartupInfo.cb=sizeof(startup);
|
||||
startup.lpAttributeList=attributes;
|
||||
PROCESS_INFORMATION info{};
|
||||
wchar_t command[]=L"powershell.exe -NoLogo -NoProfile -NoExit -Command \"Remove-Module PSReadLine -ErrorAction SilentlyContinue; function global:prompt { ([string][char]27) + '[?25l' }; Clear-Host\"";
|
||||
wchar_t command[]=L"powershell.exe -NoLogo -NoProfile -NoExit -Command \"Remove-Module PSReadLine -ErrorAction SilentlyContinue; function global:prompt { $location = Get-Location; $path = if ($location.Provider.Name -eq 'FileSystem') { [Uri]::EscapeDataString($location.ProviderPath) }; ([string][char]27) + ']633;ks-directory:' + $path + [char]7 + [char]27 + '[?25l' }; Clear-Host\"";
|
||||
wchar_t system[MAX_PATH]{};
|
||||
const auto length=GetSystemDirectoryW(system,MAX_PATH);
|
||||
const auto shell=std::wstring(system)+L"\\WindowsPowerShell\\v1.0\\powershell.exe";
|
||||
|
||||
@@ -56,7 +56,10 @@ class FilesTerminalView
|
||||
int viewportHeight{};
|
||||
public:
|
||||
bool initialized() const { return ready; }
|
||||
void enableTrace() { if(ready) web->PostWebMessageAsString(L"trace:on"); }
|
||||
void enableTrace(bool output)
|
||||
{
|
||||
if(ready) web->PostWebMessageAsString(output?L"trace:output":L"trace:on");
|
||||
}
|
||||
void setFont(const LOGFONTW &font,int points)
|
||||
{
|
||||
if(!ready) return;
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
#include "files_text_document.h"
|
||||
|
||||
#include <objbase.h>
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace ks::files
|
||||
{
|
||||
namespace
|
||||
{
|
||||
[[noreturn]] void fail(const char *operation)
|
||||
{
|
||||
throw std::system_error(static_cast<int>(GetLastError()), std::system_category(), operation);
|
||||
}
|
||||
|
||||
struct File
|
||||
{
|
||||
HANDLE handle = INVALID_HANDLE_VALUE;
|
||||
~File() { if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); }
|
||||
HANDLE release() { return std::exchange(handle, INVALID_HANDLE_VALUE); }
|
||||
};
|
||||
|
||||
struct PendingSave
|
||||
{
|
||||
std::filesystem::path path;
|
||||
~PendingSave() { if (!path.empty()) DeleteFileW(path.c_str()); }
|
||||
};
|
||||
|
||||
HANDLE openSource(const std::filesystem::path &path, bool editable)
|
||||
{
|
||||
const auto sharing = FILE_SHARE_READ | FILE_SHARE_DELETE | (editable ? 0 : FILE_SHARE_WRITE);
|
||||
const auto result = CreateFileW(path.c_str(), GENERIC_READ, sharing,
|
||||
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (result == INVALID_HANDLE_VALUE) fail("Open file");
|
||||
return result;
|
||||
}
|
||||
|
||||
uint64_t fileSize(HANDLE file)
|
||||
{
|
||||
LARGE_INTEGER size{};
|
||||
if (!GetFileSizeEx(file, &size)) fail("Read file size");
|
||||
return size.QuadPart;
|
||||
}
|
||||
|
||||
void seek(HANDLE file, uint64_t offset)
|
||||
{
|
||||
LARGE_INTEGER position{};
|
||||
position.QuadPart = static_cast<LONGLONG>(offset);
|
||||
if (!SetFilePointerEx(file, position, nullptr, FILE_BEGIN)) fail("Seek");
|
||||
}
|
||||
|
||||
std::string read(HANDLE file, uint64_t offset, size_t length)
|
||||
{
|
||||
if (!length) return {};
|
||||
seek(file, offset);
|
||||
std::string result(length, '\0');
|
||||
DWORD count{};
|
||||
if (!ReadFile(file, result.data(), static_cast<DWORD>(length), &count, nullptr)) fail("Read file");
|
||||
if (count != length) throw std::runtime_error("The file ended unexpectedly.");
|
||||
return result;
|
||||
}
|
||||
|
||||
void write(HANDLE file, std::string_view bytes)
|
||||
{
|
||||
if (bytes.empty()) return;
|
||||
DWORD written{};
|
||||
if (!WriteFile(file, bytes.data(), static_cast<DWORD>(bytes.size()), &written, nullptr))
|
||||
fail("Write file");
|
||||
if (written != bytes.size()) throw std::runtime_error("The complete block could not be written.");
|
||||
}
|
||||
|
||||
bool sameFile(HANDLE a, HANDLE b)
|
||||
{
|
||||
BY_HANDLE_FILE_INFORMATION first{}, second{};
|
||||
if (!GetFileInformationByHandle(a, &first) || !GetFileInformationByHandle(b, &second))
|
||||
fail("Check file identity");
|
||||
return first.dwVolumeSerialNumber == second.dwVolumeSerialNumber &&
|
||||
first.nFileIndexHigh == second.nFileIndexHigh && first.nFileIndexLow == second.nFileIndexLow;
|
||||
}
|
||||
|
||||
std::filesystem::path temporaryPath(const std::filesystem::path &directory)
|
||||
{
|
||||
GUID id{};
|
||||
wchar_t name[40]{};
|
||||
if (FAILED(CoCreateGuid(&id)) || !StringFromGUID2(id, name, 40))
|
||||
throw std::runtime_error("Cannot create a temporary file name.");
|
||||
return directory / (std::wstring(L".kswitcher-") + name + L".tmp");
|
||||
}
|
||||
|
||||
std::filesystem::path createTemporary(const std::filesystem::path &directory, File &file, DWORD flags)
|
||||
{
|
||||
auto path = temporaryPath(directory);
|
||||
file.handle = CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, CREATE_NEW, flags, nullptr);
|
||||
if (file.handle == INVALID_HANDLE_VALUE) fail("Create temporary file");
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
TextDocument::TextDocument(std::filesystem::path path, bool editable) : path_(std::move(path)), editable_(editable)
|
||||
{
|
||||
File source{openSource(path_, editable)};
|
||||
if (editable)
|
||||
{
|
||||
std::wstring resolved(32768, L'\0');
|
||||
const auto length = GetFinalPathNameByHandleW(source.handle, resolved.data(),
|
||||
static_cast<DWORD>(resolved.size()), FILE_NAME_NORMALIZED | VOLUME_NAME_DOS);
|
||||
if (length && length < resolved.size())
|
||||
{
|
||||
resolved.resize(length);
|
||||
path_ = resolved;
|
||||
}
|
||||
}
|
||||
size_ = fileSize(source.handle);
|
||||
const auto prefix = read(source.handle, 0, static_cast<size_t>(std::min<uint64_t>(3, size_)));
|
||||
if (prefix.starts_with("\xef\xbb\xbf")) bom_ = 3;
|
||||
else if (prefix.starts_with("\xff\xfe") || prefix.starts_with("\xfe\xff"))
|
||||
{
|
||||
encoding_ = prefix.starts_with("\xff") ? Encoding::utf16le : Encoding::utf16be;
|
||||
bom_ = 2;
|
||||
if (size_ % 2) throw std::runtime_error("The UTF-16 file has an incomplete code unit.");
|
||||
}
|
||||
source_ = source.release();
|
||||
}
|
||||
|
||||
TextDocument::~TextDocument()
|
||||
{
|
||||
if (source_ != INVALID_HANDLE_VALUE) CloseHandle(source_);
|
||||
if (spool_ != INVALID_HANDLE_VALUE) CloseHandle(spool_);
|
||||
}
|
||||
|
||||
const wchar_t *TextDocument::encodingName() const
|
||||
{
|
||||
switch (encoding_)
|
||||
{
|
||||
case Encoding::utf16le: return L"UTF-16 LE";
|
||||
case Encoding::utf16be: return L"UTF-16 BE";
|
||||
default: return bom_ ? L"UTF-8 BOM" : L"UTF-8";
|
||||
}
|
||||
}
|
||||
|
||||
uint64_t TextDocument::pages() const
|
||||
{
|
||||
return std::max<uint64_t>(1, (size_ - bom_ + pageBytes - 1) / pageBytes);
|
||||
}
|
||||
|
||||
uint64_t TextDocument::boundary(uint64_t page) const
|
||||
{
|
||||
if (!page) return bom_;
|
||||
if (page >= pages()) return size_;
|
||||
|
||||
const auto offset = bom_ + page * pageBytes;
|
||||
const auto unit = encoding_ == Encoding::utf8 ? 1u : 2u;
|
||||
const auto sample = read(source_, offset - unit,
|
||||
static_cast<size_t>(std::min<uint64_t>(4096 + unit, size_ - offset + unit)));
|
||||
const auto getUnit = [&](size_t i) -> unsigned {
|
||||
const auto first = static_cast<unsigned char>(sample[i]);
|
||||
if (unit == 1) return first;
|
||||
const auto second = static_cast<unsigned char>(sample[i + 1]);
|
||||
return encoding_ == Encoding::utf16le ? first | (second << 8) : (first << 8) | second;
|
||||
};
|
||||
if (getUnit(0) == '\n') return offset;
|
||||
for (size_t i = unit; i + unit < sample.size(); i += unit)
|
||||
if (getUnit(i) == '\n') return offset + i;
|
||||
|
||||
size_t i = unit;
|
||||
if (unit == 1)
|
||||
{
|
||||
// Advance past at most one UTF-8 code point, even on malformed input.
|
||||
while (i < sample.size() && i <= 3 && (getUnit(i) & 0xc0) == 0x80) ++i;
|
||||
}
|
||||
else if (const auto value = getUnit(i); value >= 0xdc00 && value <= 0xdfff) i += unit;
|
||||
if (i == unit && getUnit(0) == '\r' && getUnit(i) == '\n') i += unit;
|
||||
return offset + i - unit;
|
||||
}
|
||||
|
||||
std::string TextDocument::decode(std::string bytes) const
|
||||
{
|
||||
if (encoding_ == Encoding::utf8 || bytes.empty()) return bytes;
|
||||
std::wstring units;
|
||||
units.resize(bytes.size() / 2);
|
||||
for (size_t i = 0; i < units.size(); ++i)
|
||||
{
|
||||
const auto a = static_cast<unsigned char>(bytes[2 * i]);
|
||||
const auto b = static_cast<unsigned char>(bytes[2 * i + 1]);
|
||||
units[i] = static_cast<wchar_t>(encoding_ == Encoding::utf16le ? a | (b << 8) : (a << 8) | b);
|
||||
}
|
||||
const auto length = static_cast<int>(units.size());
|
||||
const int required = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, units.data(), length, nullptr, 0, nullptr, nullptr);
|
||||
if (!required) fail("Decode UTF-16");
|
||||
std::string result(required, '\0');
|
||||
if (!WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, units.data(), length, result.data(), required, nullptr, nullptr))
|
||||
fail("Decode UTF-16");
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string TextDocument::encode(std::string_view utf8) const
|
||||
{
|
||||
if (encoding_ == Encoding::utf8 || utf8.empty()) return std::string(utf8);
|
||||
const auto length = static_cast<int>(utf8.size());
|
||||
const int required = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8.data(), length, nullptr, 0);
|
||||
if (!required) fail("Encode UTF-16");
|
||||
std::wstring units(required, L'\0');
|
||||
if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8.data(), length, units.data(), required))
|
||||
fail("Encode UTF-16");
|
||||
std::string bytes(units.size() * 2, '\0');
|
||||
const int low = encoding_ == Encoding::utf16le ? 0 : 1;
|
||||
for (size_t i = 0; i < units.size(); ++i)
|
||||
{
|
||||
bytes[2 * i + low] = static_cast<char>(units[i] & 0xff);
|
||||
bytes[2 * i + 1 - low] = static_cast<char>(units[i] >> 8);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
std::string TextDocument::readPage(uint64_t page) const
|
||||
{
|
||||
if (const auto it = changes_.find(page); it != changes_.end())
|
||||
return read(spool_, it->second.offset, it->second.size);
|
||||
return readOriginal(page);
|
||||
}
|
||||
|
||||
std::string TextDocument::readOriginal(uint64_t page) const
|
||||
{
|
||||
const auto start = boundary(page);
|
||||
return decode(read(source_, start, static_cast<size_t>(boundary(page + 1) - start)));
|
||||
}
|
||||
|
||||
bool TextDocument::modifiedWith(uint64_t page, std::string_view current) const
|
||||
{
|
||||
const size_t changesOnPage = changes_.contains(page) ? 1 : 0;
|
||||
return changes_.size() > changesOnPage || current != readOriginal(page);
|
||||
}
|
||||
|
||||
void TextDocument::stage(uint64_t page, std::string_view utf8)
|
||||
{
|
||||
if (!editable_) throw std::logic_error("The file is open for viewing.");
|
||||
if (utf8.size() > editLimit) throw std::runtime_error("The edit exceeds 8 MiB on this page.");
|
||||
if (utf8 == readOriginal(page))
|
||||
{
|
||||
changes_.erase(page);
|
||||
return;
|
||||
}
|
||||
if (spool_ == INVALID_HANDLE_VALUE)
|
||||
{
|
||||
wchar_t directory[32768]{};
|
||||
const auto length = GetTempPathW(static_cast<DWORD>(std::size(directory)), directory);
|
||||
if (!length || length >= std::size(directory)) fail("Locate temporary directory");
|
||||
File file;
|
||||
createTemporary(directory, file, FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE);
|
||||
spool_ = file.release();
|
||||
}
|
||||
seek(spool_, spoolSize_);
|
||||
write(spool_, utf8);
|
||||
changes_.insert_or_assign(page, Change{spoolSize_, utf8.size()});
|
||||
spoolSize_ += utf8.size();
|
||||
}
|
||||
|
||||
bool TextDocument::save()
|
||||
{
|
||||
if (!modified()) return true;
|
||||
PendingSave pending;
|
||||
File output;
|
||||
pending.path = createTemporary(path_.parent_path(), output, FILE_ATTRIBUTE_NORMAL);
|
||||
|
||||
uint64_t written{};
|
||||
const auto append = [&](std::string_view bytes) {
|
||||
write(output.handle, bytes);
|
||||
written += bytes.size();
|
||||
};
|
||||
const auto copyUntil = [&](uint64_t &offset, uint64_t end) {
|
||||
while (offset < end)
|
||||
{
|
||||
const auto block = read(source_, offset, static_cast<size_t>(std::min(pageBytes, end - offset)));
|
||||
append(block);
|
||||
offset += block.size();
|
||||
}
|
||||
};
|
||||
uint64_t offset{};
|
||||
for (const auto &[page, change] : changes_)
|
||||
{
|
||||
copyUntil(offset, boundary(page));
|
||||
append(encode(read(spool_, change.offset, change.size)));
|
||||
offset = boundary(page + 1);
|
||||
}
|
||||
copyUntil(offset, size_);
|
||||
if (!FlushFileBuffers(output.handle)) fail("Flush saved file");
|
||||
CloseHandle(output.release());
|
||||
|
||||
// A metadata-only handle survives the rename without blocking ReplaceFile's exclusive open.
|
||||
File replacement{CreateFileW(pending.path.c_str(), 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
|
||||
if (replacement.handle == INVALID_HANDLE_VALUE) fail("Track replacement file");
|
||||
File current{openSource(path_, true)};
|
||||
if (!sameFile(source_, current.handle)) throw std::runtime_error("The file was replaced outside KSwitcher.");
|
||||
const auto backup = temporaryPath(path_.parent_path());
|
||||
if (!ReplaceFileW(path_.c_str(), pending.path.c_str(), backup.c_str(), 0, nullptr, nullptr))
|
||||
{
|
||||
const auto error = GetLastError();
|
||||
if (GetFileAttributesW(backup.c_str()) != INVALID_FILE_ATTRIBUTES &&
|
||||
!MoveFileExW(backup.c_str(), path_.c_str(), MOVEFILE_WRITE_THROUGH))
|
||||
{
|
||||
const auto location = backup.u8string();
|
||||
throw std::runtime_error("Could not restore the original name after a save error. The original is at " +
|
||||
std::string(location.begin(), location.end()));
|
||||
}
|
||||
SetLastError(error);
|
||||
fail("Replace original file");
|
||||
}
|
||||
pending.path.clear();
|
||||
const auto newSource = ReOpenFile(replacement.handle, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, 0);
|
||||
DeleteFileW(backup.c_str());
|
||||
CloseHandle(source_);
|
||||
source_ = newSource;
|
||||
size_ = written;
|
||||
changes_.clear();
|
||||
if (spool_ != INVALID_HANDLE_VALUE) CloseHandle(std::exchange(spool_, INVALID_HANDLE_VALUE));
|
||||
spoolSize_ = 0;
|
||||
return source_ != INVALID_HANDLE_VALUE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ks::files
|
||||
{
|
||||
class TextDocument
|
||||
{
|
||||
public:
|
||||
static constexpr uint64_t pageBytes = 1024 * 1024;
|
||||
static constexpr size_t editLimit = 8 * pageBytes;
|
||||
|
||||
explicit TextDocument(std::filesystem::path path, bool editable = true);
|
||||
~TextDocument();
|
||||
TextDocument(const TextDocument &) = delete;
|
||||
TextDocument &operator=(const TextDocument &) = delete;
|
||||
|
||||
uint64_t pages() const;
|
||||
uint64_t size() const { return size_; }
|
||||
uint64_t boundary(uint64_t page) const;
|
||||
std::string readPage(uint64_t page) const;
|
||||
void stage(uint64_t page, std::string_view utf8);
|
||||
bool modified() const { return !changes_.empty(); }
|
||||
bool modifiedWith(uint64_t page, std::string_view current) const;
|
||||
bool save();
|
||||
const wchar_t *encodingName() const;
|
||||
|
||||
private:
|
||||
enum class Encoding { utf8, utf16le, utf16be };
|
||||
struct Change { uint64_t offset; size_t size; };
|
||||
|
||||
std::filesystem::path path_;
|
||||
HANDLE source_ = INVALID_HANDLE_VALUE;
|
||||
HANDLE spool_ = INVALID_HANDLE_VALUE;
|
||||
uint64_t size_{}, spoolSize_{};
|
||||
size_t bom_{};
|
||||
Encoding encoding_ = Encoding::utf8;
|
||||
bool editable_;
|
||||
std::map<uint64_t, Change> changes_;
|
||||
|
||||
std::string decode(std::string bytes) const;
|
||||
std::string readOriginal(uint64_t page) const;
|
||||
std::string encode(std::string_view utf8) const;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
#include "files_text_language.h"
|
||||
#include "core.h"
|
||||
|
||||
#include <Sci_Position.h>
|
||||
#include <ILexer.h>
|
||||
#include <Lexilla.h>
|
||||
#include <Scintilla.h>
|
||||
#include <SciLexer.h>
|
||||
#include <algorithm>
|
||||
#include <cwctype>
|
||||
|
||||
namespace ks::files
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr char cpp[] = "alignas alignof asm auto bool break case catch char char8_t char16_t char32_t class concept const "
|
||||
"consteval constexpr constinit const_cast continue co_await co_return co_yield decltype default delete do double "
|
||||
"dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace "
|
||||
"new noexcept nullptr operator private protected public register reinterpret_cast requires return short signed "
|
||||
"sizeof static static_assert static_cast struct switch template this thread_local throw true try typedef typeid "
|
||||
"typename union unsigned using virtual void volatile wchar_t while import module";
|
||||
constexpr char csharp[] = "abstract as async await base bool break byte case catch char checked class const continue "
|
||||
"decimal default delegate do double else enum event explicit extern false file finally fixed float for foreach from "
|
||||
"get global goto if implicit in init int interface internal is let lock long namespace new not null object operator "
|
||||
"or out override partial private protected public readonly record ref required return sbyte sealed select set short "
|
||||
"sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unmanaged unsafe "
|
||||
"ushort using value var virtual void volatile when where while with yield";
|
||||
constexpr char javascript[] = "abstract any as async await boolean break case catch class const continue debugger declare "
|
||||
"default delete do else enum export extends false finally for from function get if implements import in infer "
|
||||
"instanceof interface keyof let module namespace never new null number object of override private protected public "
|
||||
"readonly return satisfies set static string super switch symbol this throw true try type typeof undefined unknown "
|
||||
"using var void while with yield";
|
||||
constexpr char java[] = "abstract assert boolean break byte case catch char class const continue default do double else "
|
||||
"enum exports extends final finally float for if implements import instanceof int interface long module native new "
|
||||
"non-sealed null open opens package permits private protected public record requires return sealed short static super "
|
||||
"switch synchronized this throw throws transient true try var void volatile while yield false";
|
||||
constexpr char sql[] = "add alter and any as asc begin between by case check column commit constraint create cross "
|
||||
"database declare default delete desc distinct drop else end except exists fetch foreign from full function grant "
|
||||
"group having in index inner insert intersect into is join left like limit merge not null offset on or order outer "
|
||||
"over partition primary procedure references returning right rollback row select set table then top transaction "
|
||||
"truncate union unique update using values view when where window with";
|
||||
constexpr char python[] = "and as assert async await break class continue def del elif else except False finally for from "
|
||||
"global if import in is lambda match case None nonlocal not or pass raise return True try while with yield";
|
||||
constexpr char ps[] = "begin break catch class continue data define do dynamicparam else elseif end enum exit filter finally "
|
||||
"for foreach from function if in param process return switch throw trap try until using var while workflow";
|
||||
constexpr char shell[] = "alias bg break case cd continue declare do done echo elif else esac eval exec exit export "
|
||||
"false fi for function if in local read readonly return select set shift source test then time trap true type "
|
||||
"typeset ulimit umask unalias unset until wait while";
|
||||
constexpr char rust[] = "as async await break const continue crate dyn else enum extern false fn for if impl in let loop "
|
||||
"match mod move mut pub ref return self Self static struct super trait true type unsafe use where while";
|
||||
constexpr char go[] = "break case chan const continue default defer else fallthrough for func go goto if import interface "
|
||||
"map package range return select struct switch type var nil false true";
|
||||
constexpr char lua[] = "and break do else elseif end false for function goto if in local nil not or repeat return then true until while";
|
||||
constexpr char ruby[] = "BEGIN END alias and begin break case class def defined do else elsif end ensure false for if in "
|
||||
"module next nil not or redo rescue retry return self super then true undef unless until when while yield";
|
||||
constexpr char html[] = "a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption "
|
||||
"cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer "
|
||||
"form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link main map mark "
|
||||
"menu meta meter nav noscript object ol optgroup option output p picture pre progress q rp rt ruby s samp script "
|
||||
"search section select slot small source span strong style sub summary sup table tbody td template textarea tfoot "
|
||||
"th thead time title tr track u ul var video wbr accept action alt aria-label async cellpadding charset checked cite "
|
||||
"class content controls defer disabled for height href http-equiv id lang maxlength method multiple name placeholder "
|
||||
"readonly rel required role rows scope selected src srcset style tabindex target type value width";
|
||||
|
||||
struct Mapping
|
||||
{
|
||||
std::wstring_view names;
|
||||
const char *lexer;
|
||||
const char *keywords = "";
|
||||
};
|
||||
constexpr Mapping languages[] = {
|
||||
{L".c .h .cc .cpp .cxx .hh .hpp .hxx .inl .ipp .ixx .cppm .cu .cuh .m .mm .rc .idl .ino", "cpp", cpp},
|
||||
{L".cs .csx .razor.cs", "cpp", csharp},
|
||||
{L".js .jsx .mjs .cjs .ts .tsx .mts .cts .es", "cpp", javascript},
|
||||
{L".java .pde", "cpp", java},
|
||||
{L".kt .kts", "cpp", "abstract actual as break by catch class companion const constructor continue data do else enum "
|
||||
"expect false finally for fun if import in init interface internal is lateinit null object open operator out "
|
||||
"override package private protected public return sealed super suspend this throw true try typealias val var when while"},
|
||||
{L".glsl .vert .frag .geom .hlsl .fx .metal", "cpp", cpp},
|
||||
{L".html .htm .xhtml .shtml .vue .svelte .astro .cshtml .razor .aspx .jsp .php .phtml", "hypertext", html},
|
||||
{L".xml .xsl .xslt .xsd .svg .resx .xaml .csproj .fsproj .vbproj .vcxproj .props .targets .config .nuspec "
|
||||
L".wixproj .wxs .plist .rss .atom .gpx .kml .ui .iml .wsdl .manifest .slnx .axaml", "xml"},
|
||||
{L".css .scss .sass .less", "css", "color background display position width height margin padding border font "
|
||||
"grid flex content gap box-shadow transform animation opacity overflow text-align align-items justify-content"},
|
||||
{L".sql .psql .ddl .dml .mysql", "sql", sql},
|
||||
{L".py .pyw .pyi .rpy sconstruct sconscript", "python", python},
|
||||
{L".json .jsonc .json5 .ipynb .webmanifest .geojson .topojson .code-workspace .sublime-settings .sublime-project",
|
||||
"json", "false null true Infinity NaN"},
|
||||
{L".yaml .yml .eyaml .clang-format .clang-tidy", "yaml", "true false null yes no on off"},
|
||||
{L".toml pipfile uv.lock cargo.lock poetry.lock", "toml", "true false"},
|
||||
{L".md .markdown .mdown .mkd .mdx readme", "markdown"},
|
||||
{L".adoc .asciidoc", "asciidoc"},
|
||||
{L".ini .cfg .conf .properties .url .inf .reg .editorconfig .gitconfig .gitattributes .gitmodules .npmrc .env", "props"},
|
||||
{L".sh .bash .zsh .ksh .bats .bashrc .bash_profile .zshrc .profile configure gradlew mvnw", "bash", shell},
|
||||
{L".ps1 .psm1 .psd1", "powershell", ps},
|
||||
{L".bat .cmd", "batch", "call cd chdir cls copy del dir echo endlocal exit for goto if md mkdir move path pause popd "
|
||||
"pushd rd rem ren rename set setlocal shift start title type"},
|
||||
{L".cmake cmakelists.txt", "cmake", "add_executable add_library add_subdirectory cmake_minimum_required "
|
||||
"configure_file else elseif endforeach endfunction endif endwhile file find_package foreach function if include "
|
||||
"install list message option project set string target_compile_definitions target_include_directories "
|
||||
"target_link_libraries unset while"},
|
||||
{L".mk .mak makefile gnumakefile", "makefile"},
|
||||
{L".rs", "rust", rust}, {L".go", "cpp", go}, {L".lua", "lua", lua}, {L".rb .rake .gemspec gemfile rakefile", "ruby", ruby},
|
||||
{L".dart", "dart", "abstract as assert async await break case class const continue default do else enum extends "
|
||||
"false final finally for if implements import in interface late mixin new null on override return sealed static "
|
||||
"super switch this throw true try typedef var void while with yield"},
|
||||
{L".swift", "cpp", "actor as associatedtype await break case catch class continue default defer deinit do else "
|
||||
"enum extension false for func guard if import in init let nil private protocol public return self some "
|
||||
"static struct super switch throw throws true try typealias var where while"},
|
||||
{L".zig", "zig"}, {L".d .di", "d"}, {L".nim .nims .nimble", "nim"},
|
||||
{L".fs .fsi .fsx", "fsharp"}, {L".ml .mli", "caml"}, {L".sml .sig", "SML"},
|
||||
{L".vb .bas .frm .cls", "vb", "as byref byval class const dim each else elseif end enum for function if implements "
|
||||
"imports inherits interface module new next nothing private property public return select shared sub then try while"},
|
||||
{L".vbs", "vbscript"}, {L".pl .pm .t", "perl"}, {L".r .rprofile", "r"},
|
||||
{L".jl", "julia"}, {L".matlab", "matlab"}, {L".oct", "octave"},
|
||||
{L".tex .sty .ltx", "latex"}, {L".bib", "bib"}, {L".hs", "haskell"}, {L".lhs", "literatehaskell"},
|
||||
{L".lisp .lsp .cl .el .scm .ss .rkt", "lisp"},
|
||||
{L".s .asm .nasm .inc", "asm"}, {L".a68k", "a68k"},
|
||||
{L".v .vh .sv .svh", "verilog"}, {L".vhd .vhdl", "vhdl"},
|
||||
{L".f .for .f77", "f77"}, {L".f90 .f95 .f03 .f08", "fortran"},
|
||||
{L".ada .adb .ads", "ada"}, {L".pas .pp .dpr", "pascal"}, {L".cob .cbl .cpy", "COBOL"},
|
||||
{L".tcl .tk", "tcl"}, {L".coffee .litcoffee", "coffeescript"}, {L".au3", "au3"},
|
||||
{L".st", "smalltalk"}, {L".erl .hrl", "erlang"},
|
||||
{L".purs", "haskell"}, {L".diff .patch", "diff"}, {L".po .pot", "po"},
|
||||
{L".iss", "inno"}, {L".nsi .nsh", "nsis"}, {L".idl3 .odl", "cpp", cpp},
|
||||
{L".dmap", "DMAP"}, {L".dns .zone", "props"}, {L".ihex .hex", "ihex"}, {L".srec .s19 .s28 .s37", "srec"},
|
||||
{L".edifact .edi", "edifact"}, {L".x12", "x12"}, {L".g4", "cpp", java},
|
||||
{L".nix", "nix"}, {L".gd", "gdscript", python}, {L".il", "cil"}, {L".as", "cpp", javascript},
|
||||
{L".frt .fth", "forth"}, {L".e .eif", "eiffel"}, {L".pov .povray", "pov"},
|
||||
{L".rebol .reb", "rebol"}, {L".asn .asn1", "asn1"}, {L".asy", "asy"},
|
||||
{L".raku .rakumod .rakutest .p6 .pm6", "raku"}, {L".bb", "blitzbasic"}, {L".bi", "freebasic"},
|
||||
{L".t2t", "txt2tags"}, {L".troff .roff .man", "troff"},
|
||||
{L".clw", "clarion"}, {L".spice .cir", "spice"},
|
||||
};
|
||||
|
||||
bool containsWord(std::wstring_view words, std::wstring_view key)
|
||||
{
|
||||
if (key.empty()) return false;
|
||||
for (size_t start = 0; start < words.size();)
|
||||
{
|
||||
const auto end = words.find(L' ', start);
|
||||
if (words.substr(start, end == words.npos ? end : end - start) == key) return true;
|
||||
if (end == words.npos) break;
|
||||
start = end + 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
TextLanguage detectLanguage(const std::filesystem::path &path, std::string_view firstPage)
|
||||
{
|
||||
auto filename = path.filename().wstring();
|
||||
auto extension = path.extension().wstring();
|
||||
for (auto *value : {&filename, &extension})
|
||||
std::transform(value->begin(), value->end(), value->begin(), [](wchar_t ch) { return std::towlower(ch); });
|
||||
for (const auto &language : languages)
|
||||
if (containsWord(language.names, filename) || containsWord(language.names, extension))
|
||||
return {language.lexer, language.keywords};
|
||||
if (filename.starts_with(L".env.") || extension == L".env") return {"props"};
|
||||
if (firstPage.starts_with("<?xml")) return {"xml"};
|
||||
if (firstPage.starts_with("#!"))
|
||||
{
|
||||
const auto line = firstPage.substr(0, firstPage.find_first_of("\r\n"));
|
||||
for (const auto &[needle, language] : {std::pair{"python", TextLanguage{"python", python}},
|
||||
{"ruby", {"ruby", ruby}}, {"node", {"cpp", javascript}}, {"perl", {"perl"}},
|
||||
{"pwsh", {"powershell", ps}}, {"lua", {"lua", lua}}, {"sh", {"bash", shell}}})
|
||||
if (line.find(needle) != line.npos) return language;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::wstring textLanguageLabel(const TextLanguage &language)
|
||||
{
|
||||
if (language.keywords == csharp) return L"C#";
|
||||
if (language.keywords == javascript) return L"JavaScript / TypeScript";
|
||||
if (language.keywords == java) return L"Java";
|
||||
if (language.keywords == go) return L"Go";
|
||||
if (language.lexer == "cpp") return L"C-family";
|
||||
if (language.lexer == "hypertext") return L"HTML";
|
||||
return language.lexer.empty() ? L"Plain text" : wide(language.lexer);
|
||||
}
|
||||
|
||||
Scintilla::ILexer5 *createTextLexer(const TextLanguage &language)
|
||||
{
|
||||
if (language.lexer.empty()) return nullptr;
|
||||
auto *lexer = CreateLexer(language.lexer.c_str());
|
||||
if (!lexer) return nullptr;
|
||||
const auto *keywords = language.keywords;
|
||||
if (!*keywords)
|
||||
for (const auto &mapping : languages)
|
||||
if (language.lexer == mapping.lexer && *mapping.keywords)
|
||||
{
|
||||
keywords = mapping.keywords;
|
||||
break;
|
||||
}
|
||||
if (*keywords) lexer->WordListSet(0, keywords);
|
||||
if (language.lexer == "hypertext") lexer->WordListSet(1, javascript);
|
||||
if (language.lexer == "json") lexer->PropertySet("lexer.json.allow.comments", "1");
|
||||
if (language.lexer == "cpp" && (language.keywords == javascript || language.keywords == go))
|
||||
{
|
||||
lexer->PropertySet("lexer.cpp.backquoted.strings", language.keywords == javascript ? "2" : "1");
|
||||
if (language.keywords == javascript) lexer->PropertySet("lexer.cpp.regex", "1");
|
||||
}
|
||||
if (language.lexer == "cpp" && language.keywords == csharp)
|
||||
lexer->PropertySet("lexer.cpp.triplequoted.strings", "1");
|
||||
return lexer;
|
||||
}
|
||||
|
||||
TextColor syntaxColor(Scintilla::ILexer5 &lexer, std::string_view name, int style)
|
||||
{
|
||||
if (!style || (style >= STYLE_DEFAULT && style <= STYLE_LASTPREDEFINED)) return TextColor::text;
|
||||
const auto *styleTags = lexer.TagsOfStyle(style);
|
||||
const auto *styleName = lexer.NameOfStyle(style);
|
||||
const std::string_view tags = styleTags ? styleTags : "";
|
||||
const std::string_view token = styleName ? styleName : "";
|
||||
const auto has = [&](std::string_view word) {
|
||||
return tags.find(word) != tags.npos || token.find(word) != token.npos;
|
||||
};
|
||||
if (has("comment")) return TextColor::comment;
|
||||
if (has("string") || has("character") || has("quoted") || has("regex")) return TextColor::string;
|
||||
if (has("number") || has("numeric")) return TextColor::number;
|
||||
if (has("keyword") || has("command") || has("tag") || has("heading") || has("header") ||
|
||||
has("preprocessor") || has("keywordclass")) return TextColor::keyword;
|
||||
if (has("operator") || has("attribute") || has("property") || has("variable") || has("label"))
|
||||
return TextColor::symbol;
|
||||
if (name == "markdown")
|
||||
{
|
||||
if (style >= SCE_MARKDOWN_HEADER1 && style <= SCE_MARKDOWN_HEADER6) return TextColor::keyword;
|
||||
if (style >= SCE_MARKDOWN_CODE && style <= SCE_MARKDOWN_CODEBK) return TextColor::string;
|
||||
return TextColor::symbol;
|
||||
}
|
||||
if (name == "diff")
|
||||
{
|
||||
if (style == SCE_DIFF_ADDED) return TextColor::string;
|
||||
if (style == SCE_DIFF_DELETED) return TextColor::number;
|
||||
if (style == SCE_DIFF_COMMENT) return TextColor::comment;
|
||||
return TextColor::keyword;
|
||||
}
|
||||
if (name == "sql")
|
||||
{
|
||||
if (style >= SCE_SQL_COMMENT && style <= SCE_SQL_COMMENTDOC) return TextColor::comment;
|
||||
if (style == SCE_SQL_NUMBER) return TextColor::number;
|
||||
if (style == SCE_SQL_WORD || style == SCE_SQL_WORD2) return TextColor::keyword;
|
||||
if (style == SCE_SQL_STRING || style == SCE_SQL_CHARACTER) return TextColor::string;
|
||||
}
|
||||
if (name == "json")
|
||||
{
|
||||
if (style == SCE_JSON_LINECOMMENT || style == SCE_JSON_BLOCKCOMMENT) return TextColor::comment;
|
||||
if (style == SCE_JSON_STRING || style == SCE_JSON_STRINGEOL) return TextColor::string;
|
||||
if (style == SCE_JSON_NUMBER) return TextColor::number;
|
||||
if (style == SCE_JSON_KEYWORD || style == SCE_JSON_LDKEYWORD) return TextColor::keyword;
|
||||
}
|
||||
if (name == "css")
|
||||
{
|
||||
if (style == SCE_CSS_COMMENT) return TextColor::comment;
|
||||
if (style == SCE_CSS_DOUBLESTRING || style == SCE_CSS_SINGLESTRING || style == SCE_CSS_VALUE)
|
||||
return TextColor::string;
|
||||
if (style == SCE_CSS_TAG || style == SCE_CSS_DIRECTIVE) return TextColor::keyword;
|
||||
}
|
||||
if (name == "yaml" || name == "toml")
|
||||
{
|
||||
// The two lexers assign the same IDs to these categories.
|
||||
if (style == SCE_YAML_COMMENT) return TextColor::comment;
|
||||
if (style == SCE_YAML_KEYWORD) return TextColor::keyword;
|
||||
if (style == SCE_YAML_NUMBER) return TextColor::number;
|
||||
}
|
||||
if (token.empty()) return TextColor::symbol;
|
||||
return TextColor::text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace Scintilla { class ILexer5; }
|
||||
|
||||
namespace ks::files
|
||||
{
|
||||
enum class TextColor { background, text, chromebg, chrome, selection, comment, keyword, string, number, symbol,
|
||||
menuText, menuHighlight, menuHighlightText, menuAccessKey, count };
|
||||
|
||||
struct TextLanguage
|
||||
{
|
||||
std::string lexer;
|
||||
const char *keywords = "";
|
||||
};
|
||||
|
||||
TextLanguage detectLanguage(const std::filesystem::path &path, std::string_view firstPage);
|
||||
std::wstring textLanguageLabel(const TextLanguage &language);
|
||||
Scintilla::ILexer5 *createTextLexer(const TextLanguage &language);
|
||||
TextColor syntaxColor(Scintilla::ILexer5 &lexer, std::string_view name, int style);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ks::files
|
||||
{
|
||||
inline constexpr size_t documentColorCount = 10;
|
||||
using TextPalette = std::array<COLORREF, 14>;
|
||||
inline constexpr const wchar_t *textPaletteNames[] = {L"FAR (blue)", L"Dark", L"Light"};
|
||||
inline constexpr TextPalette textPalettes[] = {
|
||||
{RGB(0,0,128), RGB(215,230,255), RGB(0,128,128), RGB(255,255,255), RGB(0,115,115),
|
||||
RGB(143,195,157), RGB(255,235,118), RGB(165,235,255), RGB(255,181,228), RGB(155,214,255),
|
||||
RGB(0,0,0), RGB(0,0,0), RGB(255,255,255), RGB(255,255,0)},
|
||||
{RGB(29,33,38), RGB(234,237,241), RGB(45,60,70), RGB(255,255,255), RGB(42,86,127),
|
||||
RGB(155,185,150), RGB(145,201,255), RGB(241,186,143), RGB(208,170,255), RGB(152,226,218),
|
||||
RGB(255,255,255), RGB(42,86,127), RGB(255,255,255), RGB(255,255,0)},
|
||||
{RGB(255,255,255), RGB(28,35,46), RGB(221,231,237), RGB(25,38,54), RGB(74,104,146),
|
||||
RGB(45,111,70), RGB(103,40,154), RGB(152,52,30), RGB(35,82,156), RGB(0,99,109),
|
||||
RGB(0,0,0), RGB(189,207,223), RGB(0,0,0), RGB(111,77,0)},
|
||||
};
|
||||
inline constexpr const wchar_t *textColorNames[] = {L"Background", L"Text", L"Status background", L"Status text",
|
||||
L"Selection", L"Comment", L"Keyword", L"String", L"Number", L"Symbol",
|
||||
L"Top menu text", L"Selected menu background", L"Selected menu text", L"Menu access key"};
|
||||
|
||||
struct TextPreferences
|
||||
{
|
||||
bool wrap{}, numbers{true}, whitespace{}, tabsAsSpaces{}, autoIndent{true}, caretLine{true};
|
||||
int tabWidth{4}, lineEnding{};
|
||||
std::wstring scheme = textPaletteNames[0];
|
||||
TextPalette colors = textPalettes[0];
|
||||
bool operator==(const TextPreferences &) const = default;
|
||||
};
|
||||
|
||||
// Index 0 is Viewer and index 1 is Editor.
|
||||
using TextPreferencesSet = std::array<TextPreferences, 2>;
|
||||
struct NamedTextPalette
|
||||
{
|
||||
std::wstring name;
|
||||
TextPalette colors;
|
||||
bool operator==(const NamedTextPalette &) const = default;
|
||||
};
|
||||
inline constexpr size_t maxTextPalettes = 48;
|
||||
inline constexpr size_t maxTextPaletteName = 48;
|
||||
struct TextSettings
|
||||
{
|
||||
TextPreferencesSet profiles;
|
||||
std::vector<NamedTextPalette> schemes;
|
||||
bool operator==(const TextSettings &) const = default;
|
||||
};
|
||||
TextSettings loadTextPreferences();
|
||||
void saveTextPreferences(const TextSettings &settings);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
|
||||
namespace ks::lan { struct FilesPeer; }
|
||||
|
||||
namespace ks::files
|
||||
{
|
||||
inline constexpr int searchOptionsDivider = 104;
|
||||
inline constexpr int searchButtonsDivider = 105;
|
||||
inline constexpr int searchHistoryButton = 108;
|
||||
inline constexpr wchar_t farDropDownProperty[]=L"KSwitcher.Files.ActiveDropDown";
|
||||
|
||||
struct TextWindowFont
|
||||
{
|
||||
LOGFONTW font;
|
||||
int tenthsOfPoint;
|
||||
};
|
||||
|
||||
struct FarDropDownPalette
|
||||
{
|
||||
COLORREF surface, text, selection;
|
||||
};
|
||||
|
||||
enum class FarHistoryKind { search, folder };
|
||||
struct FarHistoryEntry { std::wstring date,text; };
|
||||
std::vector<FarHistoryEntry> loadFarHistory(FarHistoryKind kind);
|
||||
void rememberFarHistory(FarHistoryKind kind,std::wstring_view text);
|
||||
int chooseFarDropDown(HWND anchor,const std::vector<FarHistoryEntry> &entries,int selected,
|
||||
FarDropDownPalette palette,int rightInset=0);
|
||||
void showFarHistory(HWND dialog,int fieldId,const std::vector<FarHistoryEntry> &history,
|
||||
FarDropDownPalette palette);
|
||||
void installFarHistoryKey(HWND field,int arrowId);
|
||||
|
||||
struct TextDialogs
|
||||
{
|
||||
INT_PTR (*confirmSave)(HWND);
|
||||
INT_PTR (*paint)(HWND, UINT, WPARAM, LPARAM, const wchar_t *);
|
||||
INT_PTR (*paintSearch)(HWND, UINT, WPARAM, LPARAM);
|
||||
void (*prepare)(HWND);
|
||||
void (*options)(HWND, bool);
|
||||
void (*message)(HWND, const wchar_t *);
|
||||
TextWindowFont (*readFont)();
|
||||
FarDropDownPalette (*dropDownPalette)();
|
||||
INT_PTR (*run)(HINSTANCE,LPCWSTR,HWND,DLGPROC,LPARAM);
|
||||
void (*finish)(HWND,INT_PTR);
|
||||
bool (*dismissOwned)(HWND);
|
||||
bool (*confirmExisting)(HWND);
|
||||
};
|
||||
|
||||
void openTextWindow(HWND owner,const std::filesystem::path &path,bool edit,TextDialogs dialogs,
|
||||
const lan::FilesPeer *peer=nullptr);
|
||||
void textWindowOwnerClosed();
|
||||
}
|
||||
+86
-4
@@ -2,8 +2,11 @@
|
||||
#include <ws2tcpip.h>
|
||||
#include <shellapi.h>
|
||||
#include <shlobj.h>
|
||||
#include <winioctl.h>
|
||||
#include <atomic>
|
||||
#include <bcrypt.h>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include "lan_files.h"
|
||||
#include "lan_channel.h"
|
||||
@@ -52,6 +55,44 @@ bool recycleFile(HWND owner,const fs::path &path)
|
||||
|
||||
namespace
|
||||
{
|
||||
DWORD createJunction(const fs::path &name,const fs::path &target)
|
||||
{
|
||||
if(!target.is_absolute() || target.root_name().native().size()!=2) return ERROR_INVALID_NAME;
|
||||
const auto print=target.lexically_normal().native();
|
||||
const auto substitute=L"\\??\\"+print;
|
||||
struct MountPointHeader
|
||||
{
|
||||
DWORD tag;
|
||||
WORD dataLength,reserved,substituteOffset,substituteLength,printOffset,printLength;
|
||||
};
|
||||
const auto substituteBytes=substitute.size()*sizeof(wchar_t);
|
||||
const auto printBytes=print.size()*sizeof(wchar_t);
|
||||
const auto pathBytes=substituteBytes+printBytes+2*sizeof(wchar_t);
|
||||
if(sizeof(MountPointHeader)+pathBytes>MAXIMUM_REPARSE_DATA_BUFFER_SIZE) return ERROR_FILENAME_EXCED_RANGE;
|
||||
const MountPointHeader header{IO_REPARSE_TAG_MOUNT_POINT,static_cast<WORD>(8+pathBytes),0,0,
|
||||
static_cast<WORD>(substituteBytes),static_cast<WORD>(substituteBytes+sizeof(wchar_t)),
|
||||
static_cast<WORD>(printBytes)};
|
||||
std::vector<char> buffer(sizeof(header)+pathBytes);
|
||||
std::memcpy(buffer.data(),&header,sizeof(header));
|
||||
std::memcpy(buffer.data()+sizeof(header),substitute.c_str(),substituteBytes);
|
||||
std::memcpy(buffer.data()+sizeof(header)+header.printOffset,print.c_str(),printBytes);
|
||||
|
||||
if(!CreateDirectoryW(name.c_str(),nullptr)) return GetLastError();
|
||||
const auto handle=CreateFileW(name.c_str(),GENERIC_WRITE,0,nullptr,OPEN_EXISTING,
|
||||
FILE_FLAG_BACKUP_SEMANTICS|FILE_FLAG_OPEN_REPARSE_POINT,nullptr);
|
||||
DWORD error=ERROR_SUCCESS;
|
||||
if(handle==INVALID_HANDLE_VALUE) error=GetLastError();
|
||||
else
|
||||
{
|
||||
DWORD returned{};
|
||||
if(!DeviceIoControl(handle,FSCTL_SET_REPARSE_POINT,buffer.data(),static_cast<DWORD>(buffer.size()),
|
||||
nullptr,0,&returned,nullptr)) error=GetLastError();
|
||||
CloseHandle(handle);
|
||||
}
|
||||
if(error) RemoveDirectoryW(name.c_str());
|
||||
return error;
|
||||
}
|
||||
|
||||
constexpr size_t FrameLimit=256*1024;
|
||||
constexpr DWORD SocketTimeout=5000;
|
||||
|
||||
@@ -449,7 +490,7 @@ public:
|
||||
discard(true);
|
||||
return failure(error?error:ERROR_WRITE_FAULT);
|
||||
}
|
||||
if(action=="commit")
|
||||
if(action=="commit" || action=="commitEdit")
|
||||
{
|
||||
if(output.value==INVALID_HANDLE_VALUE) return failure(ERROR_INVALID_HANDLE);
|
||||
LARGE_INTEGER size{};
|
||||
@@ -459,6 +500,26 @@ public:
|
||||
const FILETIME time{static_cast<DWORD>(stamp),static_cast<DWORD>(stamp>>32)};
|
||||
if(!SetFileTime(output.value,nullptr,nullptr,&time) || !FlushFileBuffers(output.value)) return failure();
|
||||
output.close();
|
||||
static std::mutex publicationMutex;
|
||||
const std::lock_guard publication(publicationMutex);
|
||||
if(action=="commitEdit")
|
||||
{
|
||||
WIN32_FILE_ATTRIBUTE_DATA existing{};
|
||||
if(!GetFileAttributesExW(target.c_str(),GetFileExInfoStandard,&existing) ||
|
||||
(existing.dwFileAttributes&(FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_REPARSE_POINT|FILE_ATTRIBUTE_READONLY)) ||
|
||||
fileSize(existing.nFileSizeHigh,existing.nFileSizeLow)!=request.at("expectedSize").get<uint64_t>() ||
|
||||
fileSize(existing.ftLastWriteTime.dwHighDateTime,existing.ftLastWriteTime.dwLowDateTime)!=
|
||||
request.at("expectedModified").get<uint64_t>())
|
||||
{
|
||||
discard(true);
|
||||
return failure(ERROR_FILE_INVALID);
|
||||
}
|
||||
const auto attributes=FILE_ATTRIBUTE_ARCHIVE|
|
||||
(existing.dwFileAttributes&(FILE_ATTRIBUTE_HIDDEN|FILE_ATTRIBUTE_SYSTEM|
|
||||
FILE_ATTRIBUTE_NOT_CONTENT_INDEXED));
|
||||
if(!SetFileAttributesW(temporary.c_str(),attributes)) return failure();
|
||||
return commit(target,true);
|
||||
}
|
||||
const auto reply=commit(target,request.at("replace").get<bool>());
|
||||
if(reply.at("error")==ERROR_SUCCESS) SetFileAttributesW(target.c_str(),FILE_ATTRIBUTE_ARCHIVE);
|
||||
return reply;
|
||||
@@ -477,7 +538,8 @@ public:
|
||||
{
|
||||
WIN32_FILE_ATTRIBUTE_DATA data{};
|
||||
if(!GetFileAttributesExW(name.c_str(),GetFileExInfoStandard,&data)) return failure();
|
||||
return {{"error",0},{"attributes",data.dwFileAttributes},{"size",fileSize(data.nFileSizeHigh,data.nFileSizeLow)},
|
||||
return {{"error",0},{"attributes",data.dwFileAttributes},{"editCommit",true},
|
||||
{"size",fileSize(data.nFileSizeHigh,data.nFileSizeLow)},
|
||||
{"modified",fileSize(data.ftLastWriteTime.dwHighDateTime,data.ftLastWriteTime.dwLowDateTime)}};
|
||||
}
|
||||
if(action=="canonical")
|
||||
@@ -487,6 +549,20 @@ public:
|
||||
return error?failure(error.value()):Json{{"error",0},{"path",utf8(canonical.native())}};
|
||||
}
|
||||
if(action=="mkdir") return CreateDirectoryW(name.c_str(),nullptr)?ok():failure();
|
||||
if(action=="mkdir-link")
|
||||
{
|
||||
const auto target=path(request.at("target"));
|
||||
if(target.empty()) return failure(ERROR_INVALID_NAME);
|
||||
const auto kind=request.at("kind").get<std::string>();
|
||||
if(kind=="junction")
|
||||
{
|
||||
const auto error=createJunction(name,target);
|
||||
return error?failure(error):ok();
|
||||
}
|
||||
if(kind!="symlink") return failure(ERROR_INVALID_PARAMETER);
|
||||
return CreateSymbolicLinkW(name.c_str(),target.c_str(),
|
||||
SYMBOLIC_LINK_FLAG_DIRECTORY|SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE)?ok():failure();
|
||||
}
|
||||
if(action=="rmdir") return RemoveDirectoryW(name.c_str())?ok():failure();
|
||||
if(action=="recycle") return recycleFile(nullptr,name)?ok():failure(ERROR_ACCESS_DENIED);
|
||||
if(action=="remove")
|
||||
@@ -667,7 +743,7 @@ Json FilesConnection::call(const Json &request,const FileProgress &progress)
|
||||
|
||||
void streamFile(FilesConnection &source,FilesConnection &target,const std::string &sourceId,
|
||||
const std::filesystem::path &from,const std::filesystem::path &to,uint64_t size,uint64_t modified,
|
||||
bool replace,bool append,const FileProgress &progress)
|
||||
bool replace,bool append,const FileProgress &progress,const FileRevision *expectedDestination)
|
||||
{
|
||||
auto openSource=fileRequest("openRead",from);
|
||||
openSource["size"]=size;
|
||||
@@ -705,7 +781,13 @@ void streamFile(FilesConnection &source,FilesConnection &target,const std::strin
|
||||
if(data.empty())
|
||||
{
|
||||
if(done!=size) throw std::system_error(ERROR_FILE_INVALID,std::system_category());
|
||||
checkFileReply(target.call({{"action","commit"},{"replace",replace},{"modified",modified}}));
|
||||
Json commit{{"action",expectedDestination?"commitEdit":"commit"},{"replace",replace},{"modified",modified}};
|
||||
if(expectedDestination)
|
||||
{
|
||||
commit["expectedSize"]=expectedDestination->size;
|
||||
commit["expectedModified"]=expectedDestination->modified;
|
||||
}
|
||||
checkFileReply(target.call(commit));
|
||||
checkFileReply(source.call({{"action","closeRead"}}));
|
||||
return;
|
||||
}
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@ struct FilesPeer
|
||||
bool filesBrowse{};
|
||||
};
|
||||
using FileProgress = std::function<bool(uint64_t, uint64_t)>;
|
||||
struct FileRevision { uint64_t size, modified; };
|
||||
inline constexpr size_t FileChunk = 64 * 1024;
|
||||
|
||||
// One connection owns its enumeration, file handles and uncommitted upload.
|
||||
@@ -29,7 +30,7 @@ public:
|
||||
|
||||
void streamFile(FilesConnection &source,FilesConnection &target,const std::string &sourceId,
|
||||
const std::filesystem::path &from,const std::filesystem::path &to,uint64_t size,uint64_t modified,
|
||||
bool replace,bool append,const FileProgress &progress);
|
||||
bool replace,bool append,const FileProgress &progress,const FileRevision *expectedDestination=nullptr);
|
||||
|
||||
class FilesListener
|
||||
{
|
||||
|
||||
+7
-1
@@ -737,6 +737,7 @@ namespace
|
||||
{
|
||||
std::recursive_mutex logGate;
|
||||
bool loggingEnabled = false;
|
||||
std::wstring logFilename = L"kswitcher.log";
|
||||
DiagnosticBuffer diagnosticBuffer;
|
||||
}
|
||||
size_t DiagnosticBuffer::expire(ULONGLONG now)
|
||||
@@ -832,6 +833,11 @@ void diagnosticOperation(const std::string &json)
|
||||
record["context"] = utf8(diagnosticBuffer.record(L"Operation complete", true, GetTickCount64()));
|
||||
log(L"Operation " + wide(record.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace)));
|
||||
}
|
||||
void setLogFilename(std::wstring filename)
|
||||
{
|
||||
std::lock_guard lock(logGate);
|
||||
logFilename = std::move(filename);
|
||||
}
|
||||
void log(const std::wstring &message)
|
||||
{
|
||||
std::lock_guard lock(logGate);
|
||||
@@ -839,7 +845,7 @@ void log(const std::wstring &message)
|
||||
return;
|
||||
try
|
||||
{
|
||||
auto path = dataDirectory() / L"kswitcher.log";
|
||||
auto path = dataDirectory() / logFilename;
|
||||
std::error_code ec;
|
||||
auto size = std::filesystem::file_size(path, ec);
|
||||
if (!ec && size > 256 * 1024)
|
||||
|
||||
@@ -94,6 +94,7 @@ class DiagnosticBuffer
|
||||
void clear() { entries.clear(); }
|
||||
};
|
||||
void setLoggingEnabled(bool enabled);
|
||||
void setLogFilename(std::wstring filename);
|
||||
void expireDiagnostics(ULONGLONG now);
|
||||
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
||||
void diagnosticOperation(const std::string &json);
|
||||
|
||||
+3
-1
@@ -1514,7 +1514,9 @@ Settings collectSettings(HWND w, SettingsView *v)
|
||||
s.undo = value(w, 213);
|
||||
s.clipboard = value(w, 214);
|
||||
s.screenshot = value(w, 215);
|
||||
s.filesHotkey = value(w, 216);
|
||||
auto filesHotkey = value(w, 216);
|
||||
if (s.filesHotkey || !filesHotkey.empty())
|
||||
s.filesHotkey = std::move(filesHotkey);
|
||||
s.maxItems = integer(w, 240);
|
||||
s.maxMiB = integer(w, 241);
|
||||
s.maxImageMiB = integer(w, 242);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.77";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.85";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
@@ -292,11 +292,16 @@ int main()
|
||||
HWND picker = GetDlgItem(dialog, 728);
|
||||
RECT dialogBounds{};
|
||||
GetWindowRect(dialog, &dialogBounds);
|
||||
MONITORINFO monitor{sizeof(monitor)};
|
||||
require(GetMonitorInfoW(MonitorFromWindow(dialog, MONITOR_DEFAULTTONEAREST), &monitor),
|
||||
"monitor work area available");
|
||||
const auto targetX = std::clamp(dialogBounds.left + 32, monitor.rcWork.left, monitor.rcWork.right - 120);
|
||||
const auto targetY = std::clamp(dialogBounds.top + 100, monitor.rcWork.top, monitor.rcWork.bottom - 48);
|
||||
HWND target = CreateWindowExW(WS_EX_TOPMOST | WS_EX_NOACTIVATE, L"EDIT", L"Picker fixture",
|
||||
WS_POPUP | WS_VISIBLE, dialogBounds.left + 32, dialogBounds.top + 100, 120, 48,
|
||||
WS_POPUP | WS_VISIBLE, targetX, targetY, 120, 48,
|
||||
nullptr, nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||
require(picker && target, "window picker and target available");
|
||||
POINT drop{dialogBounds.left + 33, dialogBounds.top + 101};
|
||||
POINT drop{targetX + 1, targetY + 1};
|
||||
require(GetAncestor(WindowFromPoint(drop), GA_ROOT) == target, "fixture window is under the drop point");
|
||||
ScreenToClient(picker, &drop);
|
||||
SendMessageW(picker, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(8, 8));
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Executable,
|
||||
[Parameter(Mandatory)][string]$Fixture
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class FilesActivationUi {
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct Rect { public int Left, Top, Right, Bottom; }
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct GuiInfo {
|
||||
public int Size, Flags;
|
||||
public IntPtr Active, Focus, Capture, MenuOwner, MoveSize, Caret;
|
||||
public Rect CaretRect;
|
||||
}
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr window, int id);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string type, string title);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window);
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr window, uint msg, IntPtr w, IntPtr l);
|
||||
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, IntPtr process);
|
||||
[DllImport("user32.dll")] static extern bool GetGUIThreadInfo(uint thread, ref GuiInfo info);
|
||||
[DllImport("user32.dll", EntryPoint="SendMessageW")] public static extern IntPtr Send(IntPtr window, uint msg, IntPtr w, IntPtr l);
|
||||
[DllImport("user32.dll", EntryPoint="SendMessageW")] static extern IntPtr SendRect(IntPtr window, uint msg, IntPtr w, out Rect rect);
|
||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr Read(IntPtr window, uint msg, IntPtr w, StringBuilder text);
|
||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr Write(IntPtr window, uint msg, IntPtr w, string text);
|
||||
[DllImport("shell32.dll")] public static extern void SHChangeNotify(int eventId, uint flags, IntPtr first, IntPtr second);
|
||||
|
||||
public static IntPtr Focus(IntPtr window) {
|
||||
var info = new GuiInfo { Size = Marshal.SizeOf(typeof(GuiInfo)) };
|
||||
if (!GetGUIThreadInfo(GetWindowThreadProcessId(window, IntPtr.Zero), ref info))
|
||||
throw new InvalidOperationException("Could not read Files focus.");
|
||||
return info.Focus;
|
||||
}
|
||||
|
||||
public static void ClickRow(IntPtr list, int row, bool doubleClick) {
|
||||
Rect rect;
|
||||
if (SendRect(list, 0x198, new IntPtr(row), out rect).ToInt32() == -1)
|
||||
throw new InvalidOperationException("Missing listbox row.");
|
||||
var point = new IntPtr(((rect.Top + rect.Bottom) / 2 << 16) | (rect.Left + 6));
|
||||
PostMessage(list, 0x201, new IntPtr(1), point);
|
||||
PostMessage(list, 0x202, IntPtr.Zero, point);
|
||||
if (doubleClick) {
|
||||
PostMessage(list, 0x203, new IntPtr(1), point);
|
||||
PostMessage(list, 0x202, IntPtr.Zero, point);
|
||||
}
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
function Wait-Until([string]$Description, [scriptblock]$Condition) {
|
||||
$timer = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($timer.Elapsed.TotalSeconds -lt 15) {
|
||||
if (& $Condition) { return }
|
||||
if ($process -and $process.HasExited) { throw "Files exited while waiting for $Description" }
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
throw "Timed out waiting for $Description"
|
||||
}
|
||||
|
||||
function Read-Text($Control, $Message = 0x0D, $Parameter = 32768) {
|
||||
$buffer = [Text.StringBuilder]::new(32768)
|
||||
[void][FilesActivationUi]::Read($Control, $Message, [IntPtr]$Parameter, $buffer)
|
||||
$buffer.ToString()
|
||||
}
|
||||
|
||||
function Find-Row($List, [string]$Name) {
|
||||
$count = [FilesActivationUi]::Send($List, 0x18B, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32()
|
||||
for ($row = 0; $row -lt $count; $row++) {
|
||||
if ((Read-Text $List 0x189 $row) -ceq $Name) { return $row }
|
||||
}
|
||||
-1
|
||||
}
|
||||
|
||||
function Select-Row($List, [string]$Name, [switch]$DoubleClick) {
|
||||
Wait-Until "row $Name" { (Find-Row $List $Name) -ge 0 }
|
||||
$row = Find-Row $List $Name
|
||||
[void][FilesActivationUi]::Send($List, 0x197, [IntPtr]$row, [IntPtr]::Zero)
|
||||
[FilesActivationUi]::ClickRow($List, $row, $DoubleClick.IsPresent)
|
||||
if (!$DoubleClick) {
|
||||
Wait-Until "selection of $Name" {
|
||||
[FilesActivationUi]::Focus($window) -eq $inputBox -and
|
||||
[FilesActivationUi]::Send($List, 0x188, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32() -eq (Find-Row $List $Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Press-Enter($Control) {
|
||||
[void][FilesActivationUi]::PostMessage($Control, 0x100, [IntPtr]13, [IntPtr]::Zero)
|
||||
}
|
||||
|
||||
function Invoke-Row($List, [string]$Name, [bool]$DoubleClick) {
|
||||
Select-Row $List $Name -DoubleClick:$DoubleClick
|
||||
if (!$DoubleClick) { Press-Enter $inputBox }
|
||||
}
|
||||
|
||||
function Send-Terminal([string]$Command) {
|
||||
[void][FilesActivationUi]::Write($inputBox, 0x0C, [IntPtr]::Zero, $Command)
|
||||
Press-Enter $inputBox
|
||||
}
|
||||
|
||||
function Assert-Open($List, $Target, [bool]$DoubleClick) {
|
||||
$report = "$Target.opened"
|
||||
Remove-Item -LiteralPath $report -ErrorAction SilentlyContinue
|
||||
Invoke-Row $List ([IO.Path]::GetFileName($Target)) $DoubleClick
|
||||
Wait-Until "launch of $Target (double click: $DoubleClick)" {
|
||||
(Test-Path -LiteralPath $report) -and [IO.File]::ReadAllText($report) -eq 'opened'
|
||||
}
|
||||
}
|
||||
|
||||
$unique = [Guid]::NewGuid().ToString('N')
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) "KSwitcher test $unique"
|
||||
$profile = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\KSwitcher\Files')
|
||||
$classes = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Classes')
|
||||
$extension = ".ksfiles$unique"
|
||||
$progId = "KSwitcher.Files.Test.$unique"
|
||||
$saved = @{}
|
||||
$profileValues = 'LeftPath', 'RightPath', 'LeftCursor', 'RightCursor', 'LeftWide', 'RightWide',
|
||||
'ShowDetails', 'StatusFields', 'CommandHistory', 'WindowPlacement'
|
||||
$process = $null
|
||||
$trace = $null
|
||||
try {
|
||||
foreach ($name in $profileValues) {
|
||||
if ($name -in $profile.GetValueNames()) {
|
||||
$saved[$name] = @($profile.GetValue($name), $profile.GetValueKind($name))
|
||||
}
|
||||
}
|
||||
$leftDir = Join-Path $root 'Left panel'
|
||||
$rightDir = Join-Path $root 'Right panel'
|
||||
$app = Join-Path $root 'Application'
|
||||
New-Item -ItemType Directory -Path $leftDir, $rightDir, $app, "$leftDir\Test folder", "$rightDir\Test folder" | Out-Null
|
||||
$source = Split-Path (Resolve-Path -LiteralPath $Executable)
|
||||
$testExe = Join-Path $app 'KSwitcher.Files.exe'
|
||||
Copy-Item -LiteralPath $Executable -Destination $testExe
|
||||
Copy-Item -LiteralPath "$source\WebView2Loader.dll", "$source\terminal" -Destination $app -Recurse
|
||||
$program = Join-Path $leftDir 'Test program.exe'
|
||||
Copy-Item -LiteralPath $Fixture -Destination $program
|
||||
$document = Join-Path $rightDir "Test document$extension"
|
||||
[IO.File]::WriteAllText($document, 'Document fixture')
|
||||
$association = $classes.CreateSubKey($extension)
|
||||
$association.SetValue('', $progId)
|
||||
$association.Dispose()
|
||||
$handler = $classes.CreateSubKey("$progId\shell\open\command")
|
||||
$handler.SetValue('', ('"{0}" "%1"' -f (Resolve-Path -LiteralPath $Fixture).Path))
|
||||
$handler.Dispose()
|
||||
[FilesActivationUi]::SHChangeNotify(0x08000000, 0, [IntPtr]::Zero, [IntPtr]::Zero)
|
||||
|
||||
$batch = Join-Path $leftDir "Test's script.BAT"
|
||||
$cmd = Join-Path $rightDir 'Test script.CMD'
|
||||
[IO.File]::WriteAllLines($batch, @('@echo off', 'echo BAT=%KS_TEST_STATE%'))
|
||||
[IO.File]::WriteAllLines($cmd, @('@echo off', 'echo CMD=%KS_TEST_STATE%'))
|
||||
$profile.SetValue('LeftPath', $leftDir)
|
||||
$profile.SetValue('RightPath', $rightDir)
|
||||
foreach ($name in 'LeftCursor', 'RightCursor') { $profile.DeleteValue($name, $false) }
|
||||
foreach ($name in 'LeftWide', 'RightWide', 'ShowDetails') { $profile.SetValue($name, 1) }
|
||||
$profile.SetValue('StatusFields', 3)
|
||||
|
||||
$process = Start-Process -FilePath $testExe -ArgumentList '--terminal-output-trace' -PassThru
|
||||
$trace = Join-Path ([IO.Path]::GetTempPath()) "KSwitcher.Files.terminal-trace-$($process.Id).log"
|
||||
Wait-Until 'Files window' { $process.Refresh(); $process.MainWindowHandle -ne [IntPtr]::Zero }
|
||||
$window = $process.MainWindowHandle
|
||||
$left = [FilesActivationUi]::GetDlgItem($window, 102)
|
||||
$right = [FilesActivationUi]::GetDlgItem($window, 103)
|
||||
$status = [FilesActivationUi]::GetDlgItem($window, 144)
|
||||
$output = [FilesActivationUi]::GetDlgItem($window, 145)
|
||||
$inputBox = [FilesActivationUi]::FindWindowEx($window, [IntPtr]::Zero, 'Edit', $null)
|
||||
|
||||
Select-Row $left '..'
|
||||
[void][FilesActivationUi]::Send($right, 0x186, [IntPtr]::Zero, [IntPtr]::Zero)
|
||||
[void][FilesActivationUi]::Write($inputBox, 0x0C, [IntPtr]::Zero, 'draft command')
|
||||
[void][FilesActivationUi]::PostMessage($inputBox, 0x100, [IntPtr]9, [IntPtr]::Zero)
|
||||
[void][FilesActivationUi]::PostMessage($inputBox, 0x100, [IntPtr]40, [IntPtr]::Zero)
|
||||
Wait-Until 'Tab and Down navigating the right pane with command-line focus' {
|
||||
[FilesActivationUi]::Focus($window) -eq $inputBox -and
|
||||
[FilesActivationUi]::Send($right, 0x188, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32() -eq 1 -and
|
||||
[FilesActivationUi]::Send($left, 0x188, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32() -eq (Find-Row $left '..')
|
||||
}
|
||||
if ((Read-Text $inputBox) -cne 'draft command') { throw 'Pane navigation changed the command line.' }
|
||||
[void][FilesActivationUi]::Write($inputBox, 0x0C, [IntPtr]::Zero, '')
|
||||
Write-Output 'Tab and Down navigated the panes while preserving command-line focus and text.'
|
||||
|
||||
foreach ($double in $false, $true) {
|
||||
Assert-Open $left $program $double
|
||||
Assert-Open $right $document $double
|
||||
}
|
||||
Invoke-Row $left '[Test folder]' $false
|
||||
Wait-Until 'folder navigation by Enter' {
|
||||
(Read-Text ([FilesActivationUi]::GetDlgItem($window, 142))) -eq "$leftDir\Test folder"
|
||||
}
|
||||
Invoke-Row $left '..' $false
|
||||
Invoke-Row $right '[Test folder]' $true
|
||||
Wait-Until 'folder navigation by double click' {
|
||||
(Read-Text ([FilesActivationUi]::GetDlgItem($window, 143))) -eq "$rightDir\Test folder"
|
||||
}
|
||||
Invoke-Row $right '..' $false
|
||||
Write-Output 'Enter and double-click opened folders, the registered document, and the standalone EXE.'
|
||||
|
||||
$sessionValue = $unique.Substring(0, 8)
|
||||
Send-Terminal ('$global:ksSession = "{0}"; $env:KS_TEST_STATE = $ksSession; "READY=" + $ksSession' -f $sessionValue)
|
||||
Wait-Until 'initialized terminal session and rendered test marker' {
|
||||
(Get-Content -LiteralPath $trace -ErrorAction SilentlyContinue) -contains "READY=$sessionValue"
|
||||
}
|
||||
Invoke-Row $left ([IO.Path]::GetFileName($batch)) $false
|
||||
Wait-Until 'visible BAT output with the existing session environment' {
|
||||
[FilesActivationUi]::IsWindowVisible($output) -and
|
||||
(Get-Content -LiteralPath $trace) -contains "BAT=$sessionValue"
|
||||
}
|
||||
Invoke-Row $right ([IO.Path]::GetFileName($cmd)) $true
|
||||
Wait-Until 'visible CMD output with the existing session environment' {
|
||||
[FilesActivationUi]::IsWindowVisible($output) -and
|
||||
(Get-Content -LiteralPath $trace) -contains "CMD=$sessionValue"
|
||||
}
|
||||
Send-Terminal '"AFTER=" + $global:ksSession'
|
||||
Wait-Until 'unchanged PowerShell session state' {
|
||||
(Get-Content -LiteralPath $trace) -contains "AFTER=$sessionValue"
|
||||
}
|
||||
Assert-Open $left $program $false
|
||||
Write-Output 'BAT and CMD ran in the existing terminal and rendered their output.'
|
||||
|
||||
$external = 'External change.txt'
|
||||
Select-Row $left ([IO.Path]::GetFileName($program))
|
||||
foreach ($dir in $leftDir, $rightDir) { [IO.File]::WriteAllText((Join-Path $dir $external), 'one') }
|
||||
Wait-Until 'external creation in both panels' {
|
||||
(Find-Row $left $external) -ge 0 -and (Find-Row $right $external) -ge 0
|
||||
}
|
||||
if ([FilesActivationUi]::Focus($window) -ne $inputBox -or
|
||||
[FilesActivationUi]::Send($left, 0x188, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32() -ne
|
||||
(Find-Row $left ([IO.Path]::GetFileName($program)))) { throw 'Creation moved focus or selection.' }
|
||||
|
||||
foreach ($panel in @(@{ List = $left; Dir = $leftDir }, @{ List = $right; Dir = $rightDir })) {
|
||||
Select-Row $panel.List $external
|
||||
Wait-Until 'initial file size' { (Read-Text $status) -match ' 3 B$' }
|
||||
[IO.File]::AppendAllText((Join-Path $panel.Dir $external), 'two!')
|
||||
Wait-Until 'updated file size' { (Read-Text $status) -match ' 7 B$' }
|
||||
if ([FilesActivationUi]::Focus($window) -ne $inputBox) { throw 'Modification moved focus.' }
|
||||
}
|
||||
|
||||
foreach ($dir in $leftDir, $rightDir) { [IO.File]::Delete((Join-Path $dir $external)) }
|
||||
Wait-Until 'external deletion in both panels' {
|
||||
(Find-Row $left $external) -eq -1 -and (Find-Row $right $external) -eq -1
|
||||
}
|
||||
if ([FilesActivationUi]::Focus($window) -ne $inputBox) { throw 'Deletion moved focus.' }
|
||||
Write-Output 'Both panels refreshed external creation, modification and deletion while preserving focus.'
|
||||
} finally {
|
||||
if ($process -and !$process.HasExited) {
|
||||
[void]$process.CloseMainWindow()
|
||||
if (!$process.WaitForExit(4000)) { $process.Kill(); $process.WaitForExit() }
|
||||
}
|
||||
foreach ($name in $profileValues) {
|
||||
if ($saved.ContainsKey($name)) { $profile.SetValue($name, $saved[$name][0], $saved[$name][1]) }
|
||||
else { $profile.DeleteValue($name, $false) }
|
||||
}
|
||||
$profile.Dispose()
|
||||
$classes.DeleteSubKeyTree($extension, $false)
|
||||
$classes.DeleteSubKeyTree($progId, $false)
|
||||
$classes.Dispose()
|
||||
[FilesActivationUi]::SHChangeNotify(0x08000000, 0, [IntPtr]::Zero, [IntPtr]::Zero)
|
||||
if ($trace) { Remove-Item -LiteralPath $trace -ErrorAction SilentlyContinue }
|
||||
$resolvedRoot = Resolve-Path -LiteralPath $root -ErrorAction SilentlyContinue
|
||||
$tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
|
||||
if ($resolvedRoot -and $resolvedRoot.Path.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
Remove-Item -LiteralPath $resolvedRoot.Path -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#include <windows.h>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
int wmain(int argc,wchar_t **argv)
|
||||
{
|
||||
// A launch through the embedded terminal would inherit its console.
|
||||
DWORD consoleProcesses[1]{};
|
||||
if(GetConsoleProcessList(consoleProcesses,1)>1) return 1;
|
||||
wchar_t executable[32768]{};
|
||||
const auto length=GetModuleFileNameW(nullptr,executable,static_cast<DWORD>(std::size(executable)));
|
||||
if(!length || length>=std::size(executable)) return 1;
|
||||
const std::filesystem::path target=argc==2?argv[1]:executable;
|
||||
auto report=target;
|
||||
report+=L".opened";
|
||||
std::ofstream result(report,std::ios::binary);
|
||||
result<<"opened";
|
||||
return result?0:1;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#include <windows.h>
|
||||
#include <commctrl.h>
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include "files_text_window.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
#include "files_dialog_input.inl"
|
||||
|
||||
bool cancelled(HWND dialog)
|
||||
{
|
||||
MSG message{};
|
||||
return PeekMessageW(&message,dialog,WM_COMMAND,WM_COMMAND,PM_REMOVE) &&
|
||||
LOWORD(message.wParam)==IDCANCEL;
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
const auto instance=GetModuleHandleW(nullptr);
|
||||
const auto owner=CreateWindowW(L"STATIC",L"Files",WS_OVERLAPPEDWINDOW,
|
||||
0,0,300,200,nullptr,nullptr,instance,nullptr);
|
||||
const auto dialog=CreateWindowW(L"STATIC",L"Search",WS_POPUP,
|
||||
0,0,200,100,owner,nullptr,instance,nullptr);
|
||||
const auto external=CreateWindowW(L"STATIC",L"Other",WS_OVERLAPPEDWINDOW,
|
||||
0,0,300,200,nullptr,nullptr,instance,nullptr);
|
||||
if(!owner || !dialog || !external)
|
||||
{
|
||||
std::cerr << "Could not create modal test windows.\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
POINT client{10,10},frame{10,-1};
|
||||
ClientToScreen(owner,&client);
|
||||
ClientToScreen(owner,&frame);
|
||||
if(handleFilesDialogClick(dialog,external,client) ||
|
||||
handleFilesDialogClick(dialog,GetDesktopWindow(),client) ||
|
||||
handleFilesDialogClick(dialog,nullptr,client) || cancelled(dialog))
|
||||
{
|
||||
std::cerr << "A click outside Files cancelled the dialog.\n";
|
||||
return 1;
|
||||
}
|
||||
if(handleFilesDialogClick(dialog,owner,frame) || cancelled(dialog))
|
||||
{
|
||||
std::cerr << "A click on the owner's frame cancelled the dialog.\n";
|
||||
return 1;
|
||||
}
|
||||
if(!handleFilesDialogClick(dialog,owner,client) || !cancelled(dialog))
|
||||
{
|
||||
std::cerr << "A click in the Files client area did not cancel the dialog.\n";
|
||||
return 1;
|
||||
}
|
||||
DestroyWindow(owner);
|
||||
DestroyWindow(external);
|
||||
std::cout << "Verified Files modal handling of external, frame, and client-area clicks.\n";
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
param([Parameter(Mandatory)][string]$Executable)
|
||||
$ErrorActionPreference='Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public static class SearchArrowTest {
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string name, string title);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr window, out uint pid);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr hwnd, int id);
|
||||
[DllImport("user32.dll", EntryPoint="SendMessageW")] public static extern IntPtr Send(IntPtr hwnd, uint message, IntPtr w, IntPtr l);
|
||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SendText(IntPtr hwnd, uint message, IntPtr w, string l);
|
||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr hwnd, uint message, IntPtr w, StringBuilder l);
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hwnd, uint message, IntPtr w, IntPtr l);
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct Rect { public int Left, Top, Right, Bottom; }
|
||||
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int w, int h, uint flags);
|
||||
[DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
|
||||
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hwnd, IntPtr dc, uint flags);
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hwnd, out Rect rect);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hwnd);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hwnd);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr hwnd,uint command);
|
||||
[DllImport("user32.dll",EntryPoint="GetWindowLongPtrW")] public static extern IntPtr GetWindowStyle(IntPtr hwnd,int index);
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct PropertyKey { public Guid Format; public uint Id; }
|
||||
[StructLayout(LayoutKind.Explicit, Size=24)]
|
||||
struct PropertyValue {
|
||||
[FieldOffset(0)] public ushort Type;
|
||||
[FieldOffset(8)] public IntPtr String;
|
||||
}
|
||||
[ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
interface WindowProperties {
|
||||
[PreserveSig] int GetCount(out uint count);
|
||||
[PreserveSig] int GetAt(uint index, out PropertyKey key);
|
||||
[PreserveSig] int GetValue(ref PropertyKey key, out PropertyValue value);
|
||||
}
|
||||
[DllImport("shell32.dll")] static extern int SHGetPropertyStoreForWindow(IntPtr hwnd, ref Guid iid, out WindowProperties properties);
|
||||
[DllImport("ole32.dll")] static extern int PropVariantClear(ref PropertyValue value);
|
||||
public static string TaskbarIdentity(IntPtr window) {
|
||||
var iid = typeof(WindowProperties).GUID;
|
||||
if (SHGetPropertyStoreForWindow(window, ref iid, out var properties) < 0) return null;
|
||||
try {
|
||||
var key = new PropertyKey { Format = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), Id = 5 };
|
||||
if (properties.GetValue(ref key, out var value) < 0) return null;
|
||||
try { return value.Type == 31 ? Marshal.PtrToStringUni(value.String) : null; }
|
||||
finally { PropVariantClear(ref value); }
|
||||
} finally { Marshal.ReleaseComObject(properties); }
|
||||
}
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr hwnd);
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct GuiThreadInfo {
|
||||
public int Size, Flags;
|
||||
public IntPtr Active, Focus, Capture, MenuOwner, MoveSize, Caret;
|
||||
public Rect CaretBounds;
|
||||
}
|
||||
[DllImport("user32.dll")] static extern bool GetGUIThreadInfo(uint thread, ref GuiThreadInfo info);
|
||||
public static IntPtr FocusedControl(IntPtr window) {
|
||||
var info = new GuiThreadInfo { Size = Marshal.SizeOf<GuiThreadInfo>() };
|
||||
return GetGUIThreadInfo(GetWindowThreadProcessId(window, out _), ref info) ? info.Focus : IntPtr.Zero;
|
||||
}
|
||||
public static bool HasCheckboxCaret(IntPtr checkbox) {
|
||||
var info = new GuiThreadInfo { Size = Marshal.SizeOf<GuiThreadInfo>() };
|
||||
return GetGUIThreadInfo(GetWindowThreadProcessId(checkbox, out _), ref info) &&
|
||||
info.Caret == checkbox && info.CaretBounds.Left > 0 &&
|
||||
info.CaretBounds.Right > info.CaretBounds.Left &&
|
||||
info.CaretBounds.Bottom - info.CaretBounds.Top < info.CaretBounds.Right - info.CaretBounds.Left;
|
||||
}
|
||||
}
|
||||
"@
|
||||
function Wait-Until($Block) {
|
||||
for ($i=0; $i -lt 120; $i++) {
|
||||
$value = & $Block
|
||||
if ($value) { return $value }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
throw "Timeout: $Block"
|
||||
}
|
||||
function Find-Window($Name, $ProcessId, [int]$ControlId=0, [IntPtr]$Except=[IntPtr]::Zero) {
|
||||
$wnd = [IntPtr]::Zero
|
||||
while (($wnd = [SearchArrowTest]::FindWindowEx(0, $wnd, $Name, [System.Management.Automation.Language.NullString]::Value)) -ne [IntPtr]::Zero) {
|
||||
[uint32]$ownerProcess = 0
|
||||
[void][SearchArrowTest]::GetWindowThreadProcessId($wnd, [ref]$ownerProcess)
|
||||
if ($ownerProcess -eq $ProcessId -and $wnd -ne $Except -and [SearchArrowTest]::IsWindowVisible($wnd) -and
|
||||
(!$ControlId -or [SearchArrowTest]::GetDlgItem($wnd,$ControlId) -ne [IntPtr]::Zero)) { return $wnd }
|
||||
}
|
||||
}
|
||||
function Select-Row($Panel, $Name) {
|
||||
$row = [SearchArrowTest]::SendText($Panel, 0x1A2, -1, $Name)
|
||||
if ($row.ToInt64() -lt 0) { throw "Missing row $Name" }
|
||||
[void][SearchArrowTest]::Send($Panel, 0x186, $row, 0)
|
||||
}
|
||||
function Post($Window, $Message, $W, $L = 0) {
|
||||
if (![SearchArrowTest]::PostMessage($Window, $Message, $W, $L)) { throw "Post failed" }
|
||||
}
|
||||
function Assert-FarCheckbox($Dialog, [int]$Id, $NextControl) {
|
||||
$checkbox=[SearchArrowTest]::GetDlgItem($Dialog,$Id)
|
||||
Post $Dialog 0x28 $checkbox 1
|
||||
[void](Wait-Until { [SearchArrowTest]::HasCheckboxCaret($checkbox) })
|
||||
$checked=[SearchArrowTest]::Send($checkbox,0xF0,0,0)
|
||||
[void][SearchArrowTest]::Send($checkbox,0xF5,0,0)
|
||||
if ([SearchArrowTest]::Send($checkbox,0xF0,0,0) -eq $checked) { throw 'The Far checkbox did not toggle.' }
|
||||
[void][SearchArrowTest]::Send($checkbox,0xF5,0,0)
|
||||
Post $Dialog 0x28 $NextControl 1
|
||||
[void](Wait-Until {
|
||||
[SearchArrowTest]::FocusedControl($Dialog) -eq $NextControl -and
|
||||
![SearchArrowTest]::HasCheckboxCaret($checkbox)
|
||||
})
|
||||
}
|
||||
function Read-Bounds($Window) {
|
||||
$bounds=[SearchArrowTest+Rect]::new()
|
||||
if (![SearchArrowTest]::GetWindowRect($Window,[ref]$bounds)) { throw 'Missing window bounds' }
|
||||
$bounds
|
||||
}
|
||||
function Assert-FarFieldHeight($Dialog, [int[]]$Ids) {
|
||||
$heights=@(foreach ($id in $Ids) {
|
||||
$bounds=Read-Bounds ([SearchArrowTest]::GetDlgItem($Dialog,$id))
|
||||
$bounds.Bottom-$bounds.Top
|
||||
})
|
||||
if (@($heights | Select-Object -Unique).Count -ne 1) {
|
||||
throw "Far field heights differ: $heights."
|
||||
}
|
||||
}
|
||||
function Assert-FarDropDown($Popup, $Anchor, [int]$ArrowWidth=0) {
|
||||
$bounds=Read-Bounds $Popup
|
||||
$anchorBounds=Read-Bounds $Anchor
|
||||
if ($bounds.Left -ne $anchorBounds.Left -or $bounds.Top -ne $anchorBounds.Bottom -or
|
||||
$bounds.Right -ne $anchorBounds.Right-$ArrowWidth) {
|
||||
throw 'The Far list is not aligned with the field edges.'
|
||||
}
|
||||
$bitmap=[Drawing.Bitmap]::new($bounds.Right-$bounds.Left,$bounds.Bottom-$bounds.Top)
|
||||
try {
|
||||
$graphics=[Drawing.Graphics]::FromImage($bitmap)
|
||||
try {
|
||||
$dc=$graphics.GetHdc()
|
||||
try {
|
||||
if (![SearchArrowTest]::PrintWindow($Popup,$dc,0)) { throw 'Could not render Far list.' }
|
||||
} finally { $graphics.ReleaseHdc($dc) }
|
||||
} finally { $graphics.Dispose() }
|
||||
$background=$bitmap.GetPixel(2,2)
|
||||
if ($background.G -lt 85 -or $background.B -lt 85 -or $background.R -ge $background.G) {
|
||||
throw "The Far list background is not teal: $background."
|
||||
}
|
||||
$frame=$false
|
||||
$selection=$false
|
||||
for ($y=3; $y -lt $bitmap.Height-3; $y++) {
|
||||
$pixel=$bitmap.GetPixel([int]($bitmap.Width/3),$y)
|
||||
if ($y -lt 16 -and $pixel.R -gt 220 -and $pixel.G -gt 220 -and $pixel.B -gt 220) { $frame=$true }
|
||||
if ($pixel.R -lt 16 -and $pixel.G -lt 16 -and $pixel.B -lt 16) { $selection=$true }
|
||||
}
|
||||
if (!$frame -or !$selection) { throw 'The Far list is missing its light frame or black selected row.' }
|
||||
} finally { $bitmap.Dispose() }
|
||||
}
|
||||
function Resize-Window($Window, $Bounds, [int]$Width, [switch]$Clamped) {
|
||||
if (![SearchArrowTest]::SetWindowPos($Window,0,$Bounds.Left,$Bounds.Top,$Width,
|
||||
($Bounds.Bottom-$Bounds.Top),0x14)) { throw 'Could not resize the owner window.' }
|
||||
if (!$Clamped) {
|
||||
[void](Wait-Until {
|
||||
$current=Read-Bounds $Window
|
||||
$current.Right-$current.Left -eq $Width
|
||||
})
|
||||
}
|
||||
}
|
||||
function Assert-MovesWithOwner($Owner, $Dialog) {
|
||||
$ownerBefore=Read-Bounds $Owner
|
||||
$dialogBefore=Read-Bounds $Dialog
|
||||
$dx=17
|
||||
$dy=23
|
||||
if (![SearchArrowTest]::SetWindowPos($Owner,0,($ownerBefore.Left+$dx),($ownerBefore.Top+$dy),0,0,0x15)) {
|
||||
throw 'Could not move the dialog owner.'
|
||||
}
|
||||
try {
|
||||
for ($i=0; $i -lt 20; $i++) {
|
||||
$ownerAfter=Read-Bounds $Owner
|
||||
$dialogAfter=Read-Bounds $Dialog
|
||||
$ownerDeltaX=$ownerAfter.Left-$ownerBefore.Left
|
||||
$ownerDeltaY=$ownerAfter.Top-$ownerBefore.Top
|
||||
$dialogDeltaX=$dialogAfter.Left-$dialogBefore.Left
|
||||
$dialogDeltaY=$dialogAfter.Top-$dialogBefore.Top
|
||||
if ($ownerDeltaX -eq $dx -and $ownerDeltaY -eq $dy -and
|
||||
$dialogDeltaX -eq $dx -and $dialogDeltaY -eq $dy) { return }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
throw "Expected movement ($dx, $dy). Owner moved ($ownerDeltaX, $ownerDeltaY), dialog moved ($dialogDeltaX, $dialogDeltaY)."
|
||||
} finally {
|
||||
[void][SearchArrowTest]::SetWindowPos($Owner,0,$ownerBefore.Left,$ownerBefore.Top,0,0,0x15)
|
||||
}
|
||||
}
|
||||
$scratch=Join-Path ([IO.Path]::GetDirectoryName((Resolve-Path -LiteralPath $Executable).Path)) ('search-arrow-test-' + [Guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $scratch | Out-Null
|
||||
$fixture = Join-Path $scratch ('files-editor-ui-' + [Guid]::NewGuid().ToString('N') + '.cs')
|
||||
[IO.File]::WriteAllText($fixture, "public class Test {`r`n}`r`n")
|
||||
$copy=Join-Path $scratch 'KSwitcher.Files.search-arrow-test.exe'
|
||||
Copy-Item -LiteralPath $Executable -Destination $copy
|
||||
$process = Start-Process -FilePath $copy -PassThru -WindowStyle Hidden
|
||||
try {
|
||||
$previousDpi=[SearchArrowTest]::SetThreadDpiAwarenessContext([IntPtr](-4))
|
||||
$main = Wait-Until { Find-Window 'KSwitcher.Files' $process.Id }
|
||||
$panel = Wait-Until { $handle = [SearchArrowTest]::GetDlgItem($main,102); if ($handle -ne [IntPtr]::Zero) { $handle } }
|
||||
$pathControl = [SearchArrowTest]::GetDlgItem($main,142)
|
||||
$pathText = [Text.StringBuilder]::new(1024)
|
||||
[void][SearchArrowTest]::GetText($pathControl,0xD,$pathText.Capacity,$pathText)
|
||||
$path=$pathText.ToString()
|
||||
if ([IO.Path]::GetPathRoot($path) -ne [IO.Path]::GetPathRoot($scratch)) {
|
||||
throw 'Select a local left-panel folder on the test executable drive before running this interactive test.'
|
||||
}
|
||||
foreach ($component in $path.Substring(3).Split('\',[System.StringSplitOptions]::RemoveEmptyEntries)) {
|
||||
Select-Row $panel '..'
|
||||
[void][SearchArrowTest]::Send($panel,0x100,13,0)
|
||||
}
|
||||
foreach ($component in $scratch.Substring(3).Split('\')) {
|
||||
Select-Row $panel "[$component]"
|
||||
[void][SearchArrowTest]::Send($panel,0x100,13,0)
|
||||
}
|
||||
$newFolder='F7 тестова папка'
|
||||
Post $panel 0x100 0x76
|
||||
$makeDialog=Wait-Until { Find-Window '#32770' $process.Id 361 }
|
||||
$nameField=[SearchArrowTest]::GetDlgItem($makeDialog,361)
|
||||
Assert-FarFieldHeight $makeDialog @(361,363,365,367)
|
||||
$nameBounds=Read-Bounds $nameField
|
||||
$arrowBounds=Read-Bounds ([SearchArrowTest]::GetDlgItem($makeDialog,363))
|
||||
if ($nameBounds.Right -ne $arrowBounds.Left) { throw 'F7 history arrow does not touch the field.' }
|
||||
$okButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($makeDialog,1))
|
||||
$cancelButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($makeDialog,2))
|
||||
$okWidth=$okButton.Right-$okButton.Left
|
||||
$gap=$cancelButton.Left-$okButton.Right
|
||||
if ($gap -lt 0 -or $gap -gt $okWidth/7 -or $cancelButton.Right-$cancelButton.Left -le $okWidth) {
|
||||
throw 'Make folder buttons are not grouped at their caption widths.'
|
||||
}
|
||||
[void][SearchArrowTest]::SendText($nameField,0xC,0,$newFolder)
|
||||
Post $makeDialog 0x111 1
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($makeDialog) })
|
||||
if (!(Test-Path -LiteralPath (Join-Path $scratch $newFolder) -PathType Container)) {
|
||||
throw 'F7 did not create the folder in the active panel.'
|
||||
}
|
||||
$newRow=[SearchArrowTest]::SendText($panel,0x1A2,-1,"[$newFolder]")
|
||||
if ($newRow.ToInt64() -lt 0 -or [SearchArrowTest]::Send($panel,0x188,0,0) -ne $newRow) {
|
||||
throw 'F7 did not select the new folder.'
|
||||
}
|
||||
Post $main 0x111 115
|
||||
$makeDialog=Wait-Until { Find-Window '#32770' $process.Id 361 }
|
||||
$nameField=[SearchArrowTest]::GetDlgItem($makeDialog,361)
|
||||
[void][SearchArrowTest]::SendText($nameField,0xC,0,$newFolder)
|
||||
Post $makeDialog 0x111 1
|
||||
$folderError=Wait-Until { Find-Window '#32770' $process.Id 100 }
|
||||
Post $folderError 0x111 1
|
||||
[void](Wait-Until {
|
||||
![SearchArrowTest]::IsWindow($folderError) -and
|
||||
[SearchArrowTest]::FocusedControl($makeDialog) -eq $nameField
|
||||
})
|
||||
[void][SearchArrowTest]::SendText($nameField,0xC,0,'Canceled folder')
|
||||
Post $nameField 0x100 27
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($makeDialog) })
|
||||
if (Test-Path -LiteralPath (Join-Path $scratch 'Canceled folder')) { throw 'Canceled F7 created a folder.' }
|
||||
Write-Output 'Verified F7 folder creation, selection, duplicate error, and cancellation.'
|
||||
Post $panel 0x100 0x76
|
||||
$makeDialog=Wait-Until { Find-Window '#32770' $process.Id 361 }
|
||||
$nameField=[SearchArrowTest]::GetDlgItem($makeDialog,361)
|
||||
if (![SearchArrowTest]::IsWindowEnabled([SearchArrowTest]::GetDlgItem($makeDialog,363))) {
|
||||
throw 'F7 did not persist the folder name in history.'
|
||||
}
|
||||
Post $makeDialog 0x111 363
|
||||
$popup=Wait-Until { Find-Window 'KSwitcher.Files.DropDown' $process.Id }
|
||||
Assert-FarDropDown $popup $nameField
|
||||
Post $popup 0x100 13
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($popup) })
|
||||
$chosen=[Text.StringBuilder]::new(256)
|
||||
[void][SearchArrowTest]::GetText($nameField,0xD,$chosen.Capacity,$chosen)
|
||||
if ($chosen.ToString() -ne $newFolder) { throw 'The history list did not fill in the selected name.' }
|
||||
Post $makeDialog 0x111 363
|
||||
$popup=Wait-Until { Find-Window 'KSwitcher.Files.DropDown' $process.Id }
|
||||
Post $popup 0x100 27
|
||||
[void](Wait-Until {
|
||||
![SearchArrowTest]::IsWindow($popup) -and [SearchArrowTest]::FocusedControl($makeDialog) -eq $nameField
|
||||
})
|
||||
$targetField=[SearchArrowTest]::GetDlgItem($makeDialog,367)
|
||||
if ([SearchArrowTest]::IsWindowEnabled($targetField)) { throw 'Target is enabled with link type none.' }
|
||||
$multiple=[SearchArrowTest]::GetDlgItem($makeDialog,368)
|
||||
[void][SearchArrowTest]::Send($multiple,0xF1,1,0)
|
||||
[void][SearchArrowTest]::SendText($nameField,0xC,0,'Alpha;"Beta, Gamma", Delta')
|
||||
Post $makeDialog 0x111 1
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($makeDialog) })
|
||||
foreach ($name in 'Alpha','Beta, Gamma','Delta') {
|
||||
if (!(Test-Path -LiteralPath (Join-Path $scratch $name) -PathType Container)) {
|
||||
throw "F7 did not create '$name' in the batch."
|
||||
}
|
||||
}
|
||||
[IO.File]::WriteAllText((Join-Path $scratch 'Alpha\link-target.txt'),'F7 link target')
|
||||
foreach ($kind in 1,2) {
|
||||
Post $panel 0x100 0x76
|
||||
$makeDialog=Wait-Until { Find-Window '#32770' $process.Id 361 }
|
||||
$nameField=[SearchArrowTest]::GetDlgItem($makeDialog,361)
|
||||
$selector=[SearchArrowTest]::GetDlgItem($makeDialog,365)
|
||||
$targetField=[SearchArrowTest]::GetDlgItem($makeDialog,367)
|
||||
Post $selector 0x100 0x73
|
||||
$popup=Wait-Until { Find-Window 'KSwitcher.Files.DropDown' $process.Id }
|
||||
Assert-FarDropDown $popup $selector ($arrowBounds.Right-$arrowBounds.Left)
|
||||
for ($row=0; $row -lt $kind; $row++) { Post $popup 0x100 0x28 }
|
||||
Post $popup 0x100 13
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($popup) })
|
||||
[void](Wait-Until { [SearchArrowTest]::IsWindowEnabled($targetField) })
|
||||
$linkName="Folder link $kind"
|
||||
[void][SearchArrowTest]::SendText($nameField,0xC,0,$linkName)
|
||||
[void][SearchArrowTest]::SendText($targetField,0xC,0,'Alpha')
|
||||
Post $makeDialog 0x111 1
|
||||
$linkPath=Join-Path $scratch $linkName
|
||||
$outcome=Wait-Until {
|
||||
if (![SearchArrowTest]::IsWindow($makeDialog)) { 'created' }
|
||||
elseif (Find-Window '#32770' $process.Id 100) { 'error' }
|
||||
}
|
||||
if ($outcome -eq 'error' -and $kind -eq 2) {
|
||||
# The test host can deny symlink creation by policy. Confirm the same result with Windows.
|
||||
$probe=Join-Path $scratch 'Symbolic link privilege probe'
|
||||
try {
|
||||
[void][IO.Directory]::CreateSymbolicLink($probe,(Join-Path $scratch 'Alpha'))
|
||||
throw 'Windows can create symbolic links, but F7 reported an error.'
|
||||
} catch {
|
||||
if ($_.Exception.InnerException -isnot [IO.IOException] -and
|
||||
$_.Exception.InnerException -isnot [UnauthorizedAccessException]) { throw }
|
||||
}
|
||||
$alert=Find-Window '#32770' $process.Id 100
|
||||
Post $alert 0x111 1
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($alert) })
|
||||
Post $makeDialog 0x111 2
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($makeDialog) })
|
||||
Write-Output 'Windows denied directory symbolic link creation and F7 reported the error.'
|
||||
continue
|
||||
}
|
||||
if ($outcome -ne 'created') { throw "F7 failed to create link type $kind." }
|
||||
$link=Get-Item -LiteralPath $linkPath
|
||||
$expected=if ($kind -eq 1) { 'Junction' } else { 'SymbolicLink' }
|
||||
if ($link.LinkType -ne $expected -or
|
||||
!(Test-Path -LiteralPath (Join-Path $linkPath 'link-target.txt') -PathType Leaf)) {
|
||||
throw "F7 did not create a $expected pointing to Alpha."
|
||||
}
|
||||
}
|
||||
Write-Output 'Verified F7 history, quoted batch names, and directory link handling.'
|
||||
Select-Row $panel ([IO.Path]::GetFileName($fixture))
|
||||
Post $panel 0x100 0x74
|
||||
$copyDialog=Wait-Until {
|
||||
$dialog=Find-Window '#32770' $process.Id
|
||||
if ($dialog -and [SearchArrowTest]::GetDlgItem($dialog,211) -ne [IntPtr]::Zero) { $dialog }
|
||||
}
|
||||
if (![SearchArrowTest]::IsWindowEnabled($main)) { throw 'Copy disabled the Files frame.' }
|
||||
Assert-FarFieldHeight $copyDialog @(211,213)
|
||||
Assert-MovesWithOwner $main $copyDialog
|
||||
$mainBounds=Read-Bounds $main
|
||||
$copyBounds=Read-Bounds $copyDialog
|
||||
$copyPreferred=$copyBounds.Right-$copyBounds.Left
|
||||
try {
|
||||
Resize-Window $main $mainBounds ([Math]::Max(540,[int]($copyPreferred*0.65))) -Clamped
|
||||
[void](Wait-Until {
|
||||
$current=Read-Bounds $copyDialog
|
||||
$current.Right-$current.Left -lt $copyPreferred
|
||||
})
|
||||
} finally {
|
||||
Resize-Window $main $mainBounds ($mainBounds.Right-$mainBounds.Left)
|
||||
}
|
||||
[void](Wait-Until {
|
||||
$current=Read-Bounds $copyDialog
|
||||
$current.Right-$current.Left -eq $copyPreferred
|
||||
})
|
||||
$destination=[SearchArrowTest]::GetDlgItem($copyDialog,211)
|
||||
Assert-FarCheckbox $copyDialog 214 $destination
|
||||
if ([SearchArrowTest]::GetDlgItem($copyDialog,328) -ne [IntPtr]::Zero) { throw 'Copy has an inline error row.' }
|
||||
$compact=Read-Bounds $copyDialog
|
||||
[void][SearchArrowTest]::SendText($destination,0xC,0,'')
|
||||
Post $copyDialog 0x111 1
|
||||
$alert=Wait-Until { Find-Window '#32770' $process.Id 100 }
|
||||
$alertBounds=Read-Bounds $alert
|
||||
$alertText=Read-Bounds ([SearchArrowTest]::GetDlgItem($alert,100))
|
||||
$alertButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($alert,1))
|
||||
if ($alertText.Bottom-$alertText.Top -gt 2*($alertButton.Bottom-$alertButton.Top) -or
|
||||
$alertButton.Top-$alertText.Bottom -gt $alertButton.Bottom-$alertButton.Top) {
|
||||
throw 'The short error alert has excess space around the message.'
|
||||
}
|
||||
$bitmap=[Drawing.Bitmap]::new($alertBounds.Right-$alertBounds.Left,$alertBounds.Bottom-$alertBounds.Top)
|
||||
try {
|
||||
$graphics=[Drawing.Graphics]::FromImage($bitmap)
|
||||
try {
|
||||
$dc=$graphics.GetHdc()
|
||||
try {
|
||||
if (![SearchArrowTest]::PrintWindow($alert,$dc,0)) { throw 'Could not render the path alert.' }
|
||||
} finally { $graphics.ReleaseHdc($dc) }
|
||||
} finally { $graphics.Dispose() }
|
||||
$background=$bitmap.GetPixel(4,4)
|
||||
if ($background.R -lt 100 -or $background.G -gt 20 -or $background.B -gt 20) {
|
||||
throw "Path alert did not use the red warning surface. Got $background."
|
||||
}
|
||||
} finally { $bitmap.Dispose() }
|
||||
Post $alert 0x111 1
|
||||
[void](Wait-Until {
|
||||
![SearchArrowTest]::IsWindow($alert) -and [SearchArrowTest]::FocusedControl($copyDialog) -eq $destination
|
||||
})
|
||||
$current=Read-Bounds $copyDialog
|
||||
if ($current.Bottom-$current.Top -ne $compact.Bottom-$compact.Top) { throw 'The error resized Copy vertically.' }
|
||||
Write-Output 'Verified the red path alert and focus restoration to Copy.'
|
||||
Post $panel 0x201 1
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($copyDialog) })
|
||||
|
||||
function Open-Search {
|
||||
Post $editor 0x111 3
|
||||
Wait-Until {
|
||||
$dialog=Find-Window '#32770' $process.Id
|
||||
if ($dialog -and [SearchArrowTest]::GetDlgItem($dialog,108) -ne [IntPtr]::Zero) { $dialog }
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Arrow($Dialog, [bool]$Enabled) {
|
||||
$arrow=[SearchArrowTest]::GetDlgItem($Dialog,108)
|
||||
$arrowBounds=[SearchArrowTest+Rect]::new()
|
||||
$dialogBounds=[SearchArrowTest+Rect]::new()
|
||||
if (![SearchArrowTest]::GetWindowRect($arrow,[ref]$arrowBounds) -or
|
||||
![SearchArrowTest]::GetWindowRect($Dialog,[ref]$dialogBounds)) { throw 'Missing Search bounds' }
|
||||
if ([SearchArrowTest]::IsWindowEnabled($arrow) -ne $Enabled) { throw 'Unexpected history button state' }
|
||||
$bitmap=[Drawing.Bitmap]::new($dialogBounds.Right-$dialogBounds.Left,$dialogBounds.Bottom-$dialogBounds.Top)
|
||||
try {
|
||||
$graphics=[Drawing.Graphics]::FromImage($bitmap)
|
||||
try {
|
||||
$dc=$graphics.GetHdc()
|
||||
try {
|
||||
if (![SearchArrowTest]::PrintWindow($Dialog,$dc,0)) { throw 'Could not render the Search dialog' }
|
||||
} finally { $graphics.ReleaseHdc($dc) }
|
||||
} finally { $graphics.Dispose() }
|
||||
$x=$arrowBounds.Left-$dialogBounds.Left
|
||||
$y=$arrowBounds.Top-$dialogBounds.Top
|
||||
$width=$arrowBounds.Right-$arrowBounds.Left
|
||||
$height=$arrowBounds.Bottom-$arrowBounds.Top
|
||||
$field=Read-Bounds ([SearchArrowTest]::GetDlgItem($Dialog,100))
|
||||
$inset=$field.Left-$dialogBounds.Left
|
||||
foreach ($dividerId in 104,105) {
|
||||
$divider=Read-Bounds ([SearchArrowTest]::GetDlgItem($Dialog,$dividerId))
|
||||
$line=$divider.Top-$dialogBounds.Top
|
||||
for ($column=$inset; $column -lt $bitmap.Width-$inset; $column++) {
|
||||
$pixel=$bitmap.GetPixel($column,$line)
|
||||
if ($pixel.R -ne 0 -or $pixel.G -ne 0 -or $pixel.B -ne 0) {
|
||||
throw "Search divider $dividerId is broken at pixel $column."
|
||||
}
|
||||
}
|
||||
}
|
||||
$background=$bitmap.GetPixel($x+2,$y+2)
|
||||
if ([Math]::Abs($background.R-$background.G) -gt 1 -or
|
||||
[Math]::Abs($background.G-$background.B) -gt 1 -or $background.R -lt 175) {
|
||||
throw "Expected a light gray arrow slot, got $background."
|
||||
}
|
||||
$ink=@(for ($row=1; $row -lt $height-1; $row++) {
|
||||
for ($column=1; $column -lt $width-1; $column++) {
|
||||
$pixel=$bitmap.GetPixel($x+$column,$y+$row)
|
||||
if ($background.R-[Math]::Max($pixel.R,[Math]::Max($pixel.G,$pixel.B)) -ge 100) { $row; break }
|
||||
}
|
||||
})
|
||||
if ($ink.Count -lt [Math]::Max(5,$height/3)) { throw 'The arrow is missing or has insufficient contrast.' }
|
||||
if ([Math]::Abs($ink[0]+$ink[-1]-($height-1)) -gt 2) {
|
||||
throw "The arrow is vertically off center. Ink spans rows $($ink[0])–$($ink[-1]) in a $height-pixel slot."
|
||||
}
|
||||
} finally { $bitmap.Dispose() }
|
||||
}
|
||||
|
||||
Post $panel 0x100 0x73
|
||||
$editor=Wait-Until { Find-Window 'KSwitcher.Files.Text' $process.Id }
|
||||
$originalFocus=Wait-Until {
|
||||
$focus=[SearchArrowTest]::FocusedControl($editor)
|
||||
if ($focus -ne [IntPtr]::Zero) { $focus }
|
||||
}
|
||||
if (![SearchArrowTest]::IsWindowEnabled($main) -or [SearchArrowTest]::GetWindow($editor,4) -ne [IntPtr]::Zero -or
|
||||
([SearchArrowTest]::GetWindowStyle($editor,-20).ToInt64() -band 0x40000) -eq 0) {
|
||||
throw 'The editor is not an independent taskbar window.'
|
||||
}
|
||||
Post $panel 0x100 0x73
|
||||
$existing=Wait-Until { Find-Window '#32770' $process.Id 100 }
|
||||
$notice=[Text.StringBuilder]::new(256)
|
||||
[void][SearchArrowTest]::GetText([SearchArrowTest]::GetDlgItem($existing,100),0xD,$notice.Capacity,$notice)
|
||||
if (!$notice.ToString().Contains('already open')) { throw 'Reopening did not offer to switch to the editor.' }
|
||||
Post $existing 0x111 6
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($existing) })
|
||||
if (Find-Window 'KSwitcher.Files.Text' $process.Id 0 $editor) { throw 'Duplicate editor opened.' }
|
||||
$otherFile=Join-Path $scratch 'other-editor.cs'
|
||||
[IO.File]::WriteAllText($otherFile,'class Other { }')
|
||||
[void](Wait-Until {
|
||||
[SearchArrowTest]::SendText($panel,0x1A2,-1,'other-editor.cs').ToInt64() -ge 0
|
||||
})
|
||||
Select-Row $panel 'other-editor.cs'
|
||||
Post $panel 0x100 0x73
|
||||
$otherEditor=Wait-Until { Find-Window 'KSwitcher.Files.Text' $process.Id 0 $editor }
|
||||
if ([SearchArrowTest]::GetWindow($otherEditor,4) -ne [IntPtr]::Zero) { throw 'The second editor is owned.' }
|
||||
$editorIdentity=[SearchArrowTest]::TaskbarIdentity($editor)
|
||||
$otherIdentity=[SearchArrowTest]::TaskbarIdentity($otherEditor)
|
||||
if (!$editorIdentity -or !$otherIdentity -or $editorIdentity -eq $otherIdentity) {
|
||||
throw 'Editor windows do not have distinct Windows taskbar identities.'
|
||||
}
|
||||
Post $otherEditor 0x10 0
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($otherEditor) })
|
||||
Select-Row $panel ([IO.Path]::GetFileName($fixture))
|
||||
Post $editor 7 0
|
||||
[void](Wait-Until { [SearchArrowTest]::FocusedControl($editor) -eq $originalFocus })
|
||||
Write-Output 'Verified independent editor taskbar styling and the already-open prompt.'
|
||||
foreach ($action in 'Escape','outside click','owner close') {
|
||||
$dialog=Open-Search
|
||||
$field=[SearchArrowTest]::GetDlgItem($dialog,100)
|
||||
[void](Wait-Until { [SearchArrowTest]::FocusedControl($dialog) -eq $field })
|
||||
if (![SearchArrowTest]::IsWindowEnabled($editor)) { throw 'Search disabled the editor frame.' }
|
||||
switch ($action) {
|
||||
'Escape' {
|
||||
Post $main 0x111 115
|
||||
$managerPrompt=Wait-Until { Find-Window '#32770' $process.Id 361 }
|
||||
Post $managerPrompt 0x111 2
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($managerPrompt) })
|
||||
if (![SearchArrowTest]::IsWindow($dialog)) { throw 'The manager dismissed the editor Search dialog.' }
|
||||
Assert-MovesWithOwner $editor $dialog
|
||||
$ownerBounds=Read-Bounds $editor
|
||||
$dialogBounds=Read-Bounds $dialog
|
||||
$originalWidth=$ownerBounds.Right-$ownerBounds.Left
|
||||
$preferred=$dialogBounds.Right-$dialogBounds.Left
|
||||
try {
|
||||
Resize-Window $editor $ownerBounds ([int]($preferred*0.92))
|
||||
$reduced=Wait-Until {
|
||||
$current=Read-Bounds $dialog
|
||||
$width=$current.Right-$current.Left
|
||||
if ($width -lt $preferred) { $width }
|
||||
}
|
||||
Assert-Arrow $dialog $false
|
||||
Resize-Window $editor $ownerBounds ([int]($preferred*0.30)) -Clamped
|
||||
$minimum=Wait-Until {
|
||||
$current=Read-Bounds $dialog
|
||||
if ($current.Right-$current.Left -lt $reduced) { $current }
|
||||
}
|
||||
$floor=$minimum.Right-$minimum.Left
|
||||
Resize-Window $editor $ownerBounds ([int]($preferred*0.25)) -Clamped
|
||||
$stillMinimum=Read-Bounds $dialog
|
||||
$narrowOwner=Read-Bounds $editor
|
||||
if ($floor -ne ($stillMinimum.Right-$stillMinimum.Left) -or
|
||||
$stillMinimum.Left -lt $narrowOwner.Left -or $stillMinimum.Right -gt $narrowOwner.Right) {
|
||||
throw 'The editor shrank below the minimum width needed to contain Search.'
|
||||
}
|
||||
$previous=Read-Bounds ([SearchArrowTest]::GetDlgItem($dialog,103))
|
||||
$next=Read-Bounds ([SearchArrowTest]::GetDlgItem($dialog,1))
|
||||
if ($previous.Bottom -gt $next.Top) { throw 'The narrow Search actions are not on separate rows.' }
|
||||
Assert-Arrow $dialog $false
|
||||
} finally {
|
||||
Resize-Window $editor $ownerBounds $originalWidth
|
||||
}
|
||||
[void](Wait-Until {
|
||||
$restored=Read-Bounds $dialog
|
||||
$restored.Right-$restored.Left -eq $preferred
|
||||
})
|
||||
Post $field 0x100 27
|
||||
}
|
||||
'outside click' { Post $editor 0x201 1 }
|
||||
'owner close' { Post $editor 0x10 0 }
|
||||
}
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($dialog) })
|
||||
if (![SearchArrowTest]::IsWindow($editor)) { throw "$action closed the editor." }
|
||||
[void](Wait-Until { [SearchArrowTest]::FocusedControl($editor) -eq $originalFocus })
|
||||
}
|
||||
Write-Output 'Verified Search focus and dismissal by Escape, outside click, and editor close.'
|
||||
Write-Output 'Verified that Search narrows to its own minimum and regains its width as the editor grows.'
|
||||
$dialog=Open-Search
|
||||
Assert-FarCheckbox $dialog 101 ([SearchArrowTest]::GetDlgItem($dialog,100))
|
||||
$previousButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($dialog,103))
|
||||
$nextButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($dialog,1))
|
||||
if ($nextButton.Left-$previousButton.Right -ne $gap) { throw 'Search and F7 use different button gaps.' }
|
||||
Assert-Arrow $dialog $false
|
||||
[void][SearchArrowTest]::SendText([SearchArrowTest]::GetDlgItem($dialog,100),0xC,0,'public')
|
||||
Post $dialog 0x111 1
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($dialog) })
|
||||
$dialog=Open-Search
|
||||
Assert-Arrow $dialog $true
|
||||
Write-Output 'Verified continuous Search dividers and history arrow contrast and centering.'
|
||||
Post ([SearchArrowTest]::GetDlgItem($dialog,100)) 0x100 27
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($dialog) })
|
||||
Post $editor 0x10 0
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($editor) })
|
||||
Post $panel 0x100 0x73
|
||||
$editor=Wait-Until { Find-Window 'KSwitcher.Files.Text' $process.Id }
|
||||
$editControl=Wait-Until {
|
||||
$control=[SearchArrowTest]::FindWindowEx($editor,0,'Scintilla',[System.Management.Automation.Language.NullString]::Value)
|
||||
if ($control -ne [IntPtr]::Zero) { $control }
|
||||
}
|
||||
$initialText=[IO.File]::ReadAllText($fixture)
|
||||
Post $editControl 0x102 88
|
||||
[void](Wait-Until { [SearchArrowTest]::Send($editControl,2159,0,0) -ne [IntPtr]::Zero })
|
||||
function Open-SavePrompt {
|
||||
Post $editor 0x10 0
|
||||
Wait-Until {
|
||||
$dialog=Find-Window '#32770' $process.Id
|
||||
if ($dialog -and [SearchArrowTest]::GetDlgItem($dialog,7) -ne [IntPtr]::Zero) { $dialog }
|
||||
}
|
||||
}
|
||||
$dialog=Open-SavePrompt
|
||||
$saveButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($dialog,6))
|
||||
$discardButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($dialog,7))
|
||||
if ($discardButton.Left-$saveButton.Right -ne $gap) { throw 'Save and F7 use different button gaps.' }
|
||||
Post $editor 0x201 1
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($dialog) })
|
||||
if (![SearchArrowTest]::IsWindow($editor) -or
|
||||
[SearchArrowTest]::Send($editControl,2159,0,0) -eq [IntPtr]::Zero) {
|
||||
throw 'Dismissing the save prompt did not preserve the edited document.'
|
||||
}
|
||||
$dialog=Open-SavePrompt
|
||||
Post $dialog 0x111 7
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($editor) })
|
||||
if ([IO.File]::ReadAllText($fixture) -ne $initialText) { throw 'Discard wrote to the file.' }
|
||||
Write-Output 'Verified that dismissing the save prompt preserves edits, and Discard leaves the file untouched.'
|
||||
|
||||
Post $panel 0x100 0x72
|
||||
$editor=Wait-Until { Find-Window 'KSwitcher.Files.Text' $process.Id }
|
||||
$dialog=Open-Search
|
||||
Assert-Arrow $dialog $true
|
||||
Assert-MovesWithOwner $editor $dialog
|
||||
Post $editor 0x201 1
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($dialog) })
|
||||
if (![SearchArrowTest]::IsWindow($editor)) { throw 'Outside click closed the viewer.' }
|
||||
Write-Output 'Verified outside-click cancellation in Copy and Viewer Search.'
|
||||
|
||||
Write-Output 'Verified that Copy and Search move and resize with their owners.'
|
||||
Post $main 0x10 0
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($main) })
|
||||
if (![SearchArrowTest]::IsWindow($editor)) { throw 'Closing the manager also closed the viewer.' }
|
||||
Post $editor 0x10 0
|
||||
if (!$process.WaitForExit(5000)) { throw 'Files did not exit after its last window closed.' }
|
||||
}
|
||||
finally {
|
||||
if (!$process.HasExited) {
|
||||
Stop-Process -Id $process.Id
|
||||
[void]$process.WaitForExit(5000)
|
||||
}
|
||||
if ($previousDpi) { [void][SearchArrowTest]::SetThreadDpiAwarenessContext($previousDpi) }
|
||||
$resolved=(Resolve-Path -LiteralPath $scratch).Path
|
||||
$root=(Resolve-Path -LiteralPath (Split-Path -Parent $scratch)).Path
|
||||
if ([IO.Path]::GetDirectoryName($resolved) -eq $root -and (Split-Path -Leaf $resolved) -like 'search-arrow-test-*') {
|
||||
Remove-Item -LiteralPath $resolved -Recurse -Force
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#include "files_text_document.h"
|
||||
|
||||
#include <objbase.h>
|
||||
#include <winioctl.h>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using ks::files::TextDocument;
|
||||
|
||||
namespace
|
||||
{
|
||||
void check(bool condition, const char *message)
|
||||
{
|
||||
if (!condition) throw std::runtime_error(message);
|
||||
}
|
||||
|
||||
struct Fixture
|
||||
{
|
||||
fs::path directory;
|
||||
Fixture()
|
||||
{
|
||||
GUID id{};
|
||||
wchar_t name[40]{};
|
||||
check(SUCCEEDED(CoCreateGuid(&id)) && StringFromGUID2(id, name, 40), "Create fixture ID");
|
||||
directory = fs::temp_directory_path() / (std::wstring(L"ks-files-text-") + name);
|
||||
fs::create_directory(directory);
|
||||
}
|
||||
~Fixture()
|
||||
{
|
||||
std::error_code error;
|
||||
fs::remove_all(directory, error);
|
||||
}
|
||||
};
|
||||
|
||||
void writeFile(const fs::path &path, const std::string &bytes)
|
||||
{
|
||||
std::ofstream output(path, std::ios::binary);
|
||||
output.write(bytes.data(), bytes.size());
|
||||
check(static_cast<bool>(output), "Write fixture");
|
||||
}
|
||||
|
||||
std::string readFile(const fs::path &path)
|
||||
{
|
||||
std::ifstream input(path, std::ios::binary);
|
||||
check(static_cast<bool>(input), "Read fixture");
|
||||
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
|
||||
}
|
||||
|
||||
void editsAcrossPages(const fs::path &file)
|
||||
{
|
||||
const std::string first = std::string(TextDocument::pageBytes - 2, 'a') + "\r\n";
|
||||
const std::string middle = std::string(TextDocument::pageBytes - 2, 'b') + "\r\n";
|
||||
const std::string last = "last\r\n";
|
||||
const std::string bom = "\xef\xbb\xbf";
|
||||
writeFile(file, bom + first + middle + last);
|
||||
TextDocument document(file);
|
||||
check(document.pages() == 3, "Expected three pages");
|
||||
check(document.readPage(1) == middle, "Read middle page");
|
||||
document.stage(2, "last changed\r\n");
|
||||
check(!document.modifiedWith(2, last), "Restoring the only modified page leaves no changes");
|
||||
check(document.modifiedWith(0, first), "An original page must not hide changes to other pages");
|
||||
document.stage(0, "first changed\n");
|
||||
check(document.readPage(2) == "last changed\r\n", "Read staged page");
|
||||
check(document.save(), "Replace and reopen");
|
||||
check(!document.modified(), "Save clears staged page edits");
|
||||
check(readFile(file) == bom + "first changed\n" + middle + "last changed\r\n",
|
||||
"Save must preserve untouched bytes and the BOM");
|
||||
document.stage(0, "saved again\n");
|
||||
check(document.save(), "Save again through the same document");
|
||||
}
|
||||
|
||||
void unicodeBoundaries(const fs::path &file)
|
||||
{
|
||||
const auto chunk = TextDocument::pageBytes;
|
||||
const std::string emoji = "\xf0\x9f\x98\x80";
|
||||
writeFile(file, std::string(chunk - 1, 'a') + emoji + "tail");
|
||||
{
|
||||
TextDocument document(file, false);
|
||||
check(document.boundary(1) == chunk + 3, "UTF-8 split within a code point");
|
||||
check(document.readPage(0).ends_with(emoji) && document.readPage(1) == "tail", "Read split UTF-8");
|
||||
}
|
||||
for (const bool little : {true, false})
|
||||
{
|
||||
const std::string bom = little ? "\xff\xfe" : "\xfe\xff";
|
||||
const std::string letter(little ? "a\0" : "\0a", 2);
|
||||
const std::string pair(little ? "\x3d\xd8\x00\xde" : "\xd8\x3d\xde\x00", 4);
|
||||
std::string first;
|
||||
for (size_t n = 0; n < chunk / 2 - 1; ++n) first += letter;
|
||||
first += pair;
|
||||
writeFile(file, bom + first + letter);
|
||||
TextDocument document(file);
|
||||
check(document.readPage(0).ends_with(emoji) && document.readPage(1) == "a", "Read split UTF-16");
|
||||
document.stage(1, emoji);
|
||||
check(document.save() && readFile(file) == bom + first + pair, "Preserve UTF-16 byte order");
|
||||
}
|
||||
}
|
||||
|
||||
void rejectExternalReplacement(const fs::path &file)
|
||||
{
|
||||
writeFile(file, "original");
|
||||
TextDocument document(file);
|
||||
document.stage(0, "editor changes");
|
||||
auto later = file;
|
||||
later += L".later";
|
||||
auto original = file;
|
||||
original += L".original";
|
||||
writeFile(later, "external changes");
|
||||
check(MoveFileExW(file.c_str(), original.c_str(), 0) && MoveFileExW(later.c_str(), file.c_str(), 0),
|
||||
"Replace file externally");
|
||||
bool rejected{};
|
||||
try { document.save(); }
|
||||
catch (const std::exception &) { rejected = true; }
|
||||
check(rejected && document.modified() && readFile(file) == "external changes",
|
||||
"Preserve external file and staged changes after conflict");
|
||||
}
|
||||
|
||||
void sparseFile(const fs::path &file)
|
||||
{
|
||||
constexpr uint64_t offset = 5ULL << 30;
|
||||
const auto handle = CreateFileW(file.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
check(handle != INVALID_HANDLE_VALUE, "Create sparse fixture");
|
||||
DWORD count{};
|
||||
if (!DeviceIoControl(handle, FSCTL_SET_SPARSE, nullptr, 0, nullptr, 0, &count, nullptr))
|
||||
{
|
||||
CloseHandle(handle);
|
||||
std::cout << "Sparse fixture skipped on this file system.\n";
|
||||
return;
|
||||
}
|
||||
LARGE_INTEGER position{};
|
||||
position.QuadPart = offset;
|
||||
const bool written = SetFilePointerEx(handle, position, nullptr, FILE_BEGIN) &&
|
||||
WriteFile(handle, "last", 4, &count, nullptr) && count == 4;
|
||||
CloseHandle(handle);
|
||||
check(written, "Write beyond the 32-bit range");
|
||||
TextDocument document(file);
|
||||
const auto index = document.pages() - 1;
|
||||
check(document.size() == offset + 4 && document.boundary(index) == offset, "Use 64-bit offsets");
|
||||
check(document.readPage(index) == "last", "Read past 4 GiB");
|
||||
document.stage(index, "edited");
|
||||
check(document.readPage(index) == "edited", "Stage an edit past 4 GiB");
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
try
|
||||
{
|
||||
const Fixture fixture;
|
||||
editsAcrossPages(fixture.directory / L"pages.txt");
|
||||
unicodeBoundaries(fixture.directory / L"unicode.txt");
|
||||
rejectExternalReplacement(fixture.directory / L"external.txt");
|
||||
sparseFile(fixture.directory / L"5gib.log");
|
||||
std::cout << "Files text document regressions passed.\n";
|
||||
}
|
||||
catch (const std::exception &error)
|
||||
{
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,30 @@ int main()
|
||||
}
|
||||
catch(const std::system_error &){}
|
||||
FilesConnection host(one,client,key);
|
||||
const auto edited=root/L"edited.txt", serverFile=root/L"server.txt", snapshot=root/L"snapshot.txt";
|
||||
save(serverFile,"original remote content");
|
||||
const auto initial=host.call(fileRequest("stat",serverFile));
|
||||
checkFileReply(initial);
|
||||
const FileRevision base{initial.at("size"),initial.at("modified")};
|
||||
FilesConnection local;
|
||||
streamFile(host,local,one.id,serverFile,snapshot,base.size,base.modified,false,false,{});
|
||||
require(load(snapshot)=="original remote content","remote editor snapshot is incorrect");
|
||||
save(edited,"editor writeback");
|
||||
const auto changed=local.call(fileRequest("stat",edited));
|
||||
checkFileReply(changed);
|
||||
streamFile(local,host,"editor",edited,serverFile,changed.at("size"),changed.at("modified"),true,false,{},&base);
|
||||
require(load(serverFile)=="editor writeback","remote edit did not replace the original");
|
||||
save(serverFile,"an external replacement of the remote file");
|
||||
try
|
||||
{
|
||||
streamFile(local,host,"editor",edited,serverFile,changed.at("size"),changed.at("modified"),true,false,{},&base);
|
||||
require(false,"stale editor upload succeeded");
|
||||
}
|
||||
catch(const std::system_error &error)
|
||||
{
|
||||
require(error.code().value()==ERROR_FILE_INVALID,"stale editor returned the wrong error");
|
||||
}
|
||||
require(load(serverFile)=="an external replacement of the remote file","stale upload overwrote external edits");
|
||||
const auto drives=host.call({{"action","drives"}});
|
||||
checkFileReply(drives);
|
||||
require(drives.at("drives").size()==static_cast<size_t>(std::popcount(GetLogicalDrives())),"drive mask differs");
|
||||
|
||||
Reference in new issue
Block a user