Compare commits

..
Author SHA1 Message Date
agrynco 2c53e99efc Embed PowerShell terminal and polish file panels
Refs #8
2026-11-14 02:38:47 +02:00
11 changed files with 991 additions and 12 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()
+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();
+298 -9
View File
@@ -6,6 +6,7 @@
#include <commdlg.h>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <map>
#include <set>
@@ -13,6 +14,8 @@
#include <string>
#include <vector>
#include <objbase.h>
#include "files_terminal.h"
#include "files_terminal_view.h"
namespace
{
@@ -201,6 +204,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]{};
@@ -309,7 +330,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 +353,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 +361,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
@@ -490,6 +514,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 +534,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 +579,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 +858,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)
{
@@ -2285,6 +2490,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)
{
@@ -2441,6 +2652,7 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
switch (message)
{
case WM_TIMER:
if(wp==terminalTimer) { updateTerminalOutput(); return 0; }
if(wp!=transferHighlightTimer) break;
++transferHighlightPhase;
for(auto &panel:panels)
@@ -2493,6 +2705,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 +2825,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 +2857,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 +2886,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)
@@ -2738,6 +2979,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() &&
@@ -2864,6 +3114,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 +3135,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 +3161,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 +3244,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()));
}
};
+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"