1
0
Fork 0

feat(ui): elevate cockpit ergonomics, add search watermark, queue selection styling, and shortcut bindings

This commit is contained in:
Yun Chan 2026-09-05 12:44:23 +09:00
parent 4ca7352dc3
commit 388d6707d0
6 changed files with 462 additions and 23 deletions

3
.gitignore vendored
View file

@ -47,6 +47,9 @@ src/Everything2Everything.Shell/Everythi*/
# .NET artifacts dir from BuildMsix.ps1 # .NET artifacts dir from BuildMsix.ps1
artifacts/ artifacts/
# test assets
test_assets/
# NuGet packages restore + nuget.exe cache # NuGet packages restore + nuget.exe cache
packages/ packages/
tools/* tools/*

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4" xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5" xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" IgnorableNamespaces="uap rescap desktop desktop4 desktop5 com"> <Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4" xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5" xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" IgnorableNamespaces="uap rescap desktop desktop4 desktop5 com">
<Identity Name="Everything2Everything.YunChan" Publisher="CN=Everything2EverythingDev" Version="1.0.18.0" ProcessorArchitecture="x64" /> <Identity Name="Everything2Everything.YunChan" Publisher="CN=Everything2EverythingDev" Version="1.0.20.0" ProcessorArchitecture="x64" />
<Properties> <Properties>
<DisplayName>Everything2Everything</DisplayName> <DisplayName>Everything2Everything</DisplayName>
<PublisherDisplayName>YunChan</PublisherDisplayName> <PublisherDisplayName>YunChan</PublisherDisplayName>

View file

@ -26,6 +26,8 @@
<Window.InputBindings> <Window.InputBindings>
<KeyBinding Key="O" Modifiers="Ctrl" Command="{Binding AddFilesCommand, RelativeSource={RelativeSource AncestorType=Window}}"/> <KeyBinding Key="O" Modifiers="Ctrl" Command="{Binding AddFilesCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<KeyBinding Key="Enter" Modifiers="Ctrl" Command="{Binding ProcessQueueCommand, RelativeSource={RelativeSource AncestorType=Window}}"/> <KeyBinding Key="Enter" Modifiers="Ctrl" Command="{Binding ProcessQueueCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<KeyBinding Key="A" Modifiers="Ctrl" Command="{Binding SelectAllQueueCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<KeyBinding Key="Delete" Command="{Binding DeleteSelectedQueueCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<KeyBinding Key="Escape" Command="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=Window}}"/> <KeyBinding Key="Escape" Command="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<KeyBinding Key="F5" Command="{Binding RefreshCommand, RelativeSource={RelativeSource AncestorType=Window}}"/> <KeyBinding Key="F5" Command="{Binding RefreshCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
</Window.InputBindings> </Window.InputBindings>
@ -174,7 +176,7 @@
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto" Padding="16,16,16,16"> <ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto" Padding="16,16,16,16">
<StackPanel> <StackPanel>
<!-- Smart Conversion Deck: Output Format + Dynamic Presets + Live Spec Chips + Quality --> <!-- Smart Conversion Deck: Output Format + Dynamic Presets + Live Spec Chips + Quality -->
<Border Style="{StaticResource FsDoubleBezelShellStyle}" Margin="0,0,0,16"> <Border Style="{StaticResource FsDoubleBezelShellStyle}" Margin="0,0,0,12">
<Border Style="{StaticResource FsDoubleBezelCoreStyle}"> <Border Style="{StaticResource FsDoubleBezelCoreStyle}">
<StackPanel> <StackPanel>
<Grid Margin="0,0,0,12"> <Grid Margin="0,0,0,12">
@ -217,6 +219,7 @@
</Grid> </Grid>
<TextBlock x:Name="OutputFormatHint" Margin="0,0,0,12" <TextBlock x:Name="OutputFormatHint" Margin="0,0,0,12"
Style="{StaticResource FsCaptionStyle}" Style="{StaticResource FsCaptionStyle}"
TextWrapping="Wrap"
Text="모든 입력에서 변환 가능한 형식이 표시됩니다"/> Text="모든 입력에서 변환 가능한 형식이 표시됩니다"/>
<Border Height="1" Background="{StaticResource FsBorderSubtle}" Margin="0,0,0,12"/> <Border Height="1" Background="{StaticResource FsBorderSubtle}" Margin="0,0,0,12"/>
@ -464,16 +467,16 @@
</Border> </Border>
<!-- Card 3: Output Destination & Conflict Rule --> <!-- Card 3: Output Destination & Conflict Rule -->
<Border Style="{StaticResource FsDoubleBezelShellStyle}" Margin="0,0,0,16"> <Border Style="{StaticResource FsDoubleBezelShellStyle}" Margin="0,0,0,12">
<Border Style="{StaticResource FsDoubleBezelCoreStyle}"> <Border Style="{StaticResource FsDoubleBezelCoreStyle}">
<StackPanel> <StackPanel>
<TextBlock Text="저장 위치 및 충돌 규칙" Style="{StaticResource FsLabelStyle}"/> <TextBlock Text="저장 위치 및 충돌 규칙" Style="{StaticResource FsLabelStyle}"/>
<Grid Margin="0,10,0,0"> <Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBox x:Name="OutputPathTextBox" <TextBox x:Name="OutputPathTextBox"
Style="{StaticResource FsPathInputStyle}" Style="{StaticResource FsPathInputStyle}"
Text="{Binding Options.CustomOutputDirectory, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Text="{Binding Options.CustomOutputDirectory, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
ToolTip="비워두면 원본 폴더 옆 서브폴더에 저장"/> ToolTip="비워두면 원본 폴더 옆 서브폴더에 저장"/>
@ -481,11 +484,11 @@
Content="…" Style="{StaticResource FsSecondaryButtonStyle}" Content="…" Style="{StaticResource FsSecondaryButtonStyle}"
Command="{Binding PickOutputFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/> Command="{Binding PickOutputFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
</Grid> </Grid>
<TextBlock x:Name="OutputDestHint" Margin="0,6,0,0" <TextBlock x:Name="OutputDestHint" Margin="0,5,0,0"
Style="{StaticResource FsCaptionStyle}" Style="{StaticResource FsCaptionStyle}"
Text="비워두면 원본 옆 _jpg 폴더에 저장됩니다"/> Text="비워두면 원본 옆 _jpg 폴더에 저장됩니다"/>
<Border Height="1" Background="{StaticResource FsBorderSubtle}" Margin="0,14,0,14"/> <Border Height="1" Background="{StaticResource FsBorderSubtle}" Margin="0,8,0,8"/>
<TextBlock Text="파일 충돌 해결" Style="{StaticResource FsLabelStyle}"/> <TextBlock Text="파일 충돌 해결" Style="{StaticResource FsLabelStyle}"/>
<Border Margin="0,8,0,0" <Border Margin="0,8,0,0"
@ -592,7 +595,7 @@
<!-- Sidebar footer --> <!-- Sidebar footer -->
<Border Grid.Row="1" BorderBrush="{StaticResource FsBorderSubtle}" <Border Grid.Row="1" BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="0,1,0,0" Padding="24"> BorderThickness="0,1,0,0" Padding="16,12">
<StackPanel> <StackPanel>
<TextBlock x:Name="CapabilityStatusText" Margin="0,0,0,12" <TextBlock x:Name="CapabilityStatusText" Margin="0,0,0,12"
Style="{StaticResource FsCaptionStyle}" Style="{StaticResource FsCaptionStyle}"
@ -666,16 +669,56 @@
BorderThickness="0,0,1,1" Padding="16,8"> BorderThickness="0,0,1,1" Padding="16,8">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="160"/> <ColumnDefinition Width="175"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Search Box --> <!-- Search Box with Live Watermark & Clear Button -->
<Grid Grid.Column="0"> <Grid Grid.Column="0" Width="175" VerticalAlignment="Center">
<TextBox x:Name="SearchBox" Style="{StaticResource FsPathInputStyle}" <TextBox x:Name="SearchBox" Style="{StaticResource FsPathInputStyle}"
Padding="30,5,8,5" Text="{Binding SearchText, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=PropertyChanged}" Padding="28,5,24,5" Text="{Binding SearchText, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=PropertyChanged}"
ToolTip="파일명 또는 확장자로 검색"/> ToolTip="파일명 또는 확장자로 검색" VerticalAlignment="Center"/>
<ui:SymbolIcon Symbol="Search24" FontSize="13" Foreground="{StaticResource FsTextTertiary}" <ui:SymbolIcon Symbol="Search24" FontSize="13" Foreground="{StaticResource FsTextTertiary}"
HorizontalAlignment="Left" VerticalAlignment="Center" Margin="9,0,0,0" IsHitTestVisible="False"/> HorizontalAlignment="Left" VerticalAlignment="Center" Margin="8,0,0,0" IsHitTestVisible="False"/>
<TextBlock x:Name="SearchPlaceholderText"
Text="파일명 검색…"
Foreground="{StaticResource FsTextTertiary}"
VerticalAlignment="Center" Margin="28,0,0,0"
IsHitTestVisible="False">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource FsCaptionStyle}">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Text, ElementName=SearchBox}" Value="">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding Text, ElementName=SearchBox}" Value="{x:Null}">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<Button x:Name="SearchClearButton"
Width="20" Height="20"
HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="0,0,6,0"
ToolTip="검색어 지우기"
Command="{Binding ClearSearchCommand, RelativeSource={RelativeSource AncestorType=Window}}">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource FsIconButtonStyle}">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Text, ElementName=SearchBox}" Value="">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
<DataTrigger Binding="{Binding Text, ElementName=SearchBox}" Value="{x:Null}">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
<ui:SymbolIcon Symbol="Dismiss24" FontSize="10" Foreground="{StaticResource FsTextTertiary}" VerticalAlignment="Center"/>
</Button>
</Grid> </Grid>
<!-- Category Filter Chips & Inspector Toggle --> <!-- Category Filter Chips & Inspector Toggle -->
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
@ -751,6 +794,28 @@
<CheckBox x:Name="BatchSelectAllCheck" Content="전체 선택" VerticalAlignment="Center" <CheckBox x:Name="BatchSelectAllCheck" Content="전체 선택" VerticalAlignment="Center"
Command="{Binding BatchSelectAllCommand, RelativeSource={RelativeSource AncestorType=Window}}" Command="{Binding BatchSelectAllCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding IsChecked, RelativeSource={RelativeSource Self}}"/> CommandParameter="{Binding IsChecked, RelativeSource={RelativeSource Self}}"/>
<!-- Live Queue Telemetry Capsule (Cockpit Summary) -->
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<Border Background="#10FFFFFF" BorderBrush="{StaticResource FsBorderHairline}"
BorderThickness="1" CornerRadius="12" Padding="10,3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="QueueSummaryCountText" Text="0개 항목"
FontFamily="{StaticResource FsFontSans}" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource FsTextPrimary}" VerticalAlignment="Center"/>
<TextBlock Text=" · " FontSize="11" Foreground="{StaticResource FsTextTertiary}" VerticalAlignment="Center"/>
<TextBlock x:Name="QueueSummarySizeText" Text="0 B"
FontFamily="{StaticResource FsFontMono}" FontSize="11"
Foreground="{StaticResource FsAccentCyan}" VerticalAlignment="Center"/>
<Border x:Name="QueueSelectedBadge" Visibility="Collapsed"
Margin="8,0,0,0" Background="{StaticResource FsAccentCyanBg}"
CornerRadius="6" Padding="6,1" VerticalAlignment="Center">
<TextBlock x:Name="QueueSelectedText" Text="0개 선택됨"
FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource FsAccentCyan}" VerticalAlignment="Center"/>
</Border>
</StackPanel>
</Border>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button Margin="0,0,8,0" Padding="10,4" Style="{StaticResource FsSecondaryButtonStyle}" <Button Margin="0,0,8,0" Padding="10,4" Style="{StaticResource FsSecondaryButtonStyle}"
Command="{Binding BatchRemoveSelectedCommand, RelativeSource={RelativeSource AncestorType=Window}}" Command="{Binding BatchRemoveSelectedCommand, RelativeSource={RelativeSource AncestorType=Window}}"
@ -843,9 +908,18 @@
ItemsSource="{Binding ActiveQueue, RelativeSource={RelativeSource AncestorType=Window}}"> ItemsSource="{Binding ActiveQueue, RelativeSource={RelativeSource AncestorType=Window}}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<!-- row hover: FsListRowStyle Neo Glass 카드 + 좌측 형식색 accent bar --> <!-- row hover: FsListRowStyle Neo Glass 카드 + 좌측 형식색 accent bar + IsSelected 강조 -->
<Border x:Name="QueueRow" Style="{StaticResource FsListRowStyle}" Padding="0" <Border x:Name="QueueRow" Padding="0" Cursor="Hand">
Cursor="Hand"> <Border.Style>
<Style TargetType="Border" BasedOn="{StaticResource FsListRowStyle}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter Property="BorderBrush" Value="#6038BDF8"/>
<Setter Property="Background" Value="#162033"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<i:Interaction.Triggers> <i:Interaction.Triggers>
<i:EventTrigger EventName="PreviewMouseLeftButtonUp"> <i:EventTrigger EventName="PreviewMouseLeftButtonUp">
<i:InvokeCommandAction Command="{Binding QueueRowCommand, RelativeSource={RelativeSource AncestorType=Window}}" PassEventArgsToCommand="True"/> <i:InvokeCommandAction Command="{Binding QueueRowCommand, RelativeSource={RelativeSource AncestorType=Window}}" PassEventArgsToCommand="True"/>
@ -939,6 +1013,52 @@
<!-- Past Results view --> <!-- Past Results view -->
<Grid x:Name="PastResultsContainer" Visibility="Collapsed"> <Grid x:Name="PastResultsContainer" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Telemetry Header Bar -->
<Border x:Name="PastResultsHeaderBar" Grid.Row="0"
Background="{StaticResource FsBgPanel}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="0,0,0,1" Padding="24,8">
<Grid VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Background="#10FFFFFF" BorderBrush="{StaticResource FsBorderHairline}"
BorderThickness="1" CornerRadius="12" Padding="10,4" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<ui:SymbolIcon Symbol="CheckmarkCircle24" FontSize="13" Foreground="{StaticResource FsAccentGreen}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Text="누적 변환: " Style="{StaticResource FsCaptionStyle}" VerticalAlignment="Center"/>
<TextBlock x:Name="PastTotalCountText" Text="0개 파일"
FontFamily="{StaticResource FsFontMono}" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource FsTextPrimary}" VerticalAlignment="Center"/>
<TextBlock Text=" · " FontSize="11" Foreground="{StaticResource FsTextTertiary}" VerticalAlignment="Center"/>
<TextBlock Text="총 절감: " Style="{StaticResource FsCaptionStyle}" VerticalAlignment="Center"/>
<TextBlock x:Name="PastTotalSavingsText" Text="0 B"
FontFamily="{StaticResource FsFontMono}" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource FsAccentGreen}" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button Padding="10,4" Style="{StaticResource FsSecondaryButtonStyle}"
Command="{Binding ExportLogCommand, RelativeSource={RelativeSource AncestorType=Window}}"
ToolTip="변환 기록 CSV/JSON 내보내기">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<ui:SymbolIcon Symbol="ArrowDownload24" FontSize="12" VerticalAlignment="Center" Margin="0,0,4,0"/>
<TextBlock Text="내보내기" FontSize="11" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="1">
<!-- 빈 상태 안내 (Empty State) --> <!-- 빈 상태 안내 (Empty State) -->
<Grid x:Name="PastResultsEmpty" Visibility="Collapsed"> <Grid x:Name="PastResultsEmpty" Visibility="Collapsed">
<Border Background="{StaticResource FsBgBase}" Padding="24"> <Border Background="{StaticResource FsBgBase}" Padding="24">
@ -1106,6 +1226,7 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</ScrollViewer> </ScrollViewer>
</Grid>
</Grid> </Grid>
</Grid> </Grid>
@ -1211,6 +1332,60 @@
BorderBrush="{StaticResource FsBorderSubtle}" BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="0,1,0,0"> BorderThickness="0,1,0,0">
<StackPanel> <StackPanel>
<!-- Conversion Pipeline Card (원본 -> 변환 대상 예상) -->
<Border x:Name="ConversionPipelineCard"
Margin="0,0,0,12"
Background="{StaticResource FsBgSurface}"
BorderBrush="{StaticResource FsBorderHairline}"
BorderThickness="1" CornerRadius="8" Padding="12,10">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="현재 파일" Style="{StaticResource FsCaptionStyle}" FontSize="10" VerticalAlignment="Center"/>
<TextBlock x:Name="PipelineSourceText" Text="—"
FontFamily="{StaticResource FsFontMono}" FontSize="12"
FontWeight="SemiBold" Foreground="{StaticResource FsTextPrimary}"
Margin="0,2,0,0" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
</StackPanel>
<ui:SymbolIcon Grid.Column="1" Symbol="ArrowRight24" FontSize="14"
Foreground="{StaticResource FsAccentCyan}"
VerticalAlignment="Center" Margin="8,0"/>
<StackPanel Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right">
<TextBlock Text="변환 대상" Style="{StaticResource FsCaptionStyle}" FontSize="10" HorizontalAlignment="Right" VerticalAlignment="Center"/>
<TextBlock x:Name="PipelineTargetText" Text="—"
FontFamily="{StaticResource FsFontMono}" FontSize="12"
FontWeight="SemiBold" Foreground="{StaticResource FsAccentCyan}"
Margin="0,2,0,0" HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
</StackPanel>
</Grid>
<Border Grid.Row="1" Margin="0,8,0,0" Background="#0CFFFFFF"
CornerRadius="4" Padding="8,4" VerticalAlignment="Center">
<Grid VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="PipelineLossText" Text="최적화 변환 준비"
FontSize="11" Foreground="{StaticResource FsAccentGreen}"
VerticalAlignment="Center"/>
<TextBlock x:Name="PipelineEstimatedText" Grid.Column="1"
Text="실시간 엔진 매칭"
FontFamily="{StaticResource FsFontMono}" FontSize="11"
Foreground="{StaticResource FsAccentGreen}" VerticalAlignment="Center"/>
</Grid>
</Border>
</Grid>
</Border>
<TextBlock x:Name="PreviewFileName" <TextBlock x:Name="PreviewFileName"
Style="{StaticResource FsBodyStyle}" Style="{StaticResource FsBodyStyle}"
FontWeight="SemiBold" TextWrapping="NoWrap" FontWeight="SemiBold" TextWrapping="NoWrap"
@ -1268,9 +1443,11 @@
<ColumnDefinition Width="8"/> <ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Button Grid.Column="0" <Button x:Name="PreviewOpenButton"
Grid.Column="0"
Style="{StaticResource FsSecondaryButtonStyle}" Style="{StaticResource FsSecondaryButtonStyle}"
Padding="12,8" Padding="12,8"
IsEnabled="{Binding HasSelectedItem, RelativeSource={RelativeSource AncestorType=Window}}"
Command="{Binding PreviewOpenFileCommand, RelativeSource={RelativeSource AncestorType=Window}}" Command="{Binding PreviewOpenFileCommand, RelativeSource={RelativeSource AncestorType=Window}}"
ToolTip="기본 프로그램으로 파일 열기"> ToolTip="기본 프로그램으로 파일 열기">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" VerticalAlignment="Center">
@ -1278,9 +1455,11 @@
<TextBlock Text="열기" VerticalAlignment="Center"/> <TextBlock Text="열기" VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
</Button> </Button>
<Button Grid.Column="2" <Button x:Name="PreviewRevealButton"
Grid.Column="2"
Style="{StaticResource FsSecondaryButtonStyle}" Style="{StaticResource FsSecondaryButtonStyle}"
Padding="12,8" Padding="12,8"
IsEnabled="{Binding HasSelectedItem, RelativeSource={RelativeSource AncestorType=Window}}"
Command="{Binding PreviewOpenFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}" Command="{Binding PreviewOpenFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"
ToolTip="탐색기에서 파일 위치 열기"> ToolTip="탐색기에서 파일 위치 열기">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" VerticalAlignment="Center">

View file

@ -18,8 +18,30 @@ using LossClass = Everything2Everything.Core.Providers.LossClass;
namespace Everything2Everything.App.Views; namespace Everything2Everything.App.Views;
public partial class MainWindow : Wpf.Ui.Controls.FluentWindow public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyChanged
{ {
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
private bool _hasSelectedItem;
public bool HasSelectedItem
{
get => _hasSelectedItem;
set
{
if (_hasSelectedItem != value)
{
_hasSelectedItem = value;
OnPropertyChanged();
}
}
}
public ICommand ClearSearchCommand { get; }
public ICommand SelectAllQueueCommand => BatchSelectAllCommand;
public ICommand DeleteSelectedQueueCommand => BatchRemoveSelectedCommand;
private readonly ConversionEngine _engine; private readonly ConversionEngine _engine;
private readonly ISettingsStore _settings; private readonly ISettingsStore _settings;
private readonly OptionsViewModel _options = new(); private readonly OptionsViewModel _options = new();
@ -153,6 +175,11 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
DragLeaveCommand = new RelayCommand(_ => DropHintOverlay.Visibility = Visibility.Collapsed); DragLeaveCommand = new RelayCommand(_ => DropHintOverlay.Visibility = Visibility.Collapsed);
DropCommand = new RelayCommand(p => HandleFilesDropped(p as DragEventArgs)); DropCommand = new RelayCommand(p => HandleFilesDropped(p as DragEventArgs));
QueueRowCommand = new RelayCommand(p => HandleQueueRowClick(p as MouseButtonEventArgs)); QueueRowCommand = new RelayCommand(p => HandleQueueRowClick(p as MouseButtonEventArgs));
ClearSearchCommand = new RelayCommand(_ =>
{
SearchText = "";
if (SearchBox is not null) SearchBox.Text = "";
});
PastRowCommand = new RelayCommand(p => HandlePastRowClick(p as MouseButtonEventArgs)); PastRowCommand = new RelayCommand(p => HandlePastRowClick(p as MouseButtonEventArgs));
InitializeComponent(); InitializeComponent();
@ -302,7 +329,9 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
foreach (var path in paths) foreach (var path in paths)
{ {
if (!File.Exists(path) || existing.Contains(path)) continue; if (!File.Exists(path) || existing.Contains(path)) continue;
_activeQueue.Add(QueueItem.FromPath(path)); var item = QueueItem.FromPath(path);
item.PropertyChanged += OnQueueItemPropertyChanged;
_activeQueue.Add(item);
} }
UpdateBadges(); UpdateBadges();
UpdateProcessQueueButton(); UpdateProcessQueueButton();
@ -316,9 +345,18 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
} }
} }
private void OnQueueItemPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(QueueItem.IsSelected))
{
UpdateQueueSummary();
}
}
private void RemoveQueueItem(QueueItem? item) private void RemoveQueueItem(QueueItem? item)
{ {
if (item is null) return; if (item is null) return;
item.PropertyChanged -= OnQueueItemPropertyChanged;
_activeQueue.Remove(item); _activeQueue.Remove(item);
UpdateBadges(); UpdateBadges();
UpdateProcessQueueButton(); UpdateProcessQueueButton();
@ -350,6 +388,40 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
var count = _pastResults.Sum(g => g.Entries.Count); var count = _pastResults.Sum(g => g.Entries.Count);
TabPastBadge.Text = count.ToString(CultureInfo.InvariantCulture); TabPastBadge.Text = count.ToString(CultureInfo.InvariantCulture);
UpdatePastResultsVisibility(); UpdatePastResultsVisibility();
UpdateQueueSummary();
UpdatePastResultsTelemetry();
}
private void UpdateQueueSummary()
{
if (QueueSummaryCountText is null || QueueSummarySizeText is null) return;
var count = _activeQueue.Count;
long totalBytes = _activeQueue.Sum(q => q.SourceSizeBytes);
QueueSummaryCountText.Text = $"총 {count}개 항목";
QueueSummarySizeText.Text = HumanizeBytes(totalBytes);
var selectedCount = _activeQueue.Count(q => q.IsSelected);
if (QueueSelectedBadge is not null && QueueSelectedText is not null)
{
if (selectedCount > 0)
{
QueueSelectedBadge.Visibility = Visibility.Visible;
QueueSelectedText.Text = $"{selectedCount}개 선택됨";
}
else
{
QueueSelectedBadge.Visibility = Visibility.Collapsed;
}
}
}
private void UpdatePastResultsTelemetry()
{
if (PastTotalCountText is null || PastTotalSavingsText is null) return;
var totalCount = _pastResults.Sum(g => g.Entries.Count);
var totalSavings = _pastResults.Sum(g => g.SessionSavingsBytes);
PastTotalCountText.Text = $"{totalCount}개 파일";
PastTotalSavingsText.Text = HumanizeBytes(totalSavings);
} }
private void UpdateProcessQueueButton() private void UpdateProcessQueueButton()
@ -523,10 +595,44 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
var info = FileInspectorBuilder.Build(filePath); var info = FileInspectorBuilder.Build(filePath);
PreviewFileName.Text = string.IsNullOrEmpty(fileName) ? info.FileName : fileName; PreviewFileName.Text = string.IsNullOrEmpty(fileName) ? info.FileName : fileName;
PreviewFilePath.Text = string.IsNullOrEmpty(filePath) ? info.FullPath : filePath; PreviewFilePath.Text = string.IsNullOrEmpty(filePath) ? info.FullPath : filePath;
PreviewFormatText.Text = string.IsNullOrEmpty(formatLabel) || formatLabel == "—" ? info.Extension.TrimStart('.').ToUpperInvariant() : formatLabel; var fmt = string.IsNullOrEmpty(formatLabel) || formatLabel == "—" ? info.Extension.TrimStart('.').ToUpperInvariant() : formatLabel;
PreviewSizeText.Text = string.IsNullOrEmpty(sizeText) || sizeText == "—" ? info.FormattedSize : sizeText; var sz = string.IsNullOrEmpty(sizeText) || sizeText == "—" ? info.FormattedSize : sizeText;
PreviewFormatText.Text = fmt;
PreviewSizeText.Text = sz;
PreviewDimText.Text = info.DimensionsOrMeta; PreviewDimText.Text = info.DimensionsOrMeta;
PreviewPageText.Text = info.Category.ToKoreanLabel(); PreviewPageText.Text = info.Category.ToKoreanLabel();
UpdateConversionPipelineCard(fmt, sz);
HasSelectedItem = !string.IsNullOrEmpty(filePath);
}
private void UpdateConversionPipelineCard(string? formatLabel, string? sizeText)
{
if (ConversionPipelineCard is null || PipelineSourceText is null || PipelineTargetText is null) return;
if (string.IsNullOrEmpty(formatLabel) || formatLabel == "—")
{
PipelineSourceText.Text = "선택 대기";
PipelineTargetText.Text = (SelectedOutputExtension ?? ".jpg").TrimStart('.').ToUpperInvariant();
if (PipelineLossText is not null) PipelineLossText.Text = "파일을 선택하면 변환 정보 표시";
if (PipelineEstimatedText is not null) PipelineEstimatedText.Text = "대기 중";
return;
}
PipelineSourceText.Text = $"{formatLabel} · {sizeText ?? ""}";
var targetExt = (SelectedOutputExtension ?? ".jpg").TrimStart('.').ToUpperInvariant();
PipelineTargetText.Text = targetExt;
var isSame = string.Equals(formatLabel, targetExt, StringComparison.OrdinalIgnoreCase);
if (isSame)
{
if (PipelineLossText is not null) PipelineLossText.Text = "무손실 재압축 및 메타 정돈";
if (PipelineEstimatedText is not null) PipelineEstimatedText.Text = "최적화";
}
else
{
if (PipelineLossText is not null) PipelineLossText.Text = $"{formatLabel} → {targetExt} 변환";
if (PipelineEstimatedText is not null) PipelineEstimatedText.Text = "실시간 매칭";
}
} }
private void ShowPreviewLoading() private void ShowPreviewLoading()
@ -751,6 +857,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
{ {
bool select = parameter is true; bool select = parameter is true;
BatchQueueService.SetSelectionAll(_activeQueue, select); BatchQueueService.SetSelectionAll(_activeQueue, select);
UpdateQueueSummary();
} }
private void BatchRemoveSelected() private void BatchRemoveSelected()
@ -1334,6 +1441,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
UpdateCombineState(keepExt); UpdateCombineState(keepExt);
UpdateProcessQueueButton(); UpdateProcessQueueButton();
UpdateSmartPresetsForFormat(keepExt); UpdateSmartPresetsForFormat(keepExt);
UpdateConversionPipelineCard(PreviewFormatText?.Text, PreviewSizeText?.Text);
if (OutputFormatHint is not null) if (OutputFormatHint is not null)
{ {
@ -1379,6 +1487,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
UpdateQualityPanelForFormat(ext); UpdateQualityPanelForFormat(ext);
UpdateOutputDestHint(ext); UpdateOutputDestHint(ext);
UpdateSmartPresetsForFormat(ext); UpdateSmartPresetsForFormat(ext);
UpdateConversionPipelineCard(PreviewFormatText?.Text, PreviewSizeText?.Text);
} }
} }

View file

@ -947,6 +947,137 @@ public class DesignAuditAstTests
Assert.True(int.TryParse(widthVal, out var w) && w <= 180, Assert.True(int.TryParse(widthVal, out var w) && w <= 180,
$"SearchBox 컬럼 폭은 180px 이하여야 필터 버튼들이 잘리지 않습니다. 현재: {widthVal}"); $"SearchBox 컬럼 폭은 180px 이하여야 필터 버튼들이 잘리지 않습니다. 현재: {widthVal}");
} }
[Fact]
public void BatchActionBar_MustInclude_LiveCockpitSummary()
{
// BatchActionBar는 단순 버튼 나열이 아니라 큐의 총 파일 개수 및 총 데이터 용량을 한눈에 보여주는
// QueueSummaryCountText 및 QueueSummarySizeText 콕핏 텔레메트리 요소를 포함해야 한다.
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(file));
var batchBar = doc.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "BatchActionBar");
Assert.NotNull(batchBar);
var summaryCount = batchBar.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "QueueSummaryCountText");
var summarySize = batchBar.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "QueueSummarySizeText");
Assert.NotNull(summaryCount);
Assert.NotNull(summarySize);
}
[Fact]
public void Inspector_MustInclude_ConversionPipelineCard()
{
// 미리보기 패널(Inspector)은 원본과 대상 형식 간 변환 경로 및 예상 절감 효과를 즉시 시각화하는
// ConversionPipelineCard 컨테이너를 포함해야 한다.
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(file));
var pipelineCard = doc.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "ConversionPipelineCard");
Assert.NotNull(pipelineCard);
var sourceText = pipelineCard.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PipelineSourceText");
var targetText = pipelineCard.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PipelineTargetText");
Assert.NotNull(sourceText);
Assert.NotNull(targetText);
}
[Fact]
public void PastResults_MustInclude_TelemetryHeaderBar()
{
// 변환 기록 뷰(PastResultsContainer)는 단순 리스트 노출 전에 누적 변환 수량 및 총 절감량을 요약하고
// 빠른 내보내기 액션을 제공하는 PastResultsHeaderBar 툴바를 포함해야 한다.
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(file));
var container = doc.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PastResultsContainer");
Assert.NotNull(container);
var headerBar = container.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PastResultsHeaderBar");
Assert.NotNull(headerBar);
}
[Fact]
public void Sidebar_OutputFormatHint_MustWrapText()
{
// 320px 좁은 사이드바 폭에서도 설명 문구가 '자동 필...'처럼 잘리지 않도록
// OutputFormatHint는 TextWrapping="Wrap"을 선언해야 한다.
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(file));
var hint = doc.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "OutputFormatHint");
Assert.NotNull(hint);
var textWrapping = hint.Attribute("TextWrapping")?.Value;
Assert.Equal("Wrap", textWrapping);
}
[Fact]
public void SearchBox_MustInclude_WatermarkPlaceholder_And_ClearButton()
{
// 검색창은 플레이스홀더 워터마크(SearchPlaceholderText)와 입력 내용 원클릭 지우기 버튼(SearchClearButton)을 포함해야 한다.
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(file));
var placeholder = doc.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "SearchPlaceholderText");
var clearBtn = doc.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "SearchClearButton");
Assert.NotNull(placeholder);
Assert.NotNull(clearBtn);
}
[Fact]
public void Inspector_ActionButtons_MustBind_IsEnabled_To_Selection()
{
// 파일이 선택되지 않았을 때 무의미한 클릭을 방지하기 위해
// 미리보기 하단의 열기(PreviewOpenButton) 및 폴더보기(PreviewRevealButton) 버튼은 IsEnabled 바인딩 또는 제어를 가져야 한다.
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(file));
var openBtn = doc.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PreviewOpenButton");
var revealBtn = doc.Descendants().FirstOrDefault(e =>
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PreviewRevealButton");
Assert.NotNull(openBtn);
Assert.NotNull(revealBtn);
var openEnabled = openBtn.Attribute("IsEnabled")?.Value;
var revealEnabled = revealBtn.Attribute("IsEnabled")?.Value;
Assert.NotNull(openEnabled);
Assert.NotNull(revealEnabled);
}
[Fact]
public void Window_InputBindings_MustInclude_SelectAll_And_Delete()
{
// Raycast / Linear 급 생산성을 위해 전체 선택(Ctrl+A) 및 선택 항목 삭제(Delete) 단축키가 Window.InputBindings에 등록되어야 한다.
var file = Path.Combine(ViewsDir, "MainWindow.xaml");
var doc = XDocument.Parse(File.ReadAllText(file));
var inputBindings = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "Window.InputBindings");
Assert.NotNull(inputBindings);
var keyBindings = inputBindings.Elements().Where(e => e.Name.LocalName == "KeyBinding").ToList();
bool hasCtrlA = keyBindings.Any(kb => kb.Attribute("Key")?.Value == "A" && kb.Attribute("Modifiers")?.Value == "Ctrl");
bool hasDelete = keyBindings.Any(kb => kb.Attribute("Key")?.Value == "Delete");
Assert.True(hasCtrlA, "Window.InputBindings에 Ctrl+A 단축키가 등록되어야 합니다.");
Assert.True(hasDelete, "Window.InputBindings에 Delete 단축키가 등록되어야 합니다.");
}
} }

View file

@ -416,6 +416,19 @@ public class UnifiedTitleBarTests
Assert.Equal("50%", q2.DisplayStateText); Assert.Equal("50%", q2.DisplayStateText);
Assert.Equal("변환 완료", q3.DisplayStateText); Assert.Equal("변환 완료", q3.DisplayStateText);
var summaryCount = (TextBlock)window.FindName("QueueSummaryCountText");
var summarySize = (TextBlock)window.FindName("QueueSummarySizeText");
Assert.NotNull(summaryCount);
Assert.NotNull(summarySize);
Assert.Equal("총 3개 항목", summaryCount.Text);
var pipeSource = (TextBlock)window.FindName("PipelineSourceText");
var pipeTarget = (TextBlock)window.FindName("PipelineTargetText");
Assert.NotNull(pipeSource);
Assert.NotNull(pipeTarget);
Assert.Equal("JPG · 4.2 MB", pipeSource.Text);
Assert.Equal("JPG", pipeTarget.Text);
try try
{ {
var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(1280, 960, 96, 96, System.Windows.Media.PixelFormats.Pbgra32); var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(1280, 960, 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
@ -583,6 +596,10 @@ public class UnifiedTitleBarTests
Assert.Equal(Visibility.Collapsed, pastResultsEmpty.Visibility); Assert.Equal(Visibility.Collapsed, pastResultsEmpty.Visibility);
Assert.Equal(Visibility.Visible, pastResultsView.Visibility); Assert.Equal(Visibility.Visible, pastResultsView.Visibility);
var pastTotalCount = (TextBlock)window.FindName("PastTotalCountText");
Assert.NotNull(pastTotalCount);
Assert.Equal("2개 파일", pastTotalCount.Text);
// Render populated state // Render populated state
try try
{ {