Phase C (T2-1..T3-10): command palette, reader mode (real Readability.js, E2E), boost force-dark, screenshot, split view, vertical tabs, Glance (Alt+click), tab groups (6 colors), workspaces, speed dial, InPrivate tabs, quick find, source view, print, PiP, TTS (94 tests GREEN)

This commit is contained in:
Yun Chan 2026-08-15 15:22:44 +09:00
parent 0caa1111e7
commit 406957088f
14 changed files with 3496 additions and 7 deletions

View file

@ -10,6 +10,7 @@ using System.Windows.Interop;
using System.Windows.Media;
using Microsoft.Web.WebView2.Core;
using VideoDownloader.Browser;
using VideoDownloader.App.Browser;
using VideoDownloader.App.Browser.Features;
using VideoDownloader.Browser.Tabs;
using VideoDownloader.App.Core;
@ -39,6 +40,14 @@ public partial class MainWindow : Window
private readonly ObservableCollection<HistoryEntry> _history = new();
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
// Phase C: 팔레트/부스트/워크스페이스/레이아웃
private readonly CommandPalette _palette = new();
private BoostStore _boostStore = null!;
private List<WorkspaceSnapshot> _workspaces = new();
private bool _verticalTabs;
private TabItem? _splitTab;
private System.Speech.Synthesis.SpeechSynthesizer? _speech;
// Phase B: 다운로드 매니저(T1-5) / 탭 절전(T2-2)
private readonly PageDownloadTracker _pageDownloads = new();
private System.Windows.Threading.DispatcherTimer? _speedTimer;
@ -84,6 +93,15 @@ public partial class MainWindow : Window
_resolver = new SearchEngineResolver(_config.SearchEngine);
_zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json"));
_boostStore = new BoostStore(Path.Combine(AppDataDir, "boost.json"));
try
{
var wsPath = Path.Combine(AppDataDir, "workspaces.json");
if (File.Exists(wsPath))
_workspaces = System.Text.Json.JsonSerializer.Deserialize<List<WorkspaceSnapshot>>(File.ReadAllText(wsPath)) ?? new();
}
catch { }
BuildPalette();
Loaded += async (_, _) => await InitializeBrowserAsync();
PreviewKeyDown += OnPreviewKeyDown;
@ -184,8 +202,14 @@ public partial class MainWindow : Window
};
_views.ContextMenuBuild += OnContextMenuBuild;
_views.BoostScriptProvider = url => BoostCssBuilder.BuildScript(url, _boostStore.Get(url)); // T2-7
_views.GlanceRequested += (_, link) => Dispatcher.BeginInvoke(new Action(() => OpenGlance(link))); // T2-6
_views.SpeakRequested += (_, text) => Dispatcher.BeginInvoke(new Action(() => SpeakText(text))); // T3-9
_views.ContextMenuBuild += OnContextMenuBuild;
_views.BoostScriptProvider = url => BoostCssBuilder.BuildScript(url, _boostStore.Get(url)); // T2-7
_views.GlanceRequested += (_, link) => Dispatcher.BeginInvoke(new Action(() => OpenGlance(link))); // T2-6
_views.SpeakRequested += (_, text) => Dispatcher.BeginInvoke(new Action(() => SpeakText(text))); // T3-9
_tabs = new TabManager(() => new TabItem(), _views.AttachView);
_tabs.ActiveChanged += OnActiveTabChanged;
@ -299,6 +323,7 @@ public partial class MainWindow : Window
private void OnActiveTabChanged(TabItem tab)
{
if (_splitTab != null && !ReferenceEquals(tab, _splitTab)) ExitSplit(); // v1: 전환 시 분할 해제
foreach (var t in _tabs.Tabs)
if (t.View != null) t.View.Visibility = ReferenceEquals(t, tab) ? Visibility.Visible : Visibility.Collapsed;
if (!AddressBox.IsKeyboardFocused) AddressBox.Text = tab.Url;
@ -310,7 +335,11 @@ public partial class MainWindow : Window
UpdateNavState();
}
private void OnTabRemoved(TabItem tab) => _views?.RemoveView(tab);
private void OnTabRemoved(TabItem tab)
{
if (ReferenceEquals(_splitTab, tab)) ExitSplit();
_views?.RemoveView(tab);
}
private void UpdateNavState()
{
@ -323,7 +352,7 @@ public partial class MainWindow : Window
}
// ---- 탭 스트립 UI 이벤트 ----
private void NewTab_Click(object sender, RoutedEventArgs e) => _tabs.NewTab(_config.HomeUrl);
private void NewTab_Click(object sender, RoutedEventArgs e) => NewTabWithDial();
private void Tab_Click(object sender, MouseButtonEventArgs e)
{
@ -645,8 +674,18 @@ public partial class MainWindow : Window
var mods = Keyboard.Modifiers;
// Phase A 탭 단축키
if (mods == ModifierKeys.Control && e.Key == Key.T) { _tabs.NewTab(_config.HomeUrl); e.Handled = true; }
if (mods == ModifierKeys.Control && e.Key == Key.T) { NewTabWithDial(); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.J) { DownloadsPopup.IsOpen = !DownloadsPopup.IsOpen; e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.K) { TogglePalette(); e.Handled = true; }
else if (e.Key == Key.F9) { _ = ToggleReader(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.D) { ToggleBoostDark(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.S) { _ = TakeScreenshot(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.N) { OpenInPrivateTab(); e.Handled = true; }
else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.V) { ToggleVerticalTabs(); e.Handled = true; }
else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.H) { ToggleSplit(); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.U) { ViewSource(); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.P) { try { Cv?.ShowPrintUI(); } catch { } e.Handled = true; }
else if (e.Key == Key.OemQuestion && Keyboard.FocusedElement is not TextBox) { OpenFind(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.A) { ToggleTabSearch(this, e); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.B) { ToggleBookmarkBar(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.T) { _tabs.ReopenClosedTab(); e.Handled = true; }
@ -1079,6 +1118,363 @@ public partial class MainWindow : Window
_manager.OnDetected(new DetectionContext(link, referer, kind, ""));
}
// ==================== Phase C 기능 ====================
// ---- T2-1 명령 팔레트 ----
private void BuildPalette()
{
_palette.Register("새 탭", "tab new", () => NewTabWithDial());
_palette.Register("새 InPrivate 탭", "private incognito 사생활", OpenInPrivateTab);
_palette.Register("탭 닫기", "close", () => { if (_tabs.ActiveTab != null) _tabs.CloseTab(_tabs.ActiveTab); });
_palette.Register("닫은 탭 복원", "reopen restore", () => _tabs.ReopenClosedTab());
_palette.Register("탭 검색", "tab search find", () => ToggleTabSearch(this, new RoutedEventArgs()));
_palette.Register("다운로드", "downloads page", () => DownloadsPopup.IsOpen = true);
_palette.Register("북마크바 토글", "bookmark bar", ToggleBookmarkBar);
_palette.Register("전체화면", "fullscreen f11", ToggleFullscreen);
_palette.Register("리더 모드", "reader reading 읽기", async () => await ToggleReader());
_palette.Register("강제 다크 모드 토글", "dark boost theme", ToggleBoostDark);
_palette.Register("세로 탭 토글", "vertical layout", ToggleVerticalTabs);
_palette.Register("분할 화면 토글", "split zen tile", ToggleSplit);
_palette.Register("그림 속 그림 (PiP)", "pip picture video", TogglePip);
_palette.Register("스크린샷", "screenshot capture", async () => await TakeScreenshot());
_palette.Register("소스 보기", "source html", ViewSource);
_palette.Register("인쇄", "print pdf", () => { try { Cv?.ShowPrintUI(); } catch { } });
_palette.Register("저장 폴더 열기", "folder open output", () => OpenInExplorer(null));
_palette.Register("이 페이지 즐겨찾기", "bookmark add star", () => AddBookmark_Click(this, new RoutedEventArgs()));
_palette.RegisterSeparator();
_palette.RegisterDynamic(() => _tabs.Tabs
.Select(t => new PaletteCommand($"탭 전환: {Trunc(t.Title, 40)}", "tab go switch", () => _tabs.Activate(t)))
.ToList());
_palette.RegisterDynamic(() => _workspaces
.Select(w => new PaletteCommand($"워크스페이스 전환: {w.Name}", "workspace switch", () => SwitchWorkspace(w)))
.ToList());
}
private void TogglePalette()
{
var open = !PalettePopup.IsOpen;
PalettePopup.IsOpen = open;
if (open) { PaletteBox.Clear(); PaletteBox.Focus(); }
}
private void PaletteBox_TextChanged(object sender, TextChangedEventArgs e)
{
var results = _palette.Search(PaletteBox.Text);
PaletteResults.ItemsSource = results;
PaletteResults.SelectedIndex = results.Count > 0 ? 0 : -1;
}
private void PaletteBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape) { PalettePopup.IsOpen = false; e.Handled = true; }
else if (e.Key == Key.Down && PaletteResults.HasItems) { PaletteResults.SelectedIndex = Math.Min(PaletteResults.SelectedIndex + 1, PaletteResults.Items.Count - 1); e.Handled = true; }
else if (e.Key == Key.Up && PaletteResults.HasItems) { PaletteResults.SelectedIndex = Math.Max(PaletteResults.SelectedIndex - 1, 0); e.Handled = true; }
else if (e.Key == Key.Enter)
{
if (PaletteResults.SelectedItem is PaletteCommand cmd) { PalettePopup.IsOpen = false; cmd.Execute(); }
e.Handled = true;
}
}
private void PaletteResult_Click(object sender, MouseButtonEventArgs e)
{
if (PaletteResults.SelectedItem is PaletteCommand cmd) { PalettePopup.IsOpen = false; cmd.Execute(); }
}
// ---- T2-4 리더 모드 ----
private async Task ToggleReader()
{
var tab = _tabs.ActiveTab;
if (tab == null) return;
var result = await ReaderMode.ToggleAsync(tab, App.IsLightTheme);
if (result == null) Log("이 페이지에서는 리더 모드를 사용할 수 없습니다");
}
// ---- T2-7 부스트(강제 다크) ----
private void ToggleBoostDark()
{
var url = Cv?.Source;
if (string.IsNullOrEmpty(url)) { Log("페이지가 없습니다"); return; }
var cur = _boostStore.Get(url);
_boostStore.Set(url, !cur.ForceDark, cur.CustomCss);
_views?.ApplyBoostNow(_tabs.ActiveTab!);
Log(!cur.ForceDark ? "이 사이트 강제 다크 켜짐" : "이 사이트 강제 다크 꺼짐");
}
// ---- T2-9 스크린샷 ----
private async Task TakeScreenshot()
{
var cv = Cv;
if (cv == null) return;
try
{
using var ms = new MemoryStream();
await cv.CapturePreviewAsync(CoreWebView2CapturePreviewImageFormat.Png, ms);
var path = MakeUniqueDownloadName(Path.GetFullPath(_config.OutputFolder),
$"screenshot_{DateTime.Now:yyyyMMdd_HHmmss}.png");
await File.WriteAllBytesAsync(path, ms.ToArray());
Log($"스크린샷 저장: {Path.GetFileName(path)}");
}
catch (Exception ex) { Log($"스크린샷 실패: {ex.Message}"); }
}
// ---- T2-5 세로 탭 ----
private void ToggleVerticalTabs()
{
_verticalTabs = !_verticalTabs;
if (_verticalTabs)
{
TabStripRow.Visibility = Visibility.Collapsed;
SideStripCol.Width = new GridLength(210);
var sv = new ScrollViewer
{
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
Padding = new Thickness(4, 8, 4, 8)
};
sv.Content = TabList;
TabSideHost.Child = sv;
SetTabListOrientation(Orientation.Vertical);
}
else
{
SetTabListOrientation(Orientation.Horizontal);
TabSideHost.Child = null;
// 원래 스트립의 DockPanel 로 복귀 (NewTabBtn 옆 ScrollViewer)
var sv = new ScrollViewer
{
HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden,
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
VerticalAlignment = VerticalAlignment.Bottom
};
sv.Content = TabList;
// 기존 DockPanel: 스트립의 유일한 DockPanel
var dock = TabStripRow.Child as DockPanel;
dock?.Children.Add(sv);
SideStripCol.Width = new GridLength(0);
TabStripRow.Visibility = Visibility.Visible;
}
if (_fullscreen) TabStripRow.Visibility = Visibility.Collapsed; // 전체화면 유지
}
private void SetTabListOrientation(Orientation o)
{
var factory = new FrameworkElementFactory(typeof(StackPanel));
factory.SetValue(StackPanel.OrientationProperty, o);
TabList.ItemsPanel = new ItemsPanelTemplate(factory);
}
// ---- T2-3 분할 화면 (활성 + 다음 탭 나란히) ----
private void ToggleSplit()
{
if (_splitTab != null) { ExitSplit(); return; }
var active = _tabs.ActiveTab;
if (active == null) return;
var next = _tabs.Tabs.FirstOrDefault(t => !ReferenceEquals(t, active)) ?? _tabs.NewTab(activate: false);
if (next.View?.Parent is Grid host) host.Children.Remove(next.View);
_splitTab = next;
SplitRightHost.Child = next.View;
next.View!.Visibility = Visibility.Visible;
SplitCol.Width = new GridLength(Math.Max(280, (TabHost.ActualWidth == 0 ? 600 : TabHost.ActualWidth) / 2));
SplitRightHost.Visibility = Visibility.Visible;
SplitSplitter.Visibility = Visibility.Visible;
Log($"분할 화면: {Trunc(next.Title, 30)}");
}
private void ExitSplit()
{
if (_splitTab?.View != null)
{
SplitRightHost.Child = null;
TabHost.Children.Add(_splitTab.View);
_splitTab.View.Visibility = ReferenceEquals(_splitTab, _tabs.ActiveTab) ? Visibility.Visible : Visibility.Collapsed;
}
_splitTab = null;
SplitCol.Width = new GridLength(0);
SplitRightHost.Visibility = Visibility.Collapsed;
SplitSplitter.Visibility = Visibility.Collapsed;
}
// ---- T2-6 Glance (Alt+클릭 링크 미리보기) ----
private void OpenGlance(string url)
{
try
{
var glance = new GlanceWindow(_env!, _config, url)
{
Owner = this,
ShowInTaskbar = false,
WindowStyle = WindowStyle.None,
AllowsTransparency = false,
Background = System.Windows.Media.Brushes.Black
};
glance.ExpandRequested += g =>
{
g.Close();
_tabs.NewTab(g.Url); // 탭으로 승격
};
glance.Show();
}
catch (Exception ex) { Log($"미리보기 실패: {ex.Message}"); }
}
// ---- T3-9 낭독(TTS) ----
private void SpeakText(string text)
{
try
{
_speech?.Dispose();
_speech = new System.Speech.Synthesis.SpeechSynthesizer();
if (text.Length > 4000) text = text[..4000];
_speech.SpeakAsync(text);
}
catch { }
}
// ---- T3-3 InPrivate 탭 ----
private void OpenInPrivateTab()
{
_views!.NextTabIsPrivate = true;
var t = _tabs.NewTab();
t.Title = "InPrivate 탭";
Log("InPrivate 탭 열림 — 이 탭은 디스크에 저장되지 않습니다");
}
// ---- T3-10 소스 보기 ----
private async void ViewSource()
{
var cv = Cv;
if (cv == null) return;
try
{
var html = await cv.ExecuteScriptAsync("document.documentElement.outerHTML");
var decoded = System.Text.Json.JsonSerializer.Deserialize<string>(html) ?? "";
var wrapped = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#1b1c24;color:#d4d4dc;font-family:Consolas,monospace;font-size:12.5px;white-space:pre-wrap;padding:20px}</style></head><body>"
+ System.Net.WebUtility.HtmlEncode(decoded) + "</body></html>";
cv.NavigateToString(wrapped);
}
catch { }
}
// ---- T3-7 PiP ----
private void TogglePip()
{
var cv = Cv;
if (cv == null) return;
_ = cv.ExecuteScriptAsync("""
(function(){ try {
var v = Array.from(document.querySelectorAll('video')).find(x => !x.paused && !x.disabled) || document.querySelector('video');
if (!v) { return 'NO_VIDEO'; }
if (document.pictureInPictureElement) { document.exitPictureInPicture(); return 'EXITED'; }
v.disablePictureInPicture = false;
v.requestPictureInPicture(); return 'PIP';
} catch(e){ return 'ERR:'+e.message; } })();
""");
Log("그림 속 그림 전환 (재생 중인 영상이 없으면 첫 번째 영상)");
}
// ---- T3-6 새 탭 스피드 다이얼 ----
private void NewTabWithDial()
{
var tab = _tabs.NewTab();
_ = ShowDialAsync(tab);
}
private async Task ShowDialAsync(TabItem tab)
{
await WaitTabReadyLocal(tab);
var cv = tab.Core;
if (cv == null) return;
var tiles = _bookmarks.Take(18).Select(b =>
$"<a href='{System.Net.WebUtility.HtmlEncode(b.Url)}' style='display:block;text-decoration:none;background:#262736;color:#e6e6ec;border-radius:12px;padding:18px 12px;text-align:center;font-size:12.5px;min-width:110px' onmouseover='this.style.background=#2f3042' onmouseout='this.style.background=#262736'><div style='font-size:26px;margin-bottom:8px'>&#127760;</div><div style='overflow:hidden;text-overflow:ellipsis;white-space:nowrap'>{System.Net.WebUtility.HtmlEncode(b.Title)}</div></a>").ToList();
var dial = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:'Segoe UI',sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#8a8aa0;font-size:12px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>";
cv.NavigateToString(dial);
}
private async Task WaitTabReadyLocal(TabItem tab)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
while (tab.Core == null && sw.ElapsedMilliseconds < 15000)
await Task.Delay(50);
}
// ---- T2-11/T3-1 탭 컨텍스트 메뉴 + 워크스페이스 ----
private static readonly string[] GroupColors = { "#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#e91e8c" };
private void Tab_ContextMenu(object sender, MouseButtonEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is not TabItem tab) return;
e.Handled = true;
var menu = new ContextMenu();
var pin = new MenuItem { Header = tab.IsPinned ? "고정 해제" : "탭 고정" };
pin.Click += (_, _) => _tabs.SetPinned(tab, !tab.IsPinned);
menu.Items.Add(pin);
var dup = new MenuItem { Header = "탭 복제" };
dup.Click += (_, _) => _tabs.NewTab(tab.Url);
menu.Items.Add(dup);
var mute = new MenuItem { Header = tab.Core is { IsMuted: true } ? "음소거 해제" : "사이트 음소거" };
mute.Click += (_, _) => { try { if (tab.Core != null) { tab.Core.IsMuted = !(tab.Core?.IsMuted == true); tab.IsMuted = tab.Core.IsMuted; } } catch { } };
menu.Items.Add(mute);
menu.Items.Add(new Separator());
var group = new MenuItem { Header = "그룹" };
var noGroup = new MenuItem { Header = "그룹 없음" };
noGroup.Click += (_, _) => _tabs.SetGroup(tab, null, null);
group.Items.Add(noGroup);
for (var i = 0; i < GroupColors.Length; i++)
{
var color = GroupColors[i];
var mi = new MenuItem { Header = $"그룹 {i + 1}" };
var sw = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 };
sw.Fill = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(color));
mi.Icon = sw;
mi.Click += (_, _) => _tabs.SetGroup(tab, $"G{i + 1}", color);
group.Items.Add(mi);
}
menu.Items.Add(group);
if (tab.GroupName != null)
{
var closeG = new MenuItem { Header = $"그룹 전체 닫기 ({tab.GroupName})" };
closeG.Click += (_, _) => _tabs.CloseGroup(tab.GroupName!);
menu.Items.Add(closeG);
}
menu.Items.Add(new Separator());
var close = new MenuItem { Header = "탭 닫기" };
close.Click += (_, _) => _tabs.CloseTab(tab);
menu.Items.Add(close);
menu.PlacementTarget = sender as UIElement;
menu.IsOpen = true;
}
private void SaveWorkspace(string name)
{
_workspaces.RemoveAll(w => w.Name == name);
_workspaces.Insert(0, _tabs.CaptureWorkspace(name));
if (_workspaces.Count > 6) _workspaces.RemoveAt(_workspaces.Count - 1);
try
{
Directory.CreateDirectory(AppDataDir);
File.WriteAllText(Path.Combine(AppDataDir, "workspaces.json"),
System.Text.Json.JsonSerializer.Serialize(_workspaces));
}
catch { }
Log($"워크스페이스 '{name}' 저장 ({_tabs.Tabs.Count} 탭)");
}
private void SwitchWorkspace(WorkspaceSnapshot ws)
{
ExitSplit();
_tabs.SwitchWorkspace(ws);
Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)");
}
// ---- 카드 액션 ----
private void CardDownload_Click(object sender, RoutedEventArgs e)
{