Compare commits

..
16 changed files with 1325 additions and 41 deletions

No files matched your search

+8 -1
View File
@@ -8,7 +8,14 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(KSwitcherFiles WIN32 src/files_main.cpp resources/files.rc)
set_target_properties(KSwitcherFiles PROPERTIES OUTPUT_NAME "KSwitcher.Files")
target_compile_definitions(KSwitcherFiles PRIVATE UNICODE _UNICODE NOMINMAX _WIN32_WINNT=0x0A00)
target_link_libraries(KSwitcherFiles PRIVATE user32 gdi32 comctl32 shell32 comdlg32 advapi32 ole32)
find_program(KSWITCHER_POWERSHELL NAMES pwsh powershell REQUIRED)
add_custom_target(KSwitcherTerminalAssets
COMMAND "${KSWITCHER_POWERSHELL}" -NoProfile -File "${CMAKE_CURRENT_SOURCE_DIR}/tools/stage-files-terminal.ps1"
-Destination "$<TARGET_FILE_DIR:KSwitcherFiles>"
VERBATIM)
add_dependencies(KSwitcherFiles KSwitcherTerminalAssets)
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)
target_compile_options(KSwitcherFiles PRIVATE /utf-8)
else()
+2
View File
@@ -4,6 +4,8 @@ Far Manager є зразком для оформлення файлового м
## Панелі
- Меню Change drive показує літеру, тип, загальний обсяг і вільне місце. Окрема секція KSwitcher LAN показує комп’ютери, які локальна служба бачить на момент відкриття меню.
- Після F5 або F6 успішно оброблені елементи коротко підсвічуються в панелі, що показує папку призначення. Підсвітка тексту відрізняється від курсора й не переносить фокус.
- У широкому режимі ширина під вирівняні розширення залежить від найдовшого розширення в поточній панелі. За вузької панелі залишай місце для імені та колонки Size.
+3 -1
View File
@@ -47,9 +47,11 @@ 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 -mwindows -municode -static -s src/files_main.cpp build/files-res.o -luser32 -lgdi32 -lcomctl32 -lshell32 -lcomdlg32 -ladvapi32 -lole32 -o $filesPath
& $compiler @common "-I$terminalIncludes" -mwindows -municode -static -s src/files_main.cpp build/files-res.o -luser32 -lgdi32 -lcomctl32 -lshell32 -lcomdlg32 -ladvapi32 -lole32 -luuid -o $filesPath
if ($LASTEXITCODE) { throw 'File manager build failed' }
Copy-Item -LiteralPath $filesPath -Destination build/release/KSwitcher.Files.exe -Force
if ($Test) {
+3 -1
View File
@@ -7,6 +7,8 @@ Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destinat
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Files.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\WebView2Loader.dll') -Destination $destination
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 'README.md') -Destination $destination
@@ -17,7 +19,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','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','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()}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'">
<title>PowerShell terminal</title>
<link rel="stylesheet" href="xterm.css">
<style>
html, body, #terminal { width:100%; height:100%; margin:0; overflow:hidden; background:#181818; }
</style>
<div id="terminal"></div>
<script src="xterm.js"></script>
<script src="addon-fit.js"></script>
<script src="terminal.js"></script>
</html>
+89
View File
@@ -0,0 +1,89 @@
const bridge = window.chrome.webview;
const terminal = new Terminal({
fontFamily: 'Consolas, monospace',
fontSize: 16,
cursorBlink: false,
stdinDisabled: true,
theme: { background: '#181818', foreground: '#e8e8e8' },
});
const fit = new FitAddon.FitAddon();
terminal.loadAddon(fit);
const host = document.getElementById('terminal');
terminal.open(host);
host.addEventListener('pointerup', () => bridge.postMessage('focus-input'), true);
const screen = host.querySelector('.xterm-screen');
let traceEnabled = false;
let traceBudget = 4096;
let currentOffset = 0;
function trace(phase) {
if (!traceEnabled || traceBudget <= 0) return;
traceBudget--;
const buffer = terminal.buffer.active;
bridge.postMessage(`trace:${phase},${host.clientHeight},${terminal.rows},${buffer.viewportY},${buffer.baseY},${Math.round(currentOffset)}`);
}
function anchorOutput() {
const buffer = terminal.buffer.active;
let offset = 0;
if (buffer.type === 'normal' && buffer.viewportY === buffer.baseY) {
let last = 0;
for (let row = terminal.rows - 1; row >= 0; row--) {
if (buffer.getLine(buffer.baseY + row)?.translateToString(true)) {
last = row;
break;
}
}
offset = Math.max(0, terminal.rows - 1 - last) * screen.offsetHeight / terminal.rows;
}
screen.style.transform = `translateY(${offset}px)`;
currentOffset = offset;
trace(2);
}
terminal.attachCustomKeyEventHandler(event => {
if (event.type !== 'keydown' || !event.ctrlKey || event.altKey || event.shiftKey) 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 return true;
event.preventDefault();
return false;
});
const reportSize = () => bridge.postMessage(`size:${terminal.cols},${terminal.rows}`);
terminal.onResize(reportSize);
terminal.onScroll(() => {
trace(3);
anchorOutput();
});
bridge.addEventListener('message', ({ data }) => {
if (data === 'trace:on') {
traceEnabled = true;
trace(2);
return;
}
const font = /^font:(\d+):(\d+):([01]):(.*)$/u.exec(data);
if (font) {
const [, points, weight, italic, family] = font;
const quotedFamily = family.replace(/["\\]/g, '\\$&');
terminal.options.fontFamily = `"${quotedFamily}", monospace`;
terminal.options.fontSize = Number(points) * 96 / 720;
terminal.options.fontWeight = Number(weight);
host.style.fontStyle = italic === '1' ? 'italic' : 'normal';
fit.fit();
anchorOutput();
return;
}
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);
});
new ResizeObserver(() => {
const buffer = terminal.buffer.active;
const wasAtBottom = buffer.viewportY === buffer.baseY;
trace(0);
fit.fit();
trace(1);
if (wasAtBottom) terminal.scrollToBottom();
requestAnimationFrame(anchorOutput);
}).observe(host);
bridge.postMessage('ready');
fit.fit();
reportSize();
+22 -2
View File
@@ -1,5 +1,6 @@
#include "configuration.h"
#include "settings_merge.h"
#include "../vendor/json.hpp"
#include <cstring>
namespace ks
{
@@ -33,7 +34,8 @@ void launchConfiguration(App &app, bool snippets, bool logs)
}
LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &message)
{
if (!configurationPeer(sender, L"KSwitcher.Config.exe") || message.cbData > ConfigLimit ||
const bool filesPeers = message.dwData == FilesLanPeers;
if (!configurationPeer(sender, filesPeers ? L"KSwitcher.Files.exe" : L"KSwitcher.Config.exe") || message.cbData > ConfigLimit ||
(message.cbData && !message.lpData)) return FALSE;
Bytes reply;
bool ok = false;
@@ -41,6 +43,23 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
{
switch (message.dwData)
{
case FilesLanPeers:
{
if(message.cbData) return FALSE;
const std::string request=R"({"action":"status"})";
const auto snapshot=nlohmann::json::parse(app.lanCommand(Bytes(request.begin(),request.end())));
for(const auto &peer:snapshot.at("peers"))
{
if(!peer.value("online",false)) continue;
const auto name=wide(peer.at("name").get<std::string>());
if(name.empty() || name.find_first_of(std::wstring_view(L"\0\r\n\t",4))!=name.npos) continue;
const auto bytes=(name.size()+1)*sizeof(wchar_t);
if(bytes>FilesLanPeersLimit-reply.size()) break;
const auto start=reinterpret_cast<const unsigned char*>(name.c_str());
reply.insert(reply.end(),start,start+bytes);
}
break;
}
case ConfigLan:
{
if (!message.cbData || message.cbData > 4096) throw std::runtime_error("Invalid LAN request.");
@@ -121,7 +140,8 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
std::string text = error.what();
reply.assign(text.begin(), text.end());
}
COPYDATASTRUCT response{ConfigReply, (DWORD)reply.size(), reply.data()};
if(filesPeers && !ok) return FALSE;
COPYDATASTRUCT response{filesPeers?FilesLanPeersReply:ConfigReply, (DWORD)reply.size(), reply.data()};
DWORD_PTR received{};
if (!SendMessageTimeoutW(sender, WM_COPYDATA, (WPARAM)app.window, (LPARAM)&response,
SMTO_ABORTIFHUNG, 2000, &received)) return FALSE;
+2 -1
View File
@@ -1,5 +1,6 @@
#pragma once
#include "app.h"
#include "files_lan_ipc.h"
namespace ks
{
inline constexpr UINT ConfigSettingsDownloaded = WM_APP + 24;
@@ -8,7 +9,7 @@ inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigSaveEdited = ConfigRead + 12, ConfigEnd = ConfigRead + 13;
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigSaveEdited = ConfigRead + 12, ConfigEnd = FilesLanPeers + 1;
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
bool configurationPeer(HWND, const wchar_t *filename);
void launchConfiguration(App &, bool snippets = false, bool logs = false);
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#include <windows.h>
inline constexpr ULONG_PTR FilesLanPeers = 0x4b53010d;
inline constexpr ULONG_PTR FilesLanPeersReply = 0x4b53010e;
inline constexpr DWORD FilesLanPeersLimit = 64 * 1024;
+539 -35
View File
@@ -5,7 +5,9 @@
#include <shlobj.h>
#include <commdlg.h>
#include <algorithm>
#include <array>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <map>
#include <set>
@@ -13,6 +15,9 @@
#include <string>
#include <vector>
#include <objbase.h>
#include "files_terminal.h"
#include "files_terminal_view.h"
#include "files_lan_ipc.h"
namespace
{
@@ -201,6 +206,24 @@ struct Panel
bool wide{};
};
Panel panels[2];
FilesTerminal terminal;
std::ofstream terminalTrace;
unsigned terminalTraceLines{};
void traceTerminal(const char *event,int a=0,int b=0,int c=0,int d=0,int e=0)
{
if(!terminalTrace.is_open() || terminalTraceLines>=4096) return;
++terminalTraceLines;
terminalTrace<<GetTickCount64()<<' '<<event<<' '<<a<<' '<<b<<' '<<c<<' '<<d<<' '<<e<<std::endl;
}
FilesTerminalView terminalView;
COORD terminalSize{80,25};
bool terminalViewRequested{};
HWND terminalInput{},terminalOutput{},terminalPrompt{};
constexpr UINT_PTR terminalTimer=2;
bool terminalVisible{};
int terminalRows{};
std::vector<std::wstring> terminalHistory;
size_t terminalHistoryIndex{};
constexpr UINT_PTR transferHighlightTimer=1;
unsigned transferHighlightPhase{};
HWND status{}, commands[6]{};
@@ -217,6 +240,10 @@ bool alignExtensions=true, showDetails=true;
bool alignFolders=false;
DWORD statusFields=15;
std::vector<fs::path> drivePaths;
int driveHover=-1;
std::array<LONG,3> driveColumnStarts{};
HWND drivePeerService{};
std::vector<std::wstring> drivePeers;
int pickerPanel{};
bool altAlone{};
enum Color { PanelBlue, Text, Frame, Dialog, Selection, Footer, PanelCursor, ColorCount };
@@ -309,7 +336,7 @@ BOOL CALLBACK updateDialogFont(HWND window,LPARAM parameter)
SendMessageW(window,WM_SETFONT,reinterpret_cast<WPARAM>(mono),FALSE);
return TRUE;
}
HBRUSH blue{}, teal{};
HBRUSH blue{}, teal{}, terminalBackground{};
void layout(HWND window);
bool applyFont(HWND owner, LOGFONTW font, int points)
@@ -332,6 +359,7 @@ bool applyFont(HWND owner, LOGFONTW font, int points)
mono=next;
displayFont=font;
fontPoints=points;
terminalView.setFont(displayFont,fontPoints);
for(auto &panel:panels)
{
if(panel.path) SendMessageW(panel.path,WM_SETFONT,reinterpret_cast<WPARAM>(mono),FALSE);
@@ -339,6 +367,8 @@ bool applyFont(HWND owner, LOGFONTW font, int points)
}
if(picker) SendMessageW(picker,LB_SETITEMHEIGHT,0,textHeight);
if(status) SendMessageW(status,WM_SETFONT,reinterpret_cast<WPARAM>(mono),FALSE);
for(const auto control:{terminalInput,terminalOutput,terminalPrompt})
if(control) SendMessageW(control,WM_SETFONT,reinterpret_cast<WPARAM>(mono),FALSE);
if(old)
{
EnumThreadWindows(GetCurrentThreadId(),[](HWND window,LPARAM parameter)->BOOL
@@ -420,6 +450,22 @@ void paintText(HDC dc, RECT rect, const wchar_t *text, COLORREF foreground, COLO
SetTextAlign(dc,alignment);
}
void paintDriveCaption(HDC dc,RECT row,const wchar_t *caption)
{
const int saved=SaveDC(dc);
SelectObject(dc,mono);
SIZE extent{};
GetTextExtentPoint32W(dc,caption,lstrlenW(caption),&extent);
const LONG width=std::min(row.right-row.left,extent.cx+2*cellWidth);
row.left+=(row.right-row.left-width)/2;
row.right=row.left+width;
FillRect(dc,&row,teal);
SetBkMode(dc,TRANSPARENT);
SetTextColor(dc,colors[Text]);
DrawTextW(dc,caption,-1,&row,DT_CENTER|DT_VCENTER|DT_SINGLELINE|DT_END_ELLIPSIS|DT_NOPREFIX);
if(saved) RestoreDC(dc,saved);
}
void showMenu(bool show);
void hidePicker()
@@ -490,6 +536,14 @@ void layout(HWND window)
RECT rect{};
GetClientRect(window, &rect);
int width = rect.right, height = rect.bottom;
const int inputTop=height-2*textHeight;
const int terminalBottom=std::max(4,inputTop);
const int detailsHeight=terminalVisible?0:textHeight+12;
const int terminalTop=terminalVisible?4:std::max(4,terminalBottom-
std::min(terminalRows,std::max(0,(terminalBottom-detailsHeight)/textHeight-4))*textHeight);
const int panelBottom=terminalTop-detailsHeight;
const bool showOutput=terminalTop<terminalBottom;
traceTerminal("layout",terminalRows,terminalTop,terminalBottom,showOutput,terminalVisible);
int middle = std::clamp(split ? split : width / 2, 120, std::max(120, width - 120));
auto place=[](HWND control, int x, int y, int width, int height)
{
@@ -502,16 +556,41 @@ void layout(HWND window)
int end = i ? width : middle - 2;
place(panels[i].path, x + 5, 4, std::max(0, end - x - 10), textHeight);
int headingHeight=panels[i].wide?textHeight:0;
place(panels[i].heading,x+3,textHeight+10,std::max(0,end-x-11),headingHeight);
ShowWindow(panels[i].heading,panels[i].wide?SW_SHOWNA:SW_HIDE);
place(panels[i].list, x + 3, textHeight+10+headingHeight, std::max(0, end - x - 11),
std::max(0, height-3*textHeight-24-headingHeight));
place(panels[i].heading,x+5,textHeight+10,std::max(0,end-x-13),headingHeight);
ShowWindow(panels[i].heading,!terminalVisible && panels[i].wide?SW_SHOWNA:SW_HIDE);
place(panels[i].list, x + 5, textHeight+10+headingHeight, std::max(0, end - x - 13),
std::max(0, panelBottom-textHeight-10-headingHeight));
}
place(menuBar,0,0,width,textHeight+4);
const int menuEdges[]{0,7,15,27,38,46};
for(int i=0;i<5;++i)
place(menuItems[i],4+menuEdges[i]*cellWidth,0,(menuEdges[i+1]-menuEdges[i])*cellWidth,textHeight+4);
place(status, 8, height-2*textHeight-8, std::max(0, width - 16), textHeight);
const int detailsLeft=active?middle+2:0;
const int detailsRight=active?width:middle-2;
place(status,detailsLeft+8,panelBottom+4,std::max(0,detailsRight-detailsLeft-16),textHeight);
const auto prompt=panels[active].directory.wstring()+L">";
SetWindowTextW(terminalPrompt,prompt.c_str());
SIZE promptExtent{static_cast<LONG>(prompt.size())*cellWidth,0};
if(const auto dc=GetDC(window))
{
const auto previous=SelectObject(dc,mono);
GetTextExtentPoint32W(dc,prompt.c_str(),static_cast<int>(prompt.size()),&promptExtent);
SelectObject(dc,previous);
ReleaseDC(window,dc);
}
const int promptWidth=std::min(width/2,static_cast<int>(promptExtent.cx));
place(terminalPrompt,4,inputTop,promptWidth,textHeight);
place(terminalInput,4+promptWidth,inputTop,std::max(0,width-8-promptWidth),textHeight);
// Keep the WebView in place while the panels reveal more or less of it.
place(terminalOutput,4,4,std::max(0,width-8),terminalBottom-4);
if(showOutput)
{
const auto region=CreateRectRgn(0,terminalTop-4,std::max(0,width-8),terminalBottom-4);
if(region && !SetWindowRgn(terminalOutput,region,FALSE)) DeleteObject(region);
}
ShowWindow(terminalOutput,showOutput?SW_SHOWNA:SW_HIDE);
ShowWindow(status,terminalVisible?SW_HIDE:SW_SHOWNA);
terminalView.layout(terminalOutput,showOutput,terminalBottom-4);
for (int i = 0; i < static_cast<int>(std::size(commands)); ++i)
{
const int count=static_cast<int>(std::size(commands));
@@ -522,6 +601,152 @@ void layout(HWND window)
void setStatus(const std::wstring &message) { SetWindowTextW(status, message.c_str()); }
void showTerminal(HWND owner,bool show)
{
terminalVisible=show;
for(const auto &panel:panels)
{
ShowWindow(panel.path,show?SW_HIDE:SW_SHOWNA);
ShowWindow(panel.list,show?SW_HIDE:SW_SHOWNA);
}
layout(owner);
SetFocus(terminalInput);
}
bool startTerminal(HWND owner)
{
if(terminal.alive()) return true;
if(!terminalViewRequested)
{
terminalViewRequested=terminalView.create(terminalOutput,[owner](const std::wstring &message)
{
if(message==L"ready")
{
terminalView.setFont(displayFont,fontPoints);
if(terminalTrace.is_open()) terminalView.enableTrace();
}
else if(terminalTrace.is_open() && message.starts_with(L"trace:"))
{
int phase{},height{},rows{},viewport{},base{},offset{};
if(swscanf_s(message.c_str()+6,L"%d,%d,%d,%d,%d,%d",
&phase,&height,&rows,&viewport,&base,&offset)==6 && phase>=0 && phase<=3)
{
const char *events[]{"web-before-fit","web-after-fit","web-anchor","web-scroll"};
traceTerminal(events[phase],height,rows,viewport,base,offset);
}
}
else if(message==L"focus-input")
{
if(GetForegroundWindow()==owner) SetFocus(terminalInput);
}
else if(message==L"toggle") showTerminal(owner,!terminalVisible);
else if(!terminalVisible && (message==L"grow" || message==L"shrink"))
{
terminalRows=std::max(0,terminalRows+(message==L"grow"?1:-1));
layout(owner);
}
else if(message.starts_with(L"size:"))
{
int columns{},rows{};
if(swscanf_s(message.c_str()+5,L"%d,%d",&columns,&rows)==2 &&
columns>=2 && columns<=32767 && rows>=2 && rows<=32767)
{
terminalSize={static_cast<SHORT>(columns),static_cast<SHORT>(rows)};
terminal.resize(terminalSize);
}
}
},[owner]
{
showTerminal(owner,false);
setStatus(L"Could not initialize the terminal view.");
});
if(!terminalViewRequested)
{
setStatus(L"WebView2 and the local terminal assets are required.");
return false;
}
}
if(terminal.start(panels[active].directory.c_str(),terminalSize)) return true;
setStatus(L"Could not start PowerShell.");
return false;
}
void updateTerminalOutput()
{
if(!terminalView.initialized()) return;
const auto bytes=terminal.takeOutput();
if(!bytes.empty()) terminalView.send(bytes);
}
LRESULT CALLBACK terminalInputKeys(HWND input,UINT message,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
{
// The edit control receives Ctrl+Backspace as DEL.
if(message==WM_CHAR && wp==127)
{
DWORD first{},last{};
SendMessageW(input,EM_GETSEL,reinterpret_cast<WPARAM>(&first),reinterpret_cast<LPARAM>(&last));
if(first==last && first)
{
std::wstring text(GetWindowTextLengthW(input)+1,L'\0');
GetWindowTextW(input,text.data(),static_cast<int>(text.size()));
while(first && iswspace(text[first-1])) --first;
while(first && !iswspace(text[first-1])) --first;
SendMessageW(input,EM_SETSEL,first,last);
}
SendMessageW(input,EM_REPLACESEL,TRUE,reinterpret_cast<LPARAM>(L""));
return 0;
}
if(message==WM_CHAR && wp==VK_RETURN)
{
if(!terminalVisible && !GetWindowTextLengthW(input))
{
SendMessageW(panels[active].list,WM_KEYDOWN,VK_RETURN,lp);
return 0;
}
const auto owner=GetParent(input);
if(!startTerminal(owner)) return 0;
std::wstring value(GetWindowTextLengthW(input)+1,L'\0');
value.resize(GetWindowTextW(input,value.data(),static_cast<int>(value.size())));
if(!terminal.write(value+L"\r")) { setStatus(L"Could not send PowerShell input."); return 0; }
if(!value.empty() && (terminalHistory.empty() || terminalHistory.back()!=value))
{
if(terminalHistory.size()==100) terminalHistory.erase(terminalHistory.begin());
terminalHistory.push_back(value);
}
terminalHistoryIndex=terminalHistory.size();
SetWindowTextW(input,L"");
return 0;
}
if(message==WM_CHAR && wp==3 && terminal.alive())
{
DWORD first{},last{};
SendMessageW(input,EM_GETSEL,reinterpret_cast<WPARAM>(&first),reinterpret_cast<LPARAM>(&last));
if(first==last)
{
terminal.write(std::wstring(1,L'\x03'));
return 0;
}
}
if(message==WM_KEYDOWN && !terminalVisible &&
(wp==VK_TAB || (wp>=VK_F1 && wp<=VK_F12) ||
(!GetWindowTextLengthW(input) &&
((wp>=VK_PRIOR && wp<=VK_DOWN) || wp==VK_INSERT || wp==VK_DELETE))))
{
SendMessageW(panels[active].list,WM_KEYDOWN,wp,lp);
if(IsWindow(input)) SetFocus(input);
return 0;
}
if(message==WM_KEYDOWN && (wp==VK_UP || wp==VK_DOWN))
{
if(wp==VK_UP && terminalHistoryIndex) --terminalHistoryIndex;
if(wp==VK_DOWN && terminalHistoryIndex<terminalHistory.size()) ++terminalHistoryIndex;
SetWindowTextW(input,terminalHistoryIndex<terminalHistory.size()?
terminalHistory[terminalHistoryIndex].c_str():L"");
SendMessageW(input,EM_SETSEL,-2,-1);
return 0;
}
return DefSubclassProc(input,message,wp,lp);
}
void showFileDetails(const Panel &panel)
{
auto row=SendMessageW(panel.list,LB_GETCURSEL,0,0);
@@ -655,6 +880,8 @@ void open(Panel &panel, const fs::path &directory, bool offerElevation=false)
for (const auto &folder : folders) add(folder, L"[" + folder.filename().wstring() + L"]", true);
for (const auto &file : files) add(file, file.filename().wstring(), false);
SetWindowTextW(panel.path, directory.c_str());
if(&panel==&panels[active])
layout(GetParent(panel.list));
auto selected=panel.rows.begin();
if(!previousDirectory.empty() && previousDirectory.parent_path()==directory)
{
@@ -670,6 +897,7 @@ void open(Panel &panel, const fs::path &directory, bool offerElevation=false)
void selectDrive()
{
auto row=SendMessageW(picker,LB_GETCURSEL,0,0);
if(!commandPicker && (row<0 || static_cast<size_t>(row)==drivePaths.size())) return;
hidePicker();
showMenu(false);
if(commandPicker)
@@ -700,10 +928,54 @@ void selectDrive()
return;
}
if(row>=0 && static_cast<size_t>(row)<drivePaths.size()) open(panels[pickerPanel],drivePaths[row]);
else if(row>static_cast<LRESULT>(drivePaths.size()))
{
const auto peer=static_cast<size_t>(row)-drivePaths.size()-1;
if(peer<drivePeers.size())
setStatus(L"Remote browsing is not available yet: "+drivePeers[peer]);
}
}
LRESULT CALLBACK pickerKeys(HWND window, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
if(!commandPicker && msg==WM_CHAR)
{
const auto key=towupper(static_cast<wchar_t>(wp));
LRESULT row=LB_ERR;
if(key>=L'A' && key<=L'Z')
{
const auto disk=std::find_if(drivePaths.begin(),drivePaths.end(),[key](const fs::path &path)
{ return path.native().front()==key; });
if(disk!=drivePaths.end()) row=disk-drivePaths.begin();
}
else if(key>=L'0' && key<=L'9')
{
const auto peer=static_cast<size_t>(key==L'0'?9:key-L'1');
if(peer<drivePeers.size()) row=static_cast<LRESULT>(drivePaths.size()+1+peer);
}
if(row!=LB_ERR)
{
SendMessageW(window,LB_SETCURSEL,row,0);
selectDrive();
return 0;
}
}
if(!commandPicker && (msg==WM_MOUSEMOVE || msg==WM_MOUSELEAVE))
{
int row=-1;
if(msg==WM_MOUSEMOVE)
{
TRACKMOUSEEVENT tracking{sizeof(tracking),TME_LEAVE,window,0};
TrackMouseEvent(&tracking);
const auto hit=SendMessageW(window,LB_ITEMFROMPOINT,0,lp);
if(!HIWORD(hit) && LOWORD(hit)<drivePaths.size()) row=LOWORD(hit);
}
if(driveHover!=row)
{
driveHover=row;
InvalidateRect(window,nullptr,FALSE);
}
}
if(msg==WM_KEYDOWN && wp==VK_ESCAPE)
{
if(commandPicker && menuIndex==3 && optionsPage)
@@ -727,54 +999,92 @@ void chooseDrive(int index, bool fromMenu)
if (!fromMenu) showMenu(false);
pickerPanel=index;
drivePaths.clear();
driveHover=-1;
SendMessageW(picker,LB_RESETCONTENT,0,0);
drivePeers.clear();
drivePeerService=FindWindowW(L"KSwitcher.Main",L"KSwitcher");
if(drivePeerService)
{
COPYDATASTRUCT request{FilesLanPeers,0,nullptr};
DWORD_PTR result{};
SendMessageTimeoutW(drivePeerService,WM_COPYDATA,reinterpret_cast<WPARAM>(GetParent(picker)),
reinterpret_cast<LPARAM>(&request),SMTO_ABORTIFHUNG|SMTO_ERRORONEXIT,350,&result);
if(result!=1) drivePeers.clear();
drivePeerService=nullptr;
}
const DWORD disks=GetLogicalDrives();
int selected=0;
int textWidth=0;
auto dc=GetDC(picker);
auto oldFont=dc ? SelectObject(dc,mono) : nullptr;
std::vector<std::array<std::wstring,3>> driveColumns;
std::array<LONG,3> columnWidths{};
SIZE space{cellWidth,0};
if(dc) GetTextExtentPoint32W(dc,L" ",1,&space);
for(int letter=0;letter<26;++letter)
{
if(!(disks & (1UL<<letter))) continue;
wchar_t drive[]{static_cast<wchar_t>(L'A'+letter),L':',L'\\',0};
wchar_t volume[MAX_PATH]{};
const auto type=GetDriveTypeW(drive);
ULARGE_INTEGER total{}, free{};
if(type==DRIVE_FIXED || type==DRIVE_RAMDISK)
if(type==DRIVE_FIXED || type==DRIVE_RAMDISK) GetDiskFreeSpaceExW(drive,nullptr,&total,&free);
const wchar_t *types[]{L"unknown",L"unavailable",L"removable",L"fixed",L"network",L"CD-ROM",L"RAM disk"};
driveColumns.push_back({std::wstring(drive,2)+L" "+types[type<std::size(types)?type:0],
total.QuadPart?formatSize(total.QuadPart):L"—",
total.QuadPart?formatSize(free.QuadPart):L"—"});
for(size_t i=0;i<columnWidths.size();++i)
{
GetVolumeInformationW(drive,volume,MAX_PATH,nullptr,nullptr,nullptr,nullptr,0);
GetDiskFreeSpaceExW(drive,&free,&total,nullptr);
const auto &value=driveColumns.back()[i];
SIZE extent{static_cast<LONG>(value.size())*cellWidth,0};
if(dc) GetTextExtentPoint32W(dc,value.c_str(),static_cast<int>(value.size()),&extent);
columnWidths[i]=std::max(columnWidths[i],extent.cx);
}
std::wostringstream columns;
columns << drive << L" " << std::left << std::setw(8) << std::wstring(volume).substr(0, 8);
if(total.QuadPart)
columns << L" " << std::right << std::setw(12) << formatSize(total.QuadPart)
<< L" | " << std::setw(12) << formatSize(free.QuadPart) << L" free";
auto label=columns.str();
SIZE extent{};
if(dc && GetTextExtentPoint32W(dc,label.c_str(),static_cast<int>(label.size()),&extent))
textWidth=std::max(textWidth,static_cast<int>(extent.cx));
if(fs::path(drive)==panels[index].directory.root_path()) selected=static_cast<int>(drivePaths.size());
SendMessageW(picker,LB_ADDSTRING,0,reinterpret_cast<LPARAM>(label.c_str()));
drivePaths.emplace_back(drive);
}
LONG edge=0;
for(size_t i=0;i<driveColumnStarts.size();++i)
{
driveColumnStarts[i]=edge+space.cx;
edge=driveColumnStarts[i]+columnWidths[i]+space.cx+1;
}
if(!driveColumns.empty()) textWidth=edge-1;
for(const auto &values:driveColumns)
{
const auto label=values[0]+L"\t"+values[1]+L"\t"+values[2];
SendMessageW(picker,LB_ADDSTRING,0,reinterpret_cast<LPARAM>(label.c_str()));
}
const auto addNetworkRow=[&](const std::wstring &label)
{
SIZE extent{static_cast<LONG>(label.size())*cellWidth,0};
if(dc) GetTextExtentPoint32W(dc,label.c_str(),static_cast<int>(label.size()),&extent);
textWidth=std::max(textWidth,static_cast<int>(extent.cx+2*space.cx));
SendMessageW(picker,LB_ADDSTRING,0,reinterpret_cast<LPARAM>(label.c_str()));
};
addNetworkRow(L"KSwitcher LAN");
if(drivePeers.empty()) addNetworkRow(L" No visible computers");
else for(size_t i=0;i<drivePeers.size();++i)
{
const auto number=i<10?std::to_wstring((i+1)%10):std::to_wstring(i+1);
addNetworkRow(number+L" "+drivePeers[i]);
}
if(dc)
{
if(oldFont) SelectObject(dc,oldFont);
ReleaseDC(picker,dc);
}
if(drivePaths.empty()) return;
HWND owner=GetParent(picker);
RECT panel{};
GetWindowRect(panels[index].list,&panel);
MapWindowPoints(nullptr,owner,reinterpret_cast<POINT*>(&panel),2);
const int panelWidth=panel.right-panel.left;
const int width=std::clamp(textWidth+24,90,std::max(90,panelWidth*4/5-12));
const int height=std::min(7,static_cast<int>(drivePaths.size()))*textHeight;
const int width=std::clamp(textWidth,90,std::max(90,panelWidth*4/5-12));
const int height=std::min(std::max(1,static_cast<int>(panel.bottom-panel.top)/textHeight),
static_cast<int>(SendMessageW(picker,LB_GETCOUNT,0,0)))*textHeight;
const int x=panel.left+(panel.right-panel.left-width)/2;
const int y=panel.top+(panel.bottom-panel.top-height)/2;
SetWindowPos(pickerFrame,HWND_TOP,x-6,y-textHeight-4,width+12,height+textHeight+11,SWP_SHOWWINDOW);
SetWindowPos(picker,HWND_TOP,x,y,width-5,height,SWP_SHOWWINDOW);
SetWindowPos(pickerFrame,HWND_TOP,x-10,y-textHeight,width+20,height+2*textHeight,SWP_SHOWWINDOW);
SetWindowPos(picker,HWND_TOP,x,y,width,height,SWP_SHOWWINDOW);
SendMessageW(picker,LB_SETCURSEL,selected,0);
SetFocus(picker);
if (fromMenu) showMenu(true);
@@ -2285,6 +2595,12 @@ LRESULT CALLBACK panelKeys(HWND window, UINT message, WPARAM wp, LPARAM lp, UINT
InvalidateRect(panel.list,nullptr,FALSE);
}
showFileDetails(panels[index]);
if(previous!=active) layout(GetParent(window));
}
if(message==WM_CHAR && wp>=32 && wp!=127)
{
SetFocus(terminalInput);
return SendMessageW(terminalInput,message,wp,lp);
}
if (message == WM_KEYDOWN)
{
@@ -2440,7 +2756,26 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
{
switch (message)
{
case WM_COPYDATA:
{
const auto *reply=reinterpret_cast<const COPYDATASTRUCT*>(lp);
if(!drivePeerService || reinterpret_cast<HWND>(wp)!=drivePeerService || !reply ||
reply->dwData!=FilesLanPeersReply || reply->cbData>FilesLanPeersLimit ||
reply->cbData%sizeof(wchar_t) || (reply->cbData && !reply->lpData)) return FALSE;
if(!reply->cbData) return TRUE;
std::wstring names(reply->cbData/sizeof(wchar_t),L'\0');
memcpy(names.data(),reply->lpData,reply->cbData);
if(names.back()!=L'\0') return FALSE;
for(size_t offset=0;offset<names.size();)
{
const auto end=names.find(L'\0',offset);
drivePeers.emplace_back(names.substr(offset,end-offset));
offset=end+1;
}
return TRUE;
}
case WM_TIMER:
if(wp==terminalTimer) { updateTerminalOutput(); return 0; }
if(wp!=transferHighlightTimer) break;
++transferHighlightPhase;
for(auto &panel:panels)
@@ -2493,6 +2828,20 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
SendMessageW(picker,LB_SETITEMHEIGHT,0,textHeight);
status = CreateWindowW(L"STATIC", L"", WS_CHILD | WS_VISIBLE | SS_PATHELLIPSIS,
0, 0, 0, 0, window, nullptr, instance, nullptr);
terminalInput=CreateWindowW(L"EDIT",L"",WS_CHILD|WS_VISIBLE|ES_AUTOHSCROLL,
0,0,0,0,window,nullptr,instance,nullptr);
terminalPrompt=CreateWindowW(L"STATIC",L"",WS_CHILD|WS_VISIBLE|SS_PATHELLIPSIS|SS_CENTERIMAGE,
0,0,0,0,window,nullptr,instance,nullptr);
terminalOutput=CreateWindowW(L"STATIC",L"",WS_CHILD|WS_CLIPCHILDREN,
0,0,0,0,window,nullptr,instance,nullptr);
terminalBackground=CreateSolidBrush(RGB(24,24,24));
if(!terminalInput || !terminalOutput || !terminalPrompt || !terminalBackground) return -1;
for(const auto control:{terminalInput,terminalOutput,terminalPrompt})
SendMessageW(control,WM_SETFONT,reinterpret_cast<WPARAM>(mono),FALSE);
SendMessageW(terminalInput,EM_SETLIMITTEXT,2048,0);
SendMessageW(terminalInput,EM_SETMARGINS,EC_LEFTMARGIN|EC_RIGHTMARGIN,0);
SetWindowSubclass(terminalInput,terminalInputKeys,1,0);
SetTimer(window,terminalTimer,50,nullptr);
if (!mono || !blue || !teal || !menuBar || !picker || !pickerFrame || !status) return -1;
SendMessageW(status, WM_SETFONT, reinterpret_cast<WPARAM>(mono), FALSE);
const wchar_t *labels[]{L"F2 Refresh", L"F3 View", L"F4 Edit", L"F5 Copy", L"F6 Move", L"F10 Exit"};
@@ -2599,21 +2948,28 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
auto border=CreateSolidBrush(colors[Frame]);
for(const auto &panel:panels)
{
if(terminalVisible) break;
RECT box{};
GetWindowRect(panel.list,&box);
MapWindowPoints(nullptr,window,reinterpret_cast<POINT*>(&box),2);
const LONG columnX=std::max(box.left,box.right-(sizeWidth+2)*static_cast<LONG>(cellWidth));
box.right+=5;
InflateRect(&box,2,2);
box.left-=2;
RECT pathBounds{};
GetWindowRect(panel.path,&pathBounds);
MapWindowPoints(nullptr,window,reinterpret_cast<POINT*>(&pathBounds),2);
box.top=pathBounds.top+(pathBounds.bottom-pathBounds.top)/2-2;
RECT details{};
GetWindowRect(status,&details);
MapWindowPoints(nullptr,window,reinterpret_cast<POINT*>(&details),2);
const RECT divider{box.left+1,box.bottom-2,box.right-1,box.bottom-1};
box.bottom=std::max(box.bottom,details.bottom+6);
FrameRect(dc,&box,border);
paintScrollPosition(window,dc,panel.list,colors[PanelBlue]);
InflateRect(&box,-1,-1);
++box.top;
InflateRect(&box,-3,-3);
FrameRect(dc,&box,border);
FillRect(dc,&divider,border);
if(panel.wide)
{
RECT column{columnX,box.top+1,columnX+1,pathBounds.bottom+6};
@@ -2624,9 +2980,17 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
EndPaint(window,&paint);
return 0;
}
case WM_CTLCOLOREDIT:
case WM_CTLCOLORSTATIC:
{
auto dc=reinterpret_cast<HDC>(wp);
if(reinterpret_cast<HWND>(lp)==terminalInput || reinterpret_cast<HWND>(lp)==terminalOutput ||
reinterpret_cast<HWND>(lp)==terminalPrompt)
{
SetTextColor(dc,RGB(232,232,232));
SetBkColor(dc,RGB(24,24,24));
return reinterpret_cast<LRESULT>(terminalBackground);
}
SetTextColor(dc,colors[Frame]);
SetBkColor(dc,colors[PanelBlue]);
return reinterpret_cast<LRESULT>(blue);
@@ -2645,7 +3009,7 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
SetDCBrushColor(item.hDC,colors[Frame]);
const auto lineBrush=static_cast<HBRUSH>(GetStockObject(DC_BRUSH));
RECT upper{bounds.left,middle-2,bounds.right,middle-1};
RECT lower{bounds.left,middle,bounds.right,middle+1};
RECT lower{bounds.left,middle+1,bounds.right,middle+2};
FillRect(item.hDC,&upper,lineBrush);
FillRect(item.hDC,&lower,lineBrush);
if(panels[item.CtlID-142].wide)
@@ -2693,18 +3057,39 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
{
auto box=item.rcItem;
FillRect(item.hDC,&box,teal);
auto border=CreateSolidBrush(colors[Frame]);
auto border=CreateSolidBrush(commandPicker?colors[Frame]:RGB(255,255,255));
if(!commandPicker)
{
InflateRect(&box,-6,-textHeight/2);
FrameRect(item.hDC,&box,border);
InflateRect(&box,-3,-3);
FrameRect(item.hDC,&box,border);
if(!drivePaths.empty())
{
RECT list{};
GetWindowRect(picker,&list);
MapWindowPoints(nullptr,pickerFrame,reinterpret_cast<POINT*>(&list),2);
for(size_t i=1;i<driveColumnStarts.size();++i)
{
const LONG x=list.left+driveColumnStarts[i]-driveColumnStarts[0]-1;
if(x<list.right)
{
const RECT line{x,box.top,x+1,list.top};
FillRect(item.hDC,&line,border);
}
}
}
RECT title{box.left,box.top-textHeight/2-2,
box.right,box.top-textHeight/2-2+textHeight};
paintDriveCaption(item.hDC,title,L"Change drive");
DeleteObject(border);
return TRUE;
}
InflateRect(&box,-2,-2);
FrameRect(item.hDC,&box,border);
InflateRect(&box,-2,-2);
FrameRect(item.hDC,&box,border);
DeleteObject(border);
if(commandPicker) return TRUE;
box.left+=9;
box.right=std::min(box.right,box.left+13*cellWidth);
box.bottom=box.top+textHeight;
paintText(item.hDC,box,L"Change drive",colors[Text],colors[Dialog]);
paintScrollPosition(pickerFrame,item.hDC,picker,colors[Dialog]);
return TRUE;
}
std::wstring text;
@@ -2725,6 +3110,19 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
}
auto background=item.CtlType==ODT_BUTTON?Footer:item.hwndItem==picker?Dialog:PanelBlue;
auto foreground=colors[Text];
if(item.hwndItem==picker && !commandPicker && item.itemID==drivePaths.size())
{
auto row=item.rcItem;
FillRect(item.hDC,&row,teal);
const RECT line{row.left,row.top,row.right,row.top+1};
SetDCBrushColor(item.hDC,RGB(255,255,255));
FillRect(item.hDC,&line,static_cast<HBRUSH>(GetStockObject(DC_BRUSH)));
++row.top;
paintDriveCaption(item.hDC,row,L"KSwitcher LAN");
const RECT sectionLine{row.left,row.bottom-1,row.right,row.bottom};
FillRect(item.hDC,&sectionLine,static_cast<HBRUSH>(GetStockObject(DC_BRUSH)));
return TRUE;
}
if(item.CtlID>=130 && item.CtlID<=134 && static_cast<int>(item.CtlID)-130==menuIndex)
background=Selection;
if(item.CtlID==102 || item.CtlID==103)
@@ -2738,6 +3136,15 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
if(item.CtlID==102 || item.CtlID==103)
{
const auto &panel=panels[item.CtlID-102];
if(item.itemID<panel.rows.size() && panel.sizes[item.itemID]!=L"Folder" &&
panel.sizes[item.itemID]!=L"Up")
{
const auto extension=panel.rows[item.itemID].extension();
if(CompareStringOrdinal(extension.c_str(),-1,L".exe",-1,TRUE)==CSTR_EQUAL)
foreground=RGB(85,255,85);
else if(CompareStringOrdinal(extension.c_str(),-1,L".md",-1,TRUE)==CSTR_EQUAL)
foreground=RGB(231,192,247);
}
if(item.itemID<panel.marked.size() && panel.marked[item.itemID])
foreground=RGB(255,255,0);
else if(transferHighlightPhase%2==0 && item.itemID<panel.rows.size() &&
@@ -2764,6 +3171,64 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
paintText(item.hDC,labelRect,text.substr(0,shortcut).c_str(),colors[Text],colors[background]);
paintText(item.hDC,keyRect,key,colors[Text],colors[background],true);
}
else if(item.hwndItem==picker && !commandPicker && item.itemID<drivePaths.size())
{
SelectObject(item.hDC,mono);
SetTextColor(item.hDC,foreground);
const auto normal=colors[Dialog];
const auto hover=RGB(3*GetRValue(normal)/4,3*GetGValue(normal)/4,
3*GetBValue(normal)/4);
SetBkColor(item.hDC,background==Dialog && static_cast<int>(item.itemID)==driveHover?
hover:colors[background]);
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&item.rcItem,L"",0,nullptr);
SetBkMode(item.hDC,TRANSPARENT);
SetDCBrushColor(item.hDC,RGB(255,255,255));
const auto brush=static_cast<HBRUSH>(GetStockObject(DC_BRUSH));
size_t start=0;
for(size_t i=0;i<driveColumnStarts.size();++i)
{
const LONG x=item.rcItem.left+driveColumnStarts[i];
if(i)
{
const LONG divider=x-driveColumnStarts[0]-1;
if(divider<item.rcItem.right)
{
const RECT line{divider,item.rcItem.top,divider+1,item.rcItem.bottom};
FillRect(item.hDC,&line,brush);
}
}
const auto end=text.find(L'\t',start);
const auto length=end==text.npos?lstrlenW(text.c_str()+start):end-start;
SetTextColor(item.hDC,foreground);
ExtTextOutW(item.hDC,x,item.rcItem.top+3,ETO_CLIPPED,&item.rcItem,
text.c_str()+start,static_cast<UINT>(length),nullptr);
if(i==0 && length)
{
SetTextColor(item.hDC,RGB(255,255,0));
ExtTextOutW(item.hDC,x,item.rcItem.top+3,ETO_CLIPPED,&item.rcItem,text.c_str(),1,nullptr);
}
if(end==text.npos) break;
start=end+1;
}
SetBkMode(item.hDC,OPAQUE);
}
else if(item.hwndItem==picker && !commandPicker && item.itemID>drivePaths.size())
{
auto bounds=item.rcItem;
SelectObject(item.hDC,mono);
SetTextColor(item.hDC,foreground);
SetBkColor(item.hDC,colors[background]);
ExtTextOutW(item.hDC,bounds.left+driveColumnStarts[0],bounds.top+3,
ETO_OPAQUE|ETO_CLIPPED,&bounds,text.c_str(),lstrlenW(text.c_str()),nullptr);
if(item.itemID-drivePaths.size()<=std::min<size_t>(10,drivePeers.size()))
{
SetBkMode(item.hDC,TRANSPARENT);
SetTextColor(item.hDC,RGB(255,255,0));
ExtTextOutW(item.hDC,bounds.left+driveColumnStarts[0],bounds.top+3,
ETO_CLIPPED,&bounds,text.c_str(),1,nullptr);
SetBkMode(item.hDC,OPAQUE);
}
}
else paintText(item.hDC,item.rcItem,text.c_str(),foreground,colors[background]);
if(item.CtlID==102 || item.CtlID==103)
{
@@ -2864,6 +3329,9 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
DestroyWindow(window);
return 0;
case WM_DESTROY:
KillTimer(window,terminalTimer);
terminalView.close();
terminal.close();
PostQuitMessage(0);
return 0;
}
@@ -2882,6 +3350,16 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show)
LocalFree(args);
return static_cast<int>(result);
}
for(int i=1;i<count;++i)
if(std::wstring_view(args[i])==L"--terminal-trace")
{
wchar_t temp[MAX_PATH]{};
const auto length=GetTempPathW(MAX_PATH,temp);
if(length && length<MAX_PATH)
terminalTrace.open(fs::path(temp)/(L"KSwitcher.Files.terminal-trace-"+
std::to_wstring(GetCurrentProcessId())+L".log"),std::ios::trunc);
traceTerminal("start");
}
LocalFree(args);
}
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_STANDARD_CLASSES|ICC_PROGRESS_CLASS};
@@ -2898,9 +3376,33 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show)
WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN, CW_USEDEFAULT, CW_USEDEFAULT, 900, 540, nullptr, nullptr, instance, nullptr);
if (!window) return 1;
if(!restorePlacement(window)) ShowWindow(window, show);
SetFocus(terminalInput);
const auto com=CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
MSG message{};
while (GetMessageW(&message, nullptr, 0, 0) > 0)
{
if(message.message==WM_KEYDOWN && (message.wParam==VK_UP || message.wParam==VK_DOWN) &&
(GetKeyState(VK_CONTROL)&0x8000) && !(GetKeyState(VK_MENU)&0x8000) &&
(message.hwnd==window || IsChild(window,message.hwnd)) && !terminalVisible && !copyInProgress)
{
RECT bounds{};
GetClientRect(window,&bounds);
const auto maximum=std::max(0,static_cast<int>(bounds.bottom)/textHeight-8);
const int rows=std::clamp(terminalRows+(message.wParam==VK_UP?1:-1),0,maximum);
traceTerminal(message.wParam==VK_UP?"key-grow":"key-shrink",terminalRows,rows);
if(rows && !startTerminal(window)) continue;
terminalRows=rows;
layout(window);
continue;
}
if(message.message==WM_KEYDOWN && message.wParam=='O' &&
(GetKeyState(VK_CONTROL)&0x8000) && !(GetKeyState(VK_MENU)&0x8000) &&
(message.hwnd==window || IsChild(window,message.hwnd)) &&
!copyInProgress)
{
if(terminalVisible || startTerminal(window)) showTerminal(window,!terminalVisible);
continue;
}
if (message.message == WM_SYSKEYDOWN && message.wParam == VK_MENU)
{
altAlone=true;
@@ -2957,5 +3459,7 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show)
DeleteObject(mono);
DeleteObject(blue);
DeleteObject(teal);
DeleteObject(terminalBackground);
if(SUCCEEDED(com)) CoUninitialize();
return 0;
}
+110
View File
@@ -0,0 +1,110 @@
#pragma once
#include <mutex>
#include <string>
#include <thread>
#include <vector>
// The reader keeps draining the output pipe while ClosePseudoConsole shuts down.
class FilesTerminal
{
public:
~FilesTerminal() { close(); }
bool start(const wchar_t *directory, COORD size)
{
if(alive()) return true;
close();
pending.clear();
HANDLE inputRead{}, outputWrite{};
if(!CreatePipe(&inputRead,&input,nullptr,0)) return false;
if(!CreatePipe(&output,&outputWrite,nullptr,0))
{
CloseHandle(inputRead);
close();
return false;
}
const auto created=CreatePseudoConsole(size,inputRead,outputWrite,0,&console);
CloseHandle(inputRead);
CloseHandle(outputWrite);
if(FAILED(created)) { close(); return false; }
reader=std::thread([this]
{
char buffer[4096];
DWORD count{};
while(ReadFile(output,buffer,sizeof(buffer),&count,nullptr) && count)
{
std::lock_guard lock(mutex);
pending.append(buffer,count);
if(pending.size()>1024*1024) pending.erase(0,pending.size()-1024*1024);
}
});
SIZE_T bytes{};
InitializeProcThreadAttributeList(nullptr,1,0,&bytes);
std::vector<unsigned char> storage(bytes);
auto attributes=reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.data());
if(!InitializeProcThreadAttributeList(attributes,1,0,&bytes)) { close(); return false; }
STARTUPINFOEXW startup{};
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 system[MAX_PATH]{};
const auto length=GetSystemDirectoryW(system,MAX_PATH);
const auto shell=std::wstring(system)+L"\\WindowsPowerShell\\v1.0\\powershell.exe";
const bool started=length && length<MAX_PATH &&
UpdateProcThreadAttribute(attributes,0,PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
console,sizeof(console),nullptr,nullptr) &&
CreateProcessW(shell.c_str(),command,nullptr,nullptr,FALSE,EXTENDED_STARTUPINFO_PRESENT,
nullptr,directory,&startup.StartupInfo,&info);
DeleteProcThreadAttributeList(attributes);
if(!started) { close(); return false; }
process=info.hProcess;
CloseHandle(info.hThread);
return true;
}
bool alive() const { return process && WaitForSingleObject(process,0)==WAIT_TIMEOUT; }
bool write(const std::wstring &value)
{
if(!alive()) return false;
const int length=WideCharToMultiByte(CP_UTF8,0,value.data(),static_cast<int>(value.size()),
nullptr,0,nullptr,nullptr);
if(!length) return false;
std::string utf8(length,'\0');
WideCharToMultiByte(CP_UTF8,0,value.data(),static_cast<int>(value.size()),
utf8.data(),length,nullptr,nullptr);
DWORD written{};
return WriteFile(input,utf8.data(),length,&written,nullptr) && written==utf8.size();
}
void resize(COORD size) { if(console) ResizePseudoConsole(console,size); }
std::string takeOutput()
{
std::lock_guard lock(mutex);
std::string result;
result.swap(pending);
return result;
}
void close()
{
if(input) CloseHandle(input);
if(console) ClosePseudoConsole(console);
if(reader.joinable()) reader.join();
if(output) CloseHandle(output);
if(process) CloseHandle(process);
input=output=process=nullptr;
console=nullptr;
}
private:
HPCON console{};
HANDLE input{},output{},process{};
std::thread reader;
std::mutex mutex;
std::string pending;
};
+186
View File
@@ -0,0 +1,186 @@
#pragma once
#include <WebView2.h>
#ifdef __MINGW32__
__CRT_UUID_DECL(ICoreWebView2_3, 0xA0D6DF20, 0x3B92, 0x416D, 0xAA, 0x0C, 0x43, 0x7A, 0x9C, 0x72, 0x78, 0x57)
__CRT_UUID_DECL(ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler, 0x4e8a3389, 0xc9d8, 0x4bd2, 0xb6, 0xb5, 0x12, 0x4f, 0xee, 0x6c, 0xc1, 0x4d)
__CRT_UUID_DECL(ICoreWebView2CreateCoreWebView2ControllerCompletedHandler, 0x6c4819f3, 0xc9b7, 0x4260, 0x81, 0x27, 0xc9, 0xf5, 0xbd, 0xe7, 0xf6, 0x8c)
__CRT_UUID_DECL(ICoreWebView2WebMessageReceivedEventHandler, 0x57213f19, 0x00e6, 0x49fa, 0x8e, 0x07, 0x89, 0x8e, 0xa0, 0x1e, 0xcb, 0xd2)
__CRT_UUID_DECL(ICoreWebView2NewWindowRequestedEventHandler, 0xd4c185fe, 0xc81c, 0x4989, 0x97, 0xaf, 0x2d, 0x3f, 0xa7, 0xab, 0x56, 0x51)
__CRT_UUID_DECL(ICoreWebView2NavigationStartingEventHandler, 0x9adbe429, 0xf36d, 0x432b, 0x9d, 0xdc, 0xf8, 0x88, 0x1f, 0xbd, 0x76, 0xe3)
#endif
#include <wrl/client.h>
#include <functional>
#include <memory>
#include <utility>
template<class Interface, class... Args>
class TerminalCallback final : public Interface
{
ULONG refs=1;
std::function<HRESULT(Args...)> invoke;
public:
explicit TerminalCallback(std::function<HRESULT(Args...)> fn):invoke(std::move(fn)) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid,void **value) override
{
if(iid!=IID_IUnknown && iid!=__uuidof(Interface)) { *value=nullptr; return E_NOINTERFACE; }
*value=static_cast<Interface*>(this);
AddRef();
return S_OK;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++refs; }
ULONG STDMETHODCALLTYPE Release() override
{
const auto remaining=--refs;
if(!remaining) delete this;
return remaining;
}
HRESULT STDMETHODCALLTYPE Invoke(Args... args) override { return invoke(args...); }
};
template<class Interface,class... Args,class Fn>
auto terminalCallback(Fn fn)
{
Microsoft::WRL::ComPtr<Interface> result;
result.Attach(new TerminalCallback<Interface,Args...>(std::move(fn)));
return result;
}
class FilesTerminalView
{
Microsoft::WRL::ComPtr<ICoreWebView2Controller> controller;
Microsoft::WRL::ComPtr<ICoreWebView2> web;
HMODULE loader{};
bool ready{};
bool visible{};
int viewportHeight{};
public:
bool initialized() const { return ready; }
void enableTrace() { if(ready) web->PostWebMessageAsString(L"trace:on"); }
void setFont(const LOGFONTW &font,int points)
{
if(!ready) return;
const auto message=L"font:"+std::to_wstring(points)+L":"+
std::to_wstring(font.lfWeight?font.lfWeight:FW_NORMAL)+L":"+
std::to_wstring(font.lfItalic!=0)+L":"+font.lfFaceName;
web->PostWebMessageAsString(message.c_str());
}
void layout(HWND host,bool show,int fullHeight)
{
visible=show;
viewportHeight=fullHeight;
if(!controller) return;
RECT bounds{};
GetClientRect(host,&bounds);
bounds.top=bounds.bottom-viewportHeight;
RECT current{};
if(FAILED(controller->get_Bounds(&current)) || !EqualRect(&current,&bounds))
controller->put_Bounds(bounds);
BOOL currentVisibility{};
if(FAILED(controller->get_IsVisible(&currentVisibility)) || !!currentVisibility!=show)
controller->put_IsVisible(show);
}
void close()
{
if(controller) controller->Close();
web.Reset();
controller.Reset();
ready=false;
}
bool send(const std::string &bytes)
{
if(!ready) return false;
constexpr wchar_t hex[]=L"0123456789abcdef";
std::wstring message;
message.reserve(bytes.size()*2);
for(unsigned char c:bytes)
{
message+=hex[c>>4];
message+=hex[c&15];
}
return SUCCEEDED(web->PostWebMessageAsString(message.c_str()));
}
bool create(HWND host,std::function<void(const std::wstring&)> receive,std::function<void()> failed)
{
wchar_t exe[MAX_PATH]{};
if(!GetModuleFileNameW(nullptr,exe,MAX_PATH)) return false;
const auto folder=std::filesystem::path(exe).parent_path();
loader=LoadLibraryExW((folder/L"WebView2Loader.dll").c_str(),nullptr,LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR|LOAD_LIBRARY_SEARCH_SYSTEM32);
if(!loader) return false;
using Create=HRESULT (STDAPICALLTYPE*)(PCWSTR,PCWSTR,ICoreWebView2EnvironmentOptions*,
ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler*);
const auto createEnvironment=reinterpret_cast<Create>(
reinterpret_cast<void*>(GetProcAddress(loader,"CreateCoreWebView2EnvironmentWithOptions")));
if(!createEnvironment) return false;
wchar_t local[MAX_PATH]{};
const auto length=GetEnvironmentVariableW(L"LOCALAPPDATA",local,MAX_PATH);
if(!length || length>=MAX_PATH) return false;
const auto profile=std::filesystem::path(local)/L"KSwitcher"/L"TerminalWebView";
const auto assets=folder/L"terminal";
auto environment=terminalCallback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler,
HRESULT,ICoreWebView2Environment*>([=,this](HRESULT hr,ICoreWebView2Environment *env)
{
if(!IsWindow(host)) return S_OK;
if(FAILED(hr) || !env) { failed(); return S_OK; }
auto completed=terminalCallback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler,
HRESULT,ICoreWebView2Controller*>([=,this](HRESULT result,ICoreWebView2Controller *view)
{
if(!IsWindow(host)) return S_OK;
if(FAILED(result) || !view) { failed(); return S_OK; }
controller=view;
view->get_CoreWebView2(&web);
Microsoft::WRL::ComPtr<ICoreWebView2_3> mapped;
if(!web || FAILED(web.As(&mapped)) || FAILED(mapped->SetVirtualHostNameToFolderMapping(
L"kswitcher-terminal.invalid",assets.c_str(),COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_DENY)))
{ failed(); return S_OK; }
Microsoft::WRL::ComPtr<ICoreWebView2Settings> settings;
if(SUCCEEDED(web->get_Settings(&settings)))
{
settings->put_AreDevToolsEnabled(FALSE);
settings->put_AreDefaultContextMenusEnabled(FALSE);
settings->put_AreHostObjectsAllowed(FALSE);
}
EventRegistrationToken token{};
auto navigation=terminalCallback<ICoreWebView2NavigationStartingEventHandler,
ICoreWebView2*,ICoreWebView2NavigationStartingEventArgs*>([](auto*,auto *args)
{
PWSTR uri{};
if(SUCCEEDED(args->get_Uri(&uri)))
{
args->put_Cancel(wcscmp(uri,L"https://kswitcher-terminal.invalid/index.html")!=0);
CoTaskMemFree(uri);
}
return S_OK;
});
auto newWindow=terminalCallback<ICoreWebView2NewWindowRequestedEventHandler,
ICoreWebView2*,ICoreWebView2NewWindowRequestedEventArgs*>([](auto*,auto *args)
{ args->put_Handled(TRUE); return S_OK; });
auto messages=terminalCallback<ICoreWebView2WebMessageReceivedEventHandler,
ICoreWebView2*,ICoreWebView2WebMessageReceivedEventArgs*>([=,this](auto*,auto *args)
{
PWSTR source{}, value{};
if(SUCCEEDED(args->get_Source(&source)) &&
wcscmp(source,L"https://kswitcher-terminal.invalid/index.html")==0 &&
SUCCEEDED(args->TryGetWebMessageAsString(&value)))
{
if(wcscmp(value,L"ready")==0) ready=true;
if(ready) receive(value);
}
CoTaskMemFree(value);
CoTaskMemFree(source);
return S_OK;
});
if(FAILED(web->add_NavigationStarting(navigation.Get(),&token)) ||
FAILED(web->add_NewWindowRequested(newWindow.Get(),&token)) ||
FAILED(web->add_WebMessageReceived(messages.Get(),&token)) ||
FAILED(web->Navigate(L"https://kswitcher-terminal.invalid/index.html")))
failed();
layout(host,visible,viewportHeight);
return S_OK;
});
if(FAILED(env->CreateCoreWebView2Controller(host,completed.Get()))) failed();
return S_OK;
});
return SUCCEEDED(createEnvironment(nullptr,profile.c_str(),nullptr,environment.Get()));
}
};
+61
View File
@@ -0,0 +1,61 @@
param([string]$Executable = "$PSScriptRoot/../build/files-dev/KSwitcher.Files.exe")
$ErrorActionPreference = 'Stop'
Add-Type @'
using System;
using System.Text;
using System.Runtime.InteropServices;
public static class FilesDriveShortcutTest {
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr window, int id);
[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", CharSet=CharSet.Unicode)]
public static extern IntPtr SendMessage(IntPtr window, uint msg, IntPtr w, StringBuilder text);
}
'@
function Wait-Until([scriptblock]$Condition) {
for ($attempt = 0; $attempt -lt 60; $attempt++) {
if (& $Condition) { return }
Start-Sleep -Milliseconds 50
}
throw "Timed out waiting for: $Condition"
}
function Read-Text($Control, $Message = 13, $Parameter = 32768) {
$buffer = [Text.StringBuilder]::new(32768)
[void][FilesDriveShortcutTest]::SendMessage($Control, $Message, [IntPtr]$Parameter, $buffer)
$buffer.ToString()
}
$process = Start-Process -FilePath (Resolve-Path $Executable) -PassThru
try {
Wait-Until { $process.Refresh(); $process.MainWindowHandle -ne [IntPtr]::Zero }
$window = $process.MainWindowHandle
$picker = [FilesDriveShortcutTest]::GetDlgItem($window, 121)
$path = [FilesDriveShortcutTest]::GetDlgItem($window, 142)
function Open-Picker {
[void][FilesDriveShortcutTest]::PostMessage($window, 260, [IntPtr]112, [IntPtr]::Zero)
Wait-Until { [FilesDriveShortcutTest]::IsWindowVisible($picker) }
}
function Press-Shortcut([char]$Key) {
[void][FilesDriveShortcutTest]::PostMessage($picker, 258, [IntPtr][int]$Key, [IntPtr]::Zero)
Wait-Until { ![FilesDriveShortcutTest]::IsWindowVisible($picker) }
}
Open-Picker
$count = [FilesDriveShortcutTest]::SendMessage($picker, 395, [IntPtr]::Zero, $null).ToInt32()
$rows = @(for ($i = 0; $i -lt $count; $i++) { Read-Text $picker 393 $i })
$disk = $rows | Where-Object { $_ -match '^[A-Z]: fixed\t' } | Select-Object -First 1
if (!$disk) { throw 'No fixed drive available for the shortcut test.' }
Press-Shortcut ([char]::ToLowerInvariant($disk[0]))
$root = $disk.Substring(0, 2) + '\'
Wait-Until { (Read-Text $path) -eq $root }
Write-Output 'Drive letter opened the drive immediately.'
Open-Picker
$count = [FilesDriveShortcutTest]::SendMessage($picker, 395, [IntPtr]::Zero, $null).ToInt32()
$rows = @(for ($i = 0; $i -lt $count; $i++) { Read-Text $picker 393 $i })
$peer = $rows | Where-Object { $_ -match '^1 .+' } | Select-Object -First 1
if (!$peer) { throw 'An online KSwitcher LAN peer is required for the numeric shortcut test.' }
Press-Shortcut '1'
if ((Read-Text $path) -ne $root) { throw 'The LAN shortcut unexpectedly changed the local path.' }
Write-Output 'LAN number invoked its action immediately.'
} finally {
if (!$process.HasExited) { [void]$process.CloseMainWindow() }
}
+78
View File
@@ -0,0 +1,78 @@
param([string]$Executable = "$PSScriptRoot/../build/files-dev/KSwitcher.Files.exe")
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class PanelFrameWindow {
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr window, out Rect rect);
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr window, int id);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
}
'@
function Get-Bounds($Window) {
$bounds = [PanelFrameWindow+Rect]::new()
if (![PanelFrameWindow]::GetWindowRect($Window, [ref]$bounds)) { throw 'Missing panel control.' }
$bounds
}
function Read-Strip($X, $Y, $Width, $Height) {
$bitmap = [Drawing.Bitmap]::new($Width, $Height)
try {
$graphics = [Drawing.Graphics]::FromImage($bitmap)
try { $graphics.CopyFromScreen($X, $Y, 0, 0, $bitmap.Size) }
finally { $graphics.Dispose() }
for ($row = 0; $row -lt $Height; $row++) {
for ($column = 0; $column -lt $Width; $column++) {
$bitmap.GetPixel($column, $row).ToArgb()
}
}
} finally { $bitmap.Dispose() }
}
$process = Start-Process -FilePath (Resolve-Path $Executable) -PassThru
$previousDpi = [PanelFrameWindow]::SetThreadDpiAwarenessContext([IntPtr](-4))
try {
$keyboard = New-Object -ComObject WScript.Shell
for ($attempt = 0; $attempt -lt 50; $attempt++) {
$process.Refresh()
$window = $process.MainWindowHandle
if ($window -and $keyboard.AppActivate($process.Id)) { break }
Start-Sleep -Milliseconds 100
}
Start-Sleep -Milliseconds 300
if (!$window -or [PanelFrameWindow]::GetForegroundWindow() -ne $window) {
throw 'The test window must be visible and in the foreground.'
}
foreach ($panel in 0, 1) {
$heading = [PanelFrameWindow]::GetDlgItem($window, 140 + $panel)
if (![PanelFrameWindow]::IsWindowVisible($heading)) {
throw 'Both panels must use Wide mode for the frame test.'
}
$header = Get-Bounds $heading
$list = Get-Bounds ([PanelFrameWindow]::GetDlgItem($window, 102 + $panel))
$path = Get-Bounds ([PanelFrameWindow]::GetDlgItem($window, 142 + $panel))
$middle = [int](($header.Top + $header.Bottom) / 2)
$side = @(Read-Strip ($list.Left - 4) ($header.Top - 2) 4 1)
$atHeading = @(Read-Strip ($list.Left - 4) $middle 4 1)
if ($side[0] -ne $side[3] -or $side[1] -ne $side[2] -or $side[0] -eq $side[1] -or
($atHeading -join ',') -ne ($side -join ',')) {
throw "Panel $panel has a broken double frame beside the column header."
}
$x = $list.Left + 16
$top = @(Read-Strip $x ($path.Top + [Math]::Floor(($path.Bottom - $path.Top) / 2) - 2) 1 4)
if (($top -join ',') -ne ($side -join ',')) { throw "Panel $panel has an inconsistent top frame." }
$bottom = @(Read-Strip $x ($list.Bottom + 4) 1 ($header.Bottom - $header.Top + 6))
$bottom = $bottom[($bottom.Count - 4)..($bottom.Count - 1)]
if (($bottom -join ',') -ne ($side -join ',')) { throw "Panel $panel has an inconsistent bottom frame." }
Write-Output "Panel $panel double frame verified on screen."
}
} finally {
if ($previousDpi) { [void][PanelFrameWindow]::SetThreadDpiAwarenessContext($previousDpi) }
if (!$process.HasExited) { [void]$process.CloseMainWindow() }
}
+174
View File
@@ -0,0 +1,174 @@
param([string]$Executable = "$PSScriptRoot/../build/files-dev/KSwitcher.Files.exe", [switch]$GeometryOnly)
$ErrorActionPreference = 'Stop'
if (!$GeometryOnly) {
Add-Type -AssemblyName System.Drawing
Add-Type -ReferencedAssemblies @([Drawing.Bitmap].Assembly.Location, [Drawing.Color].Assembly.Location,
[Drawing.Graphics].Assembly.Location, [Drawing.Point].Assembly.Location, "System.Collections", "System.Runtime", "System.Threading.Thread", "System.Runtime.InteropServices", "System.Diagnostics.TraceSource", "System.Private.Windows.GdiPlus", "System.Private.Windows.Core") -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
public static class TerminalFrames
{
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")]
public static extern bool GetWindowRect(IntPtr window, out Rect rect);
[DllImport("user32.dll")]
private static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extra);
[DllImport("user32.dll")]
private static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")]
private static extern void mouse_event(uint flags, uint x, uint y, uint data, UIntPtr extra);
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string kind, string title);
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
private static extern IntPtr SendMessage(IntPtr window, int message, IntPtr length, System.Text.StringBuilder text);
public static void ClickOutput(IntPtr window)
{
Rect bounds;
if (!GetWindowRect(window, out bounds)) throw new InvalidOperationException("Missing test window.");
SetCursorPos((bounds.Left + bounds.Right) / 2, (bounds.Top + bounds.Bottom) / 2);
mouse_event(2, 0, 0, 0, UIntPtr.Zero);
mouse_event(4, 0, 0, 0, UIntPtr.Zero);
}
public static string ReadCommand(IntPtr window)
{
var input = FindWindowEx(window, IntPtr.Zero, "Edit", null);
var text = new System.Text.StringBuilder(256);
SendMessage(input, 13, new IntPtr(text.Capacity), text);
return text.ToString();
}
public static int[] Capture(IntPtr window, byte direction, int repeats)
{
Rect bounds;
if (!GetWindowRect(window, out bounds)) throw new InvalidOperationException("Missing test window.");
var positions = new List<int>();
var clock = Stopwatch.StartNew();
using (var bitmap = new Bitmap(150, 320))
using (var graphics = Graphics.FromImage(bitmap))
{
var trigger = new Thread(() => {
Thread.Sleep(100);
keybd_event(17, 0, 0, UIntPtr.Zero);
for (int i = 0; i < repeats; i++)
{
keybd_event(direction, 0, 1, UIntPtr.Zero);
if (i + 1 < repeats) Thread.Sleep(30);
}
keybd_event(direction, 0, 3, UIntPtr.Zero);
keybd_event(17, 0, 2, UIntPtr.Zero);
});
trigger.Start();
while (clock.ElapsedMilliseconds < 650)
{
graphics.CopyFromScreen(bounds.Left + 4, bounds.Bottom - 340, 0, 0, bitmap.Size);
int marker = -1;
for (int y = 0; y < bitmap.Height && marker < 0; y++)
for (int x = 0; x < bitmap.Width; x++)
{
var color = bitmap.GetPixel(x, y);
if (color.R > 140 && color.B > 140 && color.R - color.G > 35 && color.B - color.G > 35) { marker = y; break; }
}
positions.Add(marker);
Thread.Sleep(4);
}
trigger.Join();
}
return positions.ToArray();
}
}
'@
}
function Wait-ForTrace([string]$Pattern, [int]$After = 0) {
for ($attempt = 0; $attempt -lt 100; $attempt++) {
$lines = @(Get-Content -LiteralPath $trace -ErrorAction SilentlyContinue)
$match = $lines | Select-Object -Skip $After | Where-Object { $_ -match $Pattern } | Select-Object -First 1
if ($match) { return $match }
Start-Sleep -Milliseconds 100
}
throw "Missing terminal event: $Pattern"
}
$process = Start-Process -FilePath (Resolve-Path $Executable) -ArgumentList '--terminal-trace' -PassThru
$trace = Join-Path ([IO.Path]::GetTempPath()) "KSwitcher.Files.terminal-trace-$($process.Id).log"
try {
$keyboard = New-Object -ComObject WScript.Shell
for ($attempt = 0; $attempt -lt 50 -and !$keyboard.AppActivate($process.Id); $attempt++) {
Start-Sleep -Milliseconds 100
}
if (!$keyboard.AppActivate($process.Id)) { throw 'Could not focus the test window.' }
Wait-ForTrace ' layout ' | Out-Null
for ($attempt = 0; $attempt -lt 20; $attempt++) {
$lines = @(Get-Content -LiteralPath $trace)
if ($lines -match ' layout \d+ \d+ \d+ 1 1$') { break }
if ($keyboard.AppActivate($process.Id)) { $keyboard.SendKeys('^o') }
Start-Sleep -Milliseconds 250
}
Wait-ForTrace ' web-anchor ' | Out-Null
if (!$GeometryOnly) {
[TerminalFrames]::ClickOutput($process.MainWindowHandle)
Start-Sleep -Milliseconds 200
$keyboard.SendKeys('focus-check')
Start-Sleep -Milliseconds 200
if ([TerminalFrames]::ReadCommand($process.MainWindowHandle) -ne 'focus-check') {
throw 'Typing after clicking terminal output did not reach the command line.'
}
$keyboard.SendKeys('^a{BACKSPACE}')
$keyboard.SendKeys('alpha beta gamma^{BACKSPACE}^{BACKSPACE}')
Start-Sleep -Milliseconds 150
if ([TerminalFrames]::ReadCommand($process.MainWindowHandle) -ne 'alpha ') {
throw 'Ctrl+Backspace did not delete one word per press.'
}
$keyboard.SendKeys('^a{BACKSPACE}')
Write-Output 'Typing after clicking terminal output reached the command line.'
}
$keyboard.SendKeys('[Console]::WriteLine{(}{[}char{]}27 {+} "{[}95mMMMMMMMM" {+} {[}char{]}27 {+} "{[}0m"{)}{ENTER}')
Start-Sleep -Milliseconds 500
$keyboard.SendKeys('^o')
for ($row = 0; $row -lt 5; $row++) { $keyboard.SendKeys('^{UP}') }
Start-Sleep -Milliseconds 500
foreach ($scenario in @(@{Key='UP'; Count=1}, @{Key='DOWN'; Count=1}, @{Key='UP'; Count=10})) {
$direction = $scenario.Key
$repeats = $scenario.Count
$after = @(Get-Content -LiteralPath $trace).Count
$event = if ($direction -eq 'UP') { 'grow' } else { 'shrink' }
$frames = $null
if ($GeometryOnly) {
$keyboard.SendKeys("^{$direction $repeats}")
} else {
$key = if ($direction -eq 'UP') { 38 } else { 40 }
$frames = [TerminalFrames]::Capture($process.MainWindowHandle, $key, $repeats)
}
Wait-ForTrace " key-$event " $after | Out-Null
Wait-ForTrace ' layout ' $after | Out-Null
Start-Sleep -Milliseconds 250
$events = @(Get-Content -LiteralPath $trace | Select-Object -Skip $after)
if (@($events -match " key-$event ").Count -lt $repeats) {
throw "Did not deliver all $repeats Ctrl+$direction events."
}
if ($events -match ' web-after-fit ') { throw "Terminal grid resized after Ctrl+$direction." }
if (!$GeometryOnly) {
$baseline = $frames[0]
if ($baseline -lt 0 -or $frames[-1] -lt 0) {
throw "Terminal marker was not visible at rest for Ctrl+$direction."
}
$shifted = @($frames | Where-Object { $_ -lt 0 -or [Math]::Abs($_ - $baseline) -gt 2 })
if ($shifted.Count) {
throw "Ctrl+$direction shifted or hid the marker in $($shifted.Count) of $($frames.Count) frames (rest Y=$baseline, sampled Y=$($frames | Sort-Object -Unique))."
}
}
Write-Output "Ctrl+$direction x$repeats kept the terminal grid stable."
}
} finally {
if (!$process.HasExited) { $null = $process.CloseMainWindow() }
}
+28
View File
@@ -0,0 +1,28 @@
param([Parameter(Mandatory)][string]$Destination)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot
$deps = New-Item -ItemType Directory -Force (Join-Path $root 'build/terminal-deps')
$version = '1.0.4314.35'
if (!(Test-Path "$deps/sdk/build/native/include/WebView2.h")) {
Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/$version/microsoft.web.webview2.$version.nupkg" -OutFile "$deps/sdk.zip"
Expand-Archive "$deps/sdk.zip" "$deps/sdk" -Force
}
$assets = New-Item -ItemType Directory -Force (Join-Path $Destination 'terminal')
foreach ($package in @(
@{ Name='xterm'; Spec='@xterm/[email protected]'; Archive='xterm-xterm-6.0.0.tgz'; Files=@{'lib/xterm.js'='xterm.js'; 'css/xterm.css'='xterm.css'; 'LICENSE'='xterm-LICENSE.txt'} },
@{ Name='fit'; Spec='@xterm/[email protected]'; Archive='xterm-addon-fit-0.11.0.tgz'; Files=@{'lib/addon-fit.js'='addon-fit.js'; 'LICENSE'='addon-fit-LICENSE.txt'} }
)) {
if (!(Test-Path "$deps/$($package.Archive)")) {
& npm.cmd pack $package.Spec --pack-destination $deps.FullName --silent | Out-Host
if ($LASTEXITCODE) { throw "Could not download $($package.Spec)" }
}
$unpacked = New-Item -ItemType Directory -Force "$deps/$($package.Name)"
& tar -xf "$deps/$($package.Archive)" -C $unpacked.FullName
if ($LASTEXITCODE) { throw "Could not unpack $($package.Spec)" }
foreach ($file in $package.Files.Keys) {
Copy-Item "$unpacked/package/$file" "$assets/$($package.Files[$file])" -Force
}
}
Copy-Item "$root/resources/terminal/*" $assets -Force
Copy-Item "$deps/sdk/build/native/x64/WebView2Loader.dll" $Destination -Force
"$deps/sdk/build/native/include"