1
0
Fork 0

ux: 우클릭 → 메인 윈도우 통합 + Preview를 우측 컬럼으로 승격

우클릭 진입 통합
- App.OnStartup의 dialog 분기에서 ConvertWindow 대신 MainWindow를 띄우면서
  파일을 Active Queue에 자동 추가, 첫 항목 자동 Preview 로드.
- 사용자 시점에서 우클릭 → "JPEG로 변환…" 클릭 시 작은 다이얼로그가
  아니라 풀스크린 FormatShift 메인 화면이 그대로 노출.
- ConvertWindow 자체는 FormatShift 토큰으로 통일했지만 더 이상 호출되지
  않음 (코드는 향후 활용 위해 보존).

Preview를 탭 → 우측 메인 컬럼으로 승격
- 메인 영역 그리드를 1 column → 2 column 으로 분할: 좌측 *(min 360),
  우측 380px 고정 Preview 컬럼.
- 탭은 Active Queue / Past Results 두 개만 (Preview 탭과 무의미한 0 뱃지
  제거). Preview는 항상 보임.
- Active Queue 행 클릭 → 우측 Preview에 즉시 표시 (탭 전환 없음).
- Past Results 행도 클릭 가능: 실제 파일이면 미리보기, 데모/소실 파일이면
  안내 문구.
- Drop hint overlay는 ColumnSpan=2로 양쪽 컬럼 모두 덮음.

이벤트 처리 개선
- 행 Border에 PreviewMouseLeftButtonUp(tunnel) 사용 → 자식 컨트롤이 클릭을
  가로채는 이슈 해소.
- IsInsideButton 헬퍼로 Remove/Open 버튼이 발화시킨 클릭은 행 핸들러에서
  무시하여 의도 충돌 방지.

App.xaml 정리
- FormatShiftTheme.xaml 을 App-level resources에 머지 → 모든 윈도우에서
  Application.Current.FindResource("Fs*") 조회 가능 (이전엔 Window-level
  머지만 해서 ResourceReferenceKeyNotFoundException 발생).

LoadPreview 통합
- LoadPreviewByPathAsync(path, fileName, format, size) 단일 메서드로 통합.
- _selectedPreviewPath 로 Active/Past 두 케이스 모두 Open in Explorer 일관
  처리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yun Chan 2026-05-06 19:26:27 +09:00
parent 01467a4d27
commit 00d1eeff96
6 changed files with 595 additions and 373 deletions

View file

@ -9,6 +9,7 @@
<ResourceDictionary.MergedDictionaries> <ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Dark"/> <ui:ThemesDictionary Theme="Dark"/>
<ui:ControlsDictionary/> <ui:ControlsDictionary/>
<ResourceDictionary Source="Views/FormatShiftTheme.xaml"/>
</ResourceDictionary.MergedDictionaries> </ResourceDictionary.MergedDictionaries>
<!-- Spacing tokens (Fluent 8pt grid) --> <!-- Spacing tokens (Fluent 8pt grid) -->

View file

@ -67,7 +67,7 @@ public partial class App : Application
private void ShowConvertDialog(IReadOnlyList<string> files) private void ShowConvertDialog(IReadOnlyList<string> files)
{ {
var window = new ConvertWindow(Engine, files); var window = new Views.MainWindow(files);
MainWindow = window; MainWindow = window;
window.Show(); window.Show();
} }

View file

@ -3,47 +3,66 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="JPEG로 변환" Title="JPEG로 변환"
Width="640" Height="780" Width="720" Height="780"
MinWidth="520" MinHeight="560" MinWidth="560" MinHeight="560"
ExtendsContentIntoTitleBar="True" ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica" WindowBackdropType="None"
WindowCornerPreference="Round" WindowCornerPreference="Round"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
AllowDrop="True" AllowDrop="True"
Drop="OnFilesDropped" Drop="OnFilesDropped"
DragOver="OnDragOver"> DragOver="OnDragOver"
<Grid> TextOptions.TextFormattingMode="Display"
UseLayoutRounding="True">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<Grid Background="{StaticResource FsBgBase}">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <RowDefinition Height="32"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- 0: TitleBar --> <!-- 0: TitleBar (window controls only) -->
<ui:TitleBar Grid.Row="0" Title="JPEG로 변환"/> <ui:TitleBar Grid.Row="0" Title=""/>
<!-- 1: 헤더 (제목 + 파일 카운트) --> <!-- 1: 헤더 -->
<Grid Grid.Row="1" Margin="32,8,32,12"> <Grid Grid.Row="1" Margin="32,8,32,16">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<StackPanel> <StackPanel>
<TextBlock Text="JPEG로 변환" Style="{StaticResource TextTitle}"/> <TextBlock Text="JPEG로 변환"
<TextBlock x:Name="FilesSummaryText" Style="{StaticResource TextCaption}" Margin="0,2,0,0"/> FontFamily="{StaticResource FsFontSans}"
FontSize="20" FontWeight="SemiBold"
Foreground="{StaticResource FsTextPrimary}"/>
<TextBlock x:Name="FilesSummaryText" Margin="0,2,0,0"
Style="{StaticResource FsCaptionStyle}"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<ui:Button Content="추가" Icon="{ui:SymbolIcon Add24}" <Button Content="+ 추가" Margin="0,0,8,0"
Click="OnAddFilesClick" Margin="0,0,8,0"/> Style="{StaticResource FsSecondaryButtonStyle}"
<ui:Button Content="비우기" Icon="{ui:SymbolIcon Delete24}" Click="OnAddFilesClick"/>
Click="OnClearFilesClick"/> <Button Content="비우기"
Style="{StaticResource FsSecondaryButtonStyle}"
Click="OnClearFilesClick"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<!-- 2: 옵션 바 (고정 핵심 옵션 + 고급 expander) --> <!-- 2: 옵션 카드 -->
<ui:Card Grid.Row="2" Margin="32,0,32,12" Padding="16,12"> <Border Grid.Row="2" Margin="32,0,32,16"
Background="{StaticResource FsBgPanel}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1" CornerRadius="8" Padding="20">
<StackPanel> <StackPanel>
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
@ -53,128 +72,194 @@
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- 품질 --> <!-- 품질 -->
<StackPanel Grid.Column="0" Margin="0,0,8,0"> <StackPanel Grid.Column="0" Margin="0,0,12,0">
<TextBlock Text="JPEG 품질" Style="{StaticResource TextCaption}"/> <Grid>
<Grid Margin="0,2,0,0">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="32"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Slider x:Name="QualitySlider" Minimum="50" Maximum="100" Value="92" <TextBlock Text="JPEG 품질" Style="{StaticResource FsLabelStyle}"/>
SmallChange="1" LargeChange="5" VerticalAlignment="Center"/> <Border Grid.Column="1" CornerRadius="4"
<TextBlock Grid.Column="1" Text="{Binding ElementName=QualitySlider, Path=Value, StringFormat={}{0:0}}" Background="{StaticResource FsBgInput}"
FontWeight="SemiBold" VerticalAlignment="Center" HorizontalAlignment="Right"/> BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1" Padding="6,1">
<TextBlock x:Name="QualityValueText"
FontFamily="{StaticResource FsFontMono}" FontSize="11"
Foreground="{StaticResource FsAccentBlue}"
Text="92"/>
</Border>
</Grid> </Grid>
<Slider x:Name="QualitySlider" Margin="0,8,0,0"
Style="{StaticResource FsSliderStyle}"
Minimum="50" Maximum="100" Value="92"
ValueChanged="OnQualityChanged"/>
</StackPanel> </StackPanel>
<!-- 출력 위치 --> <!-- 출력 위치 -->
<StackPanel Grid.Column="1" Margin="8,0,8,0"> <StackPanel Grid.Column="1" Margin="6,0,6,0">
<TextBlock Text="출력 위치" Style="{StaticResource TextCaption}"/> <TextBlock Text="출력 위치" Style="{StaticResource FsLabelStyle}"/>
<ComboBox x:Name="OutputModeCombo" SelectedIndex="0" Margin="0,2,0,0"> <Border Margin="0,8,0,0"
<ComboBoxItem Content="원본 폴더 안 _jpeg 하위 폴더"/> Background="{StaticResource FsBgInput}"
<ComboBoxItem Content="원본과 같은 폴더"/> BorderBrush="{StaticResource FsBorderSubtle}"
<ComboBoxItem Content="사용자 지정 폴더…"/> BorderThickness="1" CornerRadius="6" Padding="2">
</ComboBox> <UniformGrid Rows="1" Columns="3">
<ToggleButton x:Name="OutputSubBtn" Content="하위"
Style="{StaticResource FsSegmentStyle}"
IsChecked="True"
Click="OnOutputSegmentClick" Tag="Sub"
ToolTip="원본 폴더 안 _jpeg 하위 폴더"/>
<ToggleButton x:Name="OutputSameBtn" Content="동일"
Style="{StaticResource FsSegmentStyle}"
Click="OnOutputSegmentClick" Tag="Same"
ToolTip="원본과 같은 폴더"/>
<ToggleButton x:Name="OutputCustomBtn" Content="지정"
Style="{StaticResource FsSegmentStyle}"
Click="OnOutputSegmentClick" Tag="Custom"
ToolTip="사용자 지정 폴더"/>
</UniformGrid>
</Border>
</StackPanel> </StackPanel>
<!-- 충돌 --> <!-- 이름 충돌 -->
<StackPanel Grid.Column="2" Margin="8,0,0,0"> <StackPanel Grid.Column="2" Margin="12,0,0,0">
<TextBlock Text="이름 충돌" Style="{StaticResource TextCaption}"/> <TextBlock Text="이름 충돌" Style="{StaticResource FsLabelStyle}"/>
<ComboBox x:Name="CollisionCombo" SelectedIndex="0" Margin="0,2,0,0"> <Border Margin="0,8,0,0"
<ComboBoxItem Content="번호 붙여 새 파일"/> Background="{StaticResource FsBgInput}"
<ComboBoxItem Content="덮어쓰기"/> BorderBrush="{StaticResource FsBorderSubtle}"
<ComboBoxItem Content="건너뛰기"/> BorderThickness="1" CornerRadius="6" Padding="2">
</ComboBox> <UniformGrid Rows="1" Columns="3">
<ToggleButton x:Name="ConflictRenameBtn" Content="번호"
Style="{StaticResource FsSegmentStyle}"
IsChecked="True"
Click="OnConflictSegmentClick" Tag="Rename"/>
<ToggleButton x:Name="ConflictReplaceBtn" Content="덮어쓰기"
Style="{StaticResource FsSegmentStyle}"
Click="OnConflictSegmentClick" Tag="Replace"/>
<ToggleButton x:Name="ConflictSkipBtn" Content="건너뛰기"
Style="{StaticResource FsSegmentStyle}"
Click="OnConflictSegmentClick" Tag="Skip"/>
</UniformGrid>
</Border>
</StackPanel> </StackPanel>
</Grid> </Grid>
<!-- 사용자 지정 폴더 (옵션) --> <!-- 사용자 지정 폴더 -->
<Grid Margin="0,8,0,0" x:Name="CustomFolderRow" Visibility="Collapsed"> <Grid Margin="0,12,0,0" x:Name="CustomFolderRow" Visibility="Collapsed">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBox x:Name="CustomFolderTextBox"/> <TextBox x:Name="CustomFolderTextBox"
<ui:Button Grid.Column="1" Content="찾아보기" Margin="6,0,0,0" Click="OnBrowseClick"/> Style="{StaticResource FsPathInputStyle}"/>
<Button Grid.Column="1" Margin="8,0,0,0" Width="40"
Content="…" Style="{StaticResource FsSecondaryButtonStyle}"
Click="OnBrowseClick"/>
</Grid> </Grid>
<!-- 고급 옵션 expander --> <!-- 고급 옵션 -->
<Expander Header="고급 옵션" Margin="0,8,0,0" IsExpanded="False" <Expander Margin="0,12,0,0" IsExpanded="False"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"> Foreground="{StaticResource FsTextSecondary}">
<Grid Margin="0,8,0,0"> <Expander.Header>
<TextBlock Text="고급 옵션" Style="{StaticResource FsLabelStyle}"/>
</Expander.Header>
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,8,0"> <StackPanel Grid.Column="0" Margin="0,0,12,0">
<TextBlock Text="긴 변 최대 픽셀" Style="{StaticResource TextCaption}" <TextBlock Text="긴 변 최대 픽셀" Style="{StaticResource FsLabelStyle}"
ToolTip="비워 두면 원본 크기 유지"/> ToolTip="비워 두면 원본 크기 유지"/>
<TextBox x:Name="MaxLongEdgeTextBox" Margin="0,2,0,0" <TextBox x:Name="MaxLongEdgeTextBox" Margin="0,8,0,0"
ToolTip="비워 두면 원본 크기 유지"/> Style="{StaticResource FsPathInputStyle}"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Margin="8,0,8,0"> <StackPanel Grid.Column="1" Margin="6,0,12,0">
<TextBlock Text="PDF 렌더 DPI" Style="{StaticResource TextCaption}"/> <Grid>
<Grid Margin="0,2,0,0">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="32"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Slider x:Name="DpiSlider" Minimum="72" Maximum="450" Value="200" <TextBlock Text="PDF 렌더 DPI" Style="{StaticResource FsLabelStyle}"/>
VerticalAlignment="Center"/> <TextBlock Grid.Column="1"
<TextBlock Grid.Column="1" Text="{Binding ElementName=DpiSlider, Path=Value, StringFormat={}{0:0}}" FontFamily="{StaticResource FsFontMono}" FontSize="11"
VerticalAlignment="Center" HorizontalAlignment="Right" FontWeight="SemiBold"/> Foreground="{StaticResource FsAccentBlue}"
Text="{Binding ElementName=DpiSlider, Path=Value, StringFormat={}{0:0}}"/>
</Grid> </Grid>
<Slider x:Name="DpiSlider" Margin="0,8,0,0"
Style="{StaticResource FsSliderStyle}"
Minimum="72" Maximum="450" Value="200"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" Margin="8,0,0,0" VerticalAlignment="Bottom"> <CheckBox Grid.Column="2" x:Name="FlattenCheckBox"
<CheckBox x:Name="FlattenCheckBox" Content="투명을 흰색으로" Content="투명을 흰색으로"
IsChecked="True" Margin="0,0,0,4"/> IsChecked="True" VerticalAlignment="Bottom"
</StackPanel> Foreground="{StaticResource FsTextSecondary}"
FontFamily="{StaticResource FsFontSans}"
FontSize="12"/>
</Grid> </Grid>
</Expander> </Expander>
</StackPanel> </StackPanel>
</ui:Card> </Border>
<!-- 3: 파일 리스트 (메인 — 세로로 끝까지) --> <!-- 3: 파일 리스트 -->
<ui:Card Grid.Row="3" Margin="32,0,32,0" Padding="0"> <Border Grid.Row="3" Margin="32,0,32,0"
Background="{StaticResource FsBgPanel}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1" CornerRadius="8">
<DockPanel> <DockPanel>
<Border DockPanel.Dock="Top" Padding="20,12,20,8" <Border DockPanel.Dock="Top" Padding="20,14,20,10"
BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}" BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="0,0,0,1"> BorderThickness="0,0,0,1">
<TextBlock Text="대상 파일" Style="{StaticResource TextBodyStrong}"/> <TextBlock Text="대상 파일" Style="{StaticResource FsLabelStyle}"/>
</Border> </Border>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="20,8,20,16"> <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="20,12,20,16">
<ItemsControl x:Name="FilesList"> <ItemsControl x:Name="FilesList">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<Border Margin="0,0,0,6" Padding="12,8" CornerRadius="6" <Border Margin="0,0,0,8" Padding="12,10" CornerRadius="6"
Background="{DynamicResource SubtleFillColorTransparentBrush}" Background="{StaticResource FsBgSurface}"
BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}" BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1"> BorderThickness="1">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="44"/> <ColumnDefinition Width="40"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Border Width="40" Height="40" CornerRadius="4"
Background="{DynamicResource ControlFillColorDefaultBrush}"> <Border Width="32" Height="32" CornerRadius="4"
<Image Source="{Binding Thumbnail}" Stretch="UniformToFill"/> Background="{Binding FormatBrush}"
VerticalAlignment="Center">
<Grid>
<Image Source="{Binding Thumbnail}" Stretch="UniformToFill"/>
<TextBlock Text="{Binding FormatLabel}"
Foreground="White" FontSize="9"
FontWeight="SemiBold"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding ShowFormatLabel}"/>
</Grid>
</Border> </Border>
<StackPanel Grid.Column="1" Margin="12,0,8,0" VerticalAlignment="Center">
<TextBlock Text="{Binding FileName}" FontWeight="SemiBold" <StackPanel Grid.Column="1" Margin="14,0,8,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding FileName}"
FontFamily="{StaticResource FsFontSans}"
FontSize="13" FontWeight="Medium"
Foreground="{StaticResource FsTextPrimary}"
TextTrimming="CharacterEllipsis"/> TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding SubText}" FontSize="11" <TextBlock Text="{Binding SubText}"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" FontSize="11" Margin="0,2,0,0"
Foreground="{StaticResource FsTextTertiary}"
TextTrimming="CharacterEllipsis"/> TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding State}" FontSize="11" Margin="0,2,0,0"> <TextBlock Text="{Binding State}" FontSize="11" Margin="0,2,0,0">
<TextBlock.Style> <TextBlock.Style>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Foreground" <Setter Property="Foreground"
Value="{DynamicResource TextFillColorTertiaryBrush}"/> Value="{StaticResource FsTextTertiary}"/>
<Style.Triggers> <Style.Triggers>
<DataTrigger Binding="{Binding IsFailed}" Value="True"> <DataTrigger Binding="{Binding IsFailed}" Value="True">
<Setter Property="Foreground" Value="#F87171"/> <Setter Property="Foreground" Value="#F87171"/>
@ -184,9 +269,19 @@
</TextBlock.Style> </TextBlock.Style>
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
<ui:Button Grid.Column="2" Icon="{ui:SymbolIcon Dismiss20}"
Appearance="Transparent" Click="OnRemoveEntry" <Button Grid.Column="2"
ToolTip="목록에서 제거"/> Style="{StaticResource FsSecondaryButtonStyle}"
Background="Transparent"
BorderThickness="0"
Padding="6"
Click="OnRemoveEntry"
ToolTip="목록에서 제거">
<Path Width="14" Height="14" Stretch="Uniform"
Stroke="{StaticResource FsTextTertiary}"
StrokeThickness="2"
Data="M18,6 L6,18 M6,6 L18,18"/>
</Button>
</Grid> </Grid>
</Border> </Border>
</DataTemplate> </DataTemplate>
@ -194,31 +289,33 @@
</ItemsControl> </ItemsControl>
</ScrollViewer> </ScrollViewer>
</DockPanel> </DockPanel>
</ui:Card> </Border>
<!-- 4: 액션 바 (하단 고정) --> <!-- 4: 액션바 -->
<Border Grid.Row="4" Background="{DynamicResource LayerOnAcrylicFillColorDefaultBrush}" <Border Grid.Row="4" Padding="32,16,32,20" Margin="0,16,0,0"
BorderThickness="0,1,0,0" BorderBrush="{StaticResource FsBorderSubtle}" BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}" Background="{StaticResource FsBgPanel}">
Padding="32,16" Margin="0,16,0,0">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<StackPanel VerticalAlignment="Center"> <StackPanel VerticalAlignment="Center">
<ProgressBar x:Name="OverallProgress" Height="6" Minimum="0" Maximum="1" <ProgressBar x:Name="OverallProgress" Height="3"
IsIndeterminate="False"/> Background="{StaticResource FsBgInput}"
<TextBlock x:Name="ProgressStatusText" Style="{StaticResource TextCaption}" Foreground="{StaticResource FsAccentBlue}"
Margin="0,6,0,0" TextTrimming="CharacterEllipsis"/> Minimum="0" Maximum="1"/>
<TextBlock x:Name="ProgressStatusText" Margin="0,8,0,0"
Style="{StaticResource FsCaptionStyle}"
TextTrimming="CharacterEllipsis"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="20,0,0,0"> <StackPanel Grid.Column="1" Orientation="Horizontal" Margin="20,0,0,0">
<ui:Button x:Name="CancelButton" Content="닫기" Click="OnCancelClick" <Button x:Name="CancelButton" Content="닫기" Click="OnCancelClick"
Margin="0,0,8,0" Padding="20,6"/> Margin="0,0,8,0" MinWidth="80"
<ui:Button x:Name="ConvertButton" Content="변환 시작" Style="{StaticResource FsSecondaryButtonStyle}"/>
Icon="{ui:SymbolIcon ArrowRight24}" <Button x:Name="ConvertButton" Content="→ 변환 시작"
Appearance="Primary" Click="OnConvertClick" Click="OnConvertClick" MinWidth="120"
Padding="24,6"/> Style="{StaticResource FsPrimaryButtonStyle}"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>

View file

@ -1,35 +1,46 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using EverythingToJpeg.Core; using EverythingToJpeg.Core;
using EverythingToJpeg.Core.Providers;
using Wpf.Ui.Controls; using Wpf.Ui.Controls;
namespace EverythingToJpeg.App.Views; namespace EverythingToJpeg.App.Views;
public partial class ConvertWindow : FluentWindow public partial class ConvertWindow : FluentWindow
{ {
private static readonly string DialogLogPath =
Path.Combine(Path.GetTempPath(), "EverythingToJpeg_dialog.log");
private readonly ConversionEngine _engine; private readonly ConversionEngine _engine;
private readonly ObservableCollection<FileEntry> _entries = new(); private readonly ObservableCollection<ConvertFileEntry> _entries = new();
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private OutputLocation _outputMode = OutputLocation.SubfolderBesideSource;
private NameCollision _conflictRule = NameCollision.AppendNumber;
public ConvertWindow(ConversionEngine engine, IReadOnlyList<string> initialFiles) public ConvertWindow(ConversionEngine engine, IReadOnlyList<string> initialFiles)
{ {
_engine = engine; _engine = engine;
InitializeComponent(); InitializeComponent();
FilesList.ItemsSource = _entries; FilesList.ItemsSource = _entries;
AddFiles(initialFiles); AddFiles(initialFiles);
OutputModeCombo.SelectionChanged += (_, _) =>
CustomFolderRow.Visibility = OutputModeCombo.SelectedIndex == 2
? Visibility.Visible : Visibility.Collapsed;
} }
private static void DiagLog(string line)
{
try { File.AppendAllText(DialogLogPath,
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {line}{Environment.NewLine}"); }
catch { }
}
// ============== Files ==============
private void AddFiles(IEnumerable<string> paths) private void AddFiles(IEnumerable<string> paths)
{ {
var existing = new HashSet<string>(_entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase); var existing = new HashSet<string>(_entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
@ -38,7 +49,7 @@ public partial class ConvertWindow : FluentWindow
if (!File.Exists(p)) continue; if (!File.Exists(p)) continue;
if (existing.Contains(p)) continue; if (existing.Contains(p)) continue;
var entry = new FileEntry(p, _engine); var entry = ConvertFileEntry.From(p, _engine);
_entries.Add(entry); _entries.Add(entry);
_ = entry.LoadThumbnailAsync(); _ = entry.LoadThumbnailAsync();
} }
@ -85,7 +96,7 @@ public partial class ConvertWindow : FluentWindow
private void OnRemoveEntry(object sender, RoutedEventArgs e) private void OnRemoveEntry(object sender, RoutedEventArgs e)
{ {
if (sender is FrameworkElement fe && fe.DataContext is FileEntry entry) if (sender is FrameworkElement fe && fe.DataContext is ConvertFileEntry entry)
{ {
_entries.Remove(entry); _entries.Remove(entry);
UpdateSummary(); UpdateSummary();
@ -105,33 +116,74 @@ public partial class ConvertWindow : FluentWindow
{ {
if (File.Exists(p)) yield return p; if (File.Exists(p)) yield return p;
else if (Directory.Exists(p)) else if (Directory.Exists(p))
{
foreach (var f in Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly)) foreach (var f in Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly))
yield return f; yield return f;
}
} }
} }
private static readonly string DialogLogPath = // ============== Segments ==============
Path.Combine(Path.GetTempPath(), "EverythingToJpeg_dialog.log");
private static void DiagLog(string line) private void OnQualityChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{ {
try if (QualityValueText is null) return;
{ QualityValueText.Text = ((int)e.NewValue).ToString(CultureInfo.InvariantCulture);
File.AppendAllText(DialogLogPath,
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {line}{Environment.NewLine}");
}
catch { }
} }
private void OnOutputSegmentClick(object sender, RoutedEventArgs e)
{
if (sender is not ToggleButton clicked) return;
OutputSubBtn.IsChecked = clicked == OutputSubBtn;
OutputSameBtn.IsChecked = clicked == OutputSameBtn;
OutputCustomBtn.IsChecked = clicked == OutputCustomBtn;
_outputMode = (clicked.Tag as string) switch
{
"Same" => OutputLocation.SameFolderAsSource,
"Custom" => OutputLocation.Custom,
_ => OutputLocation.SubfolderBesideSource,
};
CustomFolderRow.Visibility = _outputMode == OutputLocation.Custom
? Visibility.Visible : Visibility.Collapsed;
}
private void OnConflictSegmentClick(object sender, RoutedEventArgs e)
{
if (sender is not ToggleButton clicked) return;
ConflictRenameBtn.IsChecked = clicked == ConflictRenameBtn;
ConflictReplaceBtn.IsChecked = clicked == ConflictReplaceBtn;
ConflictSkipBtn.IsChecked = clicked == ConflictSkipBtn;
_conflictRule = (clicked.Tag as string) switch
{
"Skip" => NameCollision.Skip,
"Replace" => NameCollision.Overwrite,
_ => NameCollision.AppendNumber,
};
}
private ConvertOptions BuildOptions()
{
var opts = new ConvertOptions
{
Quality = (int)QualitySlider.Value,
PdfDpi = (int)DpiSlider.Value,
FlattenTransparency = FlattenCheckBox.IsChecked == true,
OutputLocation = _outputMode,
OnCollision = _conflictRule,
};
if (_outputMode == OutputLocation.Custom)
opts.CustomOutputDirectory = CustomFolderTextBox.Text;
if (int.TryParse(MaxLongEdgeTextBox.Text, out var maxEdge) && maxEdge > 0)
opts.MaxLongEdgePixels = maxEdge;
return opts;
}
// ============== Convert ==============
private async void OnConvertClick(object sender, RoutedEventArgs e) private async void OnConvertClick(object sender, RoutedEventArgs e)
{ {
DiagLog($"OnConvertClick: entries={_entries.Count}"); DiagLog($"OnConvertClick: entries={_entries.Count}");
if (_entries.Count == 0) if (_entries.Count == 0)
{ {
DiagLog(" → no entries, showing info");
ShowInfo("변환할 파일이 없습니다."); ShowInfo("변환할 파일이 없습니다.");
return; return;
} }
@ -145,7 +197,7 @@ public partial class ConvertWindow : FluentWindow
try try
{ {
options = BuildOptions(); options = BuildOptions();
DiagLog($" options: Quality={options.Quality} OutputLocation={options.OutputLocation} Custom={options.CustomOutputDirectory} Collision={options.OnCollision} MaxLong={options.MaxLongEdgePixels} PdfDpi={options.PdfDpi}"); DiagLog($" options: Quality={options.Quality} OutputLocation={options.OutputLocation} Custom={options.CustomOutputDirectory} Collision={options.OnCollision}");
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -171,24 +223,19 @@ public partial class ConvertWindow : FluentWindow
DiagLog($" starting ConvertManyAsync, {sources.Count} files"); DiagLog($" starting ConvertManyAsync, {sources.Count} files");
var results = await _engine.ConvertManyAsync(sources, options, reporter, _cts.Token); var results = await _engine.ConvertManyAsync(sources, options, reporter, _cts.Token);
DiagLog($" finished, {results.Count} results"); DiagLog($" finished, {results.Count} results");
foreach (var r in results)
DiagLog($" [{r.Status}] {Path.GetFileName(r.SourcePath)} msg={r.Message}");
ApplyResults(results); ApplyResults(results);
ProgressStatusText.Text = SummarizeResults(results); ProgressStatusText.Text = SummarizeResults(results);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
DiagLog(" canceled");
ProgressStatusText.Text = "변환이 취소되었습니다."; ProgressStatusText.Text = "변환이 취소되었습니다.";
} }
catch (Exception ex) catch (Exception ex)
{ {
DiagLog(" EXCEPTION: " + ex); DiagLog(" EXCEPTION: " + ex);
ProgressStatusText.Text = "오류: " + ex.Message; ProgressStatusText.Text = "오류: " + ex.Message;
MessageBox.Show(this, MessageBox.Show(this, "변환 중 오류:\n\n" + ex.Message + "\n\n로그: " + DialogLogPath,
"변환 중 오류가 발생했습니다:\n\n" + ex.Message + "\n\n로그: " + DialogLogPath, "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Error);
"EverythingToJpeg",
MessageBoxButton.OK, MessageBoxImage.Error);
} }
finally finally
{ {
@ -241,84 +288,68 @@ public partial class ConvertWindow : FluentWindow
Close(); Close();
} }
private ConvertOptions BuildOptions()
{
var opts = new ConvertOptions
{
Quality = (int)QualitySlider.Value,
PdfDpi = (int)DpiSlider.Value,
FlattenTransparency = FlattenCheckBox.IsChecked == true,
};
opts.OutputLocation = OutputModeCombo.SelectedIndex switch
{
1 => OutputLocation.SameFolderAsSource,
2 => OutputLocation.Custom,
_ => OutputLocation.SubfolderBesideSource,
};
if (opts.OutputLocation == OutputLocation.Custom)
opts.CustomOutputDirectory = CustomFolderTextBox.Text;
opts.OnCollision = CollisionCombo.SelectedIndex switch
{
1 => NameCollision.Overwrite,
2 => NameCollision.Skip,
_ => NameCollision.AppendNumber,
};
if (int.TryParse(MaxLongEdgeTextBox.Text, out var maxEdge) && maxEdge > 0)
opts.MaxLongEdgePixels = maxEdge;
return opts;
}
private void ShowInfo(string message) private void ShowInfo(string message)
=> MessageBox.Show(this, message, "EverythingToJpeg", => MessageBox.Show(this, message, "EverythingToJpeg",
MessageBoxButton.OK, MessageBoxImage.Information); MessageBoxButton.OK, MessageBoxImage.Information);
} }
public sealed class FileEntry : System.ComponentModel.INotifyPropertyChanged public sealed class ConvertFileEntry : INotifyPropertyChanged
{ {
private readonly ConversionEngine _engine;
private string _state = "대기"; private string _state = "대기";
private bool _isFailed; private bool _isFailed;
private ImageSource? _thumbnail; private ImageSource? _thumbnail;
public FileEntry(string path, ConversionEngine engine) public required string Path { get; init; }
public required string FileName { get; init; }
public required string SubText { get; init; }
public required string FormatLabel { get; init; }
public required Brush FormatBrush { get; init; }
public string State
{ {
Path = path; get => _state;
_engine = engine; set { _state = value; Raise(nameof(State)); }
} }
public string Path { get; } public bool IsFailed
public string FileName => System.IO.Path.GetFileName(Path);
public string SubText
{ {
get get => _isFailed;
set { _isFailed = value; Raise(nameof(IsFailed)); }
}
public ImageSource? Thumbnail
{
get => _thumbnail;
set { _thumbnail = value; Raise(nameof(Thumbnail)); Raise(nameof(ShowFormatLabel)); }
}
public Visibility ShowFormatLabel => _thumbnail is null ? Visibility.Visible : Visibility.Collapsed;
public static ConvertFileEntry From(string path, ConversionEngine engine)
{
var ext = System.IO.Path.GetExtension(path).TrimStart('.').ToLowerInvariant();
var (label, brushKey) = FormatPalette.For(ext);
string handler;
if (engine.Providers.TryGetForFile(path, out var provider) && provider is not null)
handler = provider.Capability.DisplayName;
else
handler = "지원되지 않음";
long size = 0;
try { size = new FileInfo(path).Length; } catch { }
var sub = $".{ext} · {handler} · {MainWindow.HumanizeBytes(size)}";
return new ConvertFileEntry
{ {
var ext = System.IO.Path.GetExtension(Path).ToLowerInvariant(); Path = path,
string handler; FileName = System.IO.Path.GetFileName(path),
if (_engine.Providers.TryGetForFile(Path, out var provider) && provider is not null) SubText = sub,
handler = provider.Capability.DisplayName; FormatLabel = label,
else FormatBrush = (Brush)Application.Current.FindResource(brushKey),
handler = "지원되지 않음"; };
try
{
var size = new FileInfo(Path).Length;
return $"{ext} · {handler} · {FormatBytes(size)}";
}
catch
{
return $"{ext} · {handler}";
}
}
} }
public string State { get => _state; set { _state = value; Raise(nameof(State)); } }
public bool IsFailed { get => _isFailed; set { _isFailed = value; Raise(nameof(IsFailed)); } }
public ImageSource? Thumbnail { get => _thumbnail; set { _thumbnail = value; Raise(nameof(Thumbnail)); } }
public Task LoadThumbnailAsync() => Task.Run(() => public Task LoadThumbnailAsync() => Task.Run(() =>
{ {
try try
@ -333,21 +364,13 @@ public sealed class FileEntry : System.ComponentModel.INotifyPropertyChanged
bmp.CacheOption = BitmapCacheOption.OnLoad; bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit(); bmp.EndInit();
bmp.Freeze(); bmp.Freeze();
System.Windows.Application.Current.Dispatcher.Invoke(() => Thumbnail = bmp); Application.Current.Dispatcher.Invoke(() => Thumbnail = bmp);
} }
} }
catch { } catch { }
}); });
private static string FormatBytes(long bytes) public event PropertyChangedEventHandler? PropertyChanged;
{ private void Raise([CallerMemberName] string? n = null)
string[] units = { "B", "KB", "MB", "GB" }; => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
double size = bytes;
var unit = 0;
while (size >= 1024 && unit < units.Length - 1) { size /= 1024; unit++; }
return $"{size:0.#} {units[unit]}";
}
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
private void Raise(string n) => PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(n));
} }

View file

@ -246,18 +246,6 @@
</Border> </Border>
</StackPanel> </StackPanel>
</ToggleButton> </ToggleButton>
<ToggleButton x:Name="TabPreviewBtn" Margin="8,0,0,0"
Style="{StaticResource FsTabPillStyle}"
Click="OnTabClick" Tag="Preview">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Preview" VerticalAlignment="Center"/>
<Border Margin="8,0,0,0" Style="{StaticResource FsTabBadgeStyle}">
<TextBlock x:Name="TabPreviewBadge" Text="0"
FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource FsTextTertiary}"/>
</Border>
</StackPanel>
</ToggleButton>
<ToggleButton x:Name="TabPastBtn" Margin="8,0,0,0" <ToggleButton x:Name="TabPastBtn" Margin="8,0,0,0"
IsChecked="True" IsChecked="True"
Style="{StaticResource FsTabPillStyle}" Style="{StaticResource FsTabPillStyle}"
@ -292,8 +280,15 @@
</Grid> </Grid>
</Border> </Border>
<!-- View content --> <!-- View content (좌측: 탭 컨텐츠 / 우측: Preview 컬럼) -->
<Grid Grid.Row="1"> <Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MinWidth="360"/>
<ColumnDefinition Width="380"/>
</Grid.ColumnDefinitions>
<!-- 좌측: Active Queue OR Past Results -->
<Grid Grid.Column="0">
<!-- Active Queue view --> <!-- Active Queue view -->
<Grid x:Name="ActiveQueueView" Visibility="Collapsed"> <Grid x:Name="ActiveQueueView" Visibility="Collapsed">
<Grid x:Name="DropZoneEmpty"> <Grid x:Name="DropZoneEmpty">
@ -323,7 +318,7 @@
BorderBrush="{StaticResource FsBorderSubtle}" BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1" BorderThickness="1"
Cursor="Hand" Cursor="Hand"
MouseLeftButtonUp="OnQueueRowClick" PreviewMouseLeftButtonUp="OnQueueRowClick"
Tag="{Binding}"> Tag="{Binding}">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
@ -381,129 +376,6 @@
</ScrollViewer> </ScrollViewer>
</Grid> </Grid>
<!-- Preview view -->
<Grid x:Name="PreviewView" Visibility="Collapsed" Background="{StaticResource FsBgBase}">
<Grid Margin="32">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="320"/>
</Grid.ColumnDefinitions>
<!-- Image area -->
<Border Background="{StaticResource FsBgPanel}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1" CornerRadius="8">
<Grid>
<!-- Empty state -->
<StackPanel x:Name="PreviewEmpty" HorizontalAlignment="Center" VerticalAlignment="Center">
<Path Width="40" Height="40" Stretch="Uniform"
Stroke="{StaticResource FsTextTertiary}" StrokeThickness="1.5"
Data="M1,12 C1,12 5,4 12,4 C19,4 23,12 23,12 C23,12 19,20 12,20 C5,20 1,12 1,12 Z M12,9 A3,3 0 1,1 12,15 A3,3 0 1,1 12,9 Z"/>
<TextBlock Margin="0,12,0,0" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource FsTextSecondary}"
HorizontalAlignment="Center"
Text="No preview selected"/>
<TextBlock Margin="0,4,0,0" Style="{StaticResource FsCaptionStyle}"
HorizontalAlignment="Center" TextAlignment="Center"
Text="Active Queue 항목을 클릭하면&#x0a;여기에 미리보기가 표시됩니다."/>
</StackPanel>
<!-- Loading -->
<StackPanel x:Name="PreviewLoading" Visibility="Collapsed"
HorizontalAlignment="Center" VerticalAlignment="Center">
<ProgressBar IsIndeterminate="True" Width="200" Height="3"
Foreground="{StaticResource FsAccentBlue}"
Background="{StaticResource FsBgInput}"/>
<TextBlock Margin="0,12,0,0" Style="{StaticResource FsCaptionStyle}"
HorizontalAlignment="Center"
Text="미리보기 생성 중…"/>
</StackPanel>
<!-- Image -->
<Image x:Name="PreviewImage" Stretch="Uniform" Margin="24"
Visibility="Collapsed"/>
<!-- Reason / fallback -->
<StackPanel x:Name="PreviewReason" Visibility="Collapsed"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Path Width="36" Height="36" Stretch="Uniform"
Stroke="{StaticResource FsAccentAmber}" StrokeThickness="1.5"
Data="M12,2 L22,20 L2,20 Z M12,9 L12,14 M12,17 L12,17.01"/>
<TextBlock x:Name="PreviewReasonText" Margin="0,12,0,0"
Style="{StaticResource FsBodyStyle}"
HorizontalAlignment="Center" TextAlignment="Center"
TextWrapping="Wrap" MaxWidth="360"/>
</StackPanel>
</Grid>
</Border>
<!-- Meta panel -->
<Border Grid.Column="1" Margin="16,0,0,0"
Background="{StaticResource FsBgPanel}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1" CornerRadius="8" Padding="20">
<StackPanel>
<TextBlock Text="FILE DETAIL" Style="{StaticResource FsLabelStyle}"/>
<TextBlock x:Name="PreviewFileName" Margin="0,8,0,0"
Style="{StaticResource FsBodyStyle}"
FontWeight="SemiBold" TextWrapping="Wrap"
Text="—"/>
<TextBlock x:Name="PreviewFilePath" Margin="0,4,0,0"
FontFamily="{StaticResource FsFontMono}" FontSize="11"
Foreground="{StaticResource FsTextTertiary}"
TextWrapping="Wrap"/>
<Border Margin="0,16,0,16" Height="1" Background="{StaticResource FsBorderSubtle}"/>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Format"
Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,6"/>
<TextBlock x:Name="PreviewFormatText" Grid.Row="0" Grid.Column="1"
FontFamily="{StaticResource FsFontMono}" FontSize="12"
Foreground="{StaticResource FsTextPrimary}" Margin="0,0,0,6"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Size"
Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,6"/>
<TextBlock x:Name="PreviewSizeText" Grid.Row="1" Grid.Column="1"
FontFamily="{StaticResource FsFontMono}" FontSize="12"
Foreground="{StaticResource FsTextPrimary}" Margin="0,0,0,6"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Dimensions"
Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,6"/>
<TextBlock x:Name="PreviewDimText" Grid.Row="2" Grid.Column="1"
FontFamily="{StaticResource FsFontMono}" FontSize="12"
Foreground="{StaticResource FsTextPrimary}" Text="—"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Pages"
Style="{StaticResource FsCaptionStyle}"/>
<TextBlock x:Name="PreviewPageText" Grid.Row="3" Grid.Column="1"
FontFamily="{StaticResource FsFontMono}" FontSize="12"
Foreground="{StaticResource FsTextPrimary}" Text="—"/>
</Grid>
<Border Margin="0,20,0,16" Height="1" Background="{StaticResource FsBorderSubtle}"/>
<Button Content="Open in Explorer"
Style="{StaticResource FsSecondaryButtonStyle}"
Click="OnPreviewOpenFolder"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Border>
</Grid>
</Grid>
<!-- Past Results view (default) --> <!-- Past Results view (default) -->
<ScrollViewer x:Name="PastResultsView" VerticalScrollBarVisibility="Auto" <ScrollViewer x:Name="PastResultsView" VerticalScrollBarVisibility="Auto"
Padding="24"> Padding="24">
@ -537,7 +409,11 @@
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<Border BorderBrush="#0DFFFFFF" <Border BorderBrush="#0DFFFFFF"
BorderThickness="0,0,0,1" Padding="0,12"> BorderThickness="0,0,0,1" Padding="0,12"
Cursor="Hand"
Background="Transparent"
PreviewMouseLeftButtonUp="OnPastRowClick"
Tag="{Binding}">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="40"/> <ColumnDefinition Width="40"/>
@ -617,8 +493,154 @@
</ItemsControl> </ItemsControl>
</ScrollViewer> </ScrollViewer>
<!-- Drop hint overlay --> </Grid>
<Border x:Name="DropHintOverlay" Visibility="Collapsed" <!-- /좌측 컬럼 끝 -->
<!-- 우측: Preview 컬럼 (항상 보임) -->
<Border Grid.Column="1"
Background="{StaticResource FsBgPanel}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1,0,0,0">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 헤더 -->
<Border Grid.Row="0" Padding="20,16,20,12"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Path Width="14" Height="14" Stretch="Uniform"
Stroke="{StaticResource FsTextSecondary}" StrokeThickness="2"
Data="M1,12 C1,12 5,4 12,4 C19,4 23,12 23,12 C23,12 19,20 12,20 C5,20 1,12 1,12 Z M12,9 A3,3 0 1,1 12,15 A3,3 0 1,1 12,9 Z"/>
<TextBlock Text="PREVIEW" Margin="8,0,0,0"
Style="{StaticResource FsLabelStyle}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- 이미지 영역 -->
<Grid Grid.Row="1" Margin="20,16,20,16" x:Name="PreviewImageArea">
<Border Background="{StaticResource FsBgInput}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1" CornerRadius="6">
<Grid>
<!-- Empty state -->
<StackPanel x:Name="PreviewEmpty" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="20">
<Path Width="36" Height="36" Stretch="Uniform"
Stroke="{StaticResource FsTextTertiary}" StrokeThickness="1.5"
Data="M1,12 C1,12 5,4 12,4 C19,4 23,12 23,12 C23,12 19,20 12,20 C5,20 1,12 1,12 Z M12,9 A3,3 0 1,1 12,15 A3,3 0 1,1 12,9 Z"/>
<TextBlock Margin="0,12,0,0" FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource FsTextSecondary}"
HorizontalAlignment="Center"
Text="No selection"/>
<TextBlock Margin="0,4,0,0" Style="{StaticResource FsCaptionStyle}"
HorizontalAlignment="Center" TextAlignment="Center"
TextWrapping="Wrap" MaxWidth="240"
Text="Active Queue 항목을 클릭하면 여기에 미리보기가 표시됩니다."/>
</StackPanel>
<!-- Loading -->
<StackPanel x:Name="PreviewLoading" Visibility="Collapsed"
HorizontalAlignment="Center" VerticalAlignment="Center">
<ProgressBar IsIndeterminate="True" Width="180" Height="3"
Foreground="{StaticResource FsAccentBlue}"
Background="{StaticResource FsBgSurface}"/>
<TextBlock Margin="0,12,0,0" Style="{StaticResource FsCaptionStyle}"
HorizontalAlignment="Center"
Text="미리보기 생성 중…"/>
</StackPanel>
<!-- Image -->
<Image x:Name="PreviewImage" Stretch="Uniform" Margin="16"
Visibility="Collapsed"/>
<!-- Reason -->
<StackPanel x:Name="PreviewReason" Visibility="Collapsed"
HorizontalAlignment="Center" VerticalAlignment="Center" Margin="20">
<Path Width="32" Height="32" Stretch="Uniform"
Stroke="{StaticResource FsAccentAmber}" StrokeThickness="1.5"
Data="M12,2 L22,20 L2,20 Z M12,9 L12,14 M12,17 L12,17.01"/>
<TextBlock x:Name="PreviewReasonText" Margin="0,12,0,0"
Style="{StaticResource FsBodyStyle}"
FontSize="12"
HorizontalAlignment="Center" TextAlignment="Center"
TextWrapping="Wrap" MaxWidth="280"/>
</StackPanel>
</Grid>
</Border>
</Grid>
<!-- 메타 -->
<Border Grid.Row="2" Padding="20,12,20,20"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="0,1,0,0">
<StackPanel>
<TextBlock x:Name="PreviewFileName"
Style="{StaticResource FsBodyStyle}"
FontWeight="SemiBold" TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis"
Text="—"/>
<TextBlock x:Name="PreviewFilePath" Margin="0,4,0,0"
FontFamily="{StaticResource FsFontMono}" FontSize="11"
Foreground="{StaticResource FsTextTertiary}"
TextTrimming="CharacterEllipsis" MaxHeight="32"/>
<Border Margin="0,12,0,12" Height="1"
Background="{StaticResource FsBorderSubtle}"/>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Format"
Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,4"/>
<TextBlock x:Name="PreviewFormatText" Grid.Row="0" Grid.Column="1"
FontFamily="{StaticResource FsFontMono}" FontSize="12"
Foreground="{StaticResource FsTextPrimary}" Margin="0,0,0,4"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Size"
Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,4"/>
<TextBlock x:Name="PreviewSizeText" Grid.Row="1" Grid.Column="1"
FontFamily="{StaticResource FsFontMono}" FontSize="12"
Foreground="{StaticResource FsTextPrimary}" Margin="0,0,0,4"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Dimensions"
Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,4"/>
<TextBlock x:Name="PreviewDimText" Grid.Row="2" Grid.Column="1"
FontFamily="{StaticResource FsFontMono}" FontSize="12"
Foreground="{StaticResource FsTextPrimary}" Text="—"
Margin="0,0,0,4"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Pages"
Style="{StaticResource FsCaptionStyle}"/>
<TextBlock x:Name="PreviewPageText" Grid.Row="3" Grid.Column="1"
FontFamily="{StaticResource FsFontMono}" FontSize="12"
Foreground="{StaticResource FsTextPrimary}" Text="—"/>
</Grid>
<Button Content="Open in Explorer" Margin="0,16,0,0"
Style="{StaticResource FsSecondaryButtonStyle}"
Click="OnPreviewOpenFolder"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Border>
</Grid>
</Border>
<!-- Drop hint overlay (전체 덮음) -->
<Border x:Name="DropHintOverlay" Visibility="Collapsed" Grid.ColumnSpan="2"
Background="#CC090A0C" IsHitTestVisible="False"> Background="#CC090A0C" IsHitTestVisible="False">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"> <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<Path Width="64" Height="64" Stretch="Uniform" <Path Width="64" Height="64" Stretch="Uniform"

View file

@ -20,7 +20,9 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private NameCollision _conflictRule = NameCollision.AppendNumber; private NameCollision _conflictRule = NameCollision.AppendNumber;
public MainWindow() public MainWindow() : this(null) { }
public MainWindow(IReadOnlyList<string>? initialFiles)
{ {
InitializeComponent(); InitializeComponent();
@ -30,9 +32,18 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
SeedDemoHistory(); SeedDemoHistory();
UpdateBadges(); UpdateBadges();
UpdateProcessQueueButton(); UpdateProcessQueueButton();
ShowTab("Past");
UpdateActiveQueueVisibility();
if (initialFiles is { Count: > 0 })
{
AddToQueue(initialFiles);
ShowTab("Active");
}
else
{
ShowTab("Past");
}
UpdateActiveQueueVisibility();
ApplyAppDataStats(); ApplyAppDataStats();
} }
@ -49,11 +60,9 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
private void ShowTab(string tag) private void ShowTab(string tag)
{ {
TabActiveBtn.IsChecked = tag == "Active"; TabActiveBtn.IsChecked = tag == "Active";
TabPreviewBtn.IsChecked = tag == "Preview";
TabPastBtn.IsChecked = tag == "Past"; TabPastBtn.IsChecked = tag == "Past";
ActiveQueueView.Visibility = tag == "Active" ? Visibility.Visible : Visibility.Collapsed; ActiveQueueView.Visibility = tag == "Active" ? Visibility.Visible : Visibility.Collapsed;
PreviewView.Visibility = tag == "Preview" ? Visibility.Visible : Visibility.Collapsed;
PastResultsView.Visibility = tag == "Past" ? Visibility.Visible : Visibility.Collapsed; PastResultsView.Visibility = tag == "Past" ? Visibility.Visible : Visibility.Collapsed;
} }
@ -98,6 +107,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
private void AddToQueue(IEnumerable<string> paths) private void AddToQueue(IEnumerable<string> paths)
{ {
var wasEmpty = _activeQueue.Count == 0;
var existing = new HashSet<string>(_activeQueue.Select(q => q.SourcePath), StringComparer.OrdinalIgnoreCase); var existing = new HashSet<string>(_activeQueue.Select(q => q.SourcePath), StringComparer.OrdinalIgnoreCase);
foreach (var path in paths) foreach (var path in paths)
{ {
@ -107,6 +117,12 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
UpdateBadges(); UpdateBadges();
UpdateProcessQueueButton(); UpdateProcessQueueButton();
UpdateActiveQueueVisibility(); UpdateActiveQueueVisibility();
if (wasEmpty && _activeQueue.Count > 0 && _selectedPreviewItem is null)
{
_selectedPreviewItem = _activeQueue[0];
_ = LoadPreviewAsync(_selectedPreviewItem);
}
} }
private void OnRemoveQueueItem(object sender, RoutedEventArgs e) private void OnRemoveQueueItem(object sender, RoutedEventArgs e)
@ -130,7 +146,6 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
private void UpdateBadges() private void UpdateBadges()
{ {
TabActiveBadge.Text = _activeQueue.Count.ToString(CultureInfo.InvariantCulture); TabActiveBadge.Text = _activeQueue.Count.ToString(CultureInfo.InvariantCulture);
TabPreviewBadge.Text = "0";
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);
} }
@ -211,37 +226,74 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
// ============== Preview ============== // ============== Preview ==============
private QueueItem? _selectedPreviewItem; private QueueItem? _selectedPreviewItem;
private string? _selectedPreviewPath;
private CancellationTokenSource? _previewCts; private CancellationTokenSource? _previewCts;
private async void OnQueueRowClick(object sender, MouseButtonEventArgs e) private async void OnQueueRowClick(object sender, MouseButtonEventArgs e)
{ {
if (e.OriginalSource is DependencyObject src && IsInsideButton(src)) return;
if (sender is not FrameworkElement fe || fe.Tag is not QueueItem item) return; if (sender is not FrameworkElement fe || fe.Tag is not QueueItem item) return;
_selectedPreviewItem = item; _selectedPreviewItem = item;
ShowTab("Preview");
await LoadPreviewAsync(item); await LoadPreviewAsync(item);
e.Handled = true;
} }
private async Task LoadPreviewAsync(QueueItem item) private async void OnPastRowClick(object sender, MouseButtonEventArgs e)
{
if (e.OriginalSource is DependencyObject src && IsInsideButton(src)) return;
if (sender is not FrameworkElement fe || fe.Tag is not HistoryRow row) return;
if (row.SourcePath == "<demo>")
{
SetPreviewMeta(row.FileName, row.SourcePath, row.FormatLabel, row.SizeText);
ShowPreviewReason("샘플 데모 항목입니다. 원본 파일이 없으므로 미리보기를 만들 수 없습니다.");
_selectedPreviewPath = null;
}
else if (!File.Exists(row.SourcePath))
{
SetPreviewMeta(row.FileName, row.SourcePath, row.FormatLabel, row.SizeText);
ShowPreviewReason("원본 파일을 찾을 수 없습니다. 파일이 이동·삭제되었을 수 있습니다.");
_selectedPreviewPath = null;
}
else
{
_selectedPreviewItem = null;
_selectedPreviewPath = row.SourcePath;
await LoadPreviewByPathAsync(row.SourcePath, row.FileName, row.FormatLabel, row.SizeText);
}
e.Handled = true;
}
private static bool IsInsideButton(DependencyObject? node)
{
while (node is not null)
{
if (node is Button) return true;
node = System.Windows.Media.VisualTreeHelper.GetParent(node)
?? (node is FrameworkElement fe ? fe.Parent : null);
}
return false;
}
private Task LoadPreviewAsync(QueueItem item)
{
_selectedPreviewPath = item.SourcePath;
return LoadPreviewByPathAsync(item.SourcePath, item.FileName, item.FormatLabel, item.SizeText);
}
private async Task LoadPreviewByPathAsync(string sourcePath, string fileName, string formatLabel, string sizeText)
{ {
_previewCts?.Cancel(); _previewCts?.Cancel();
_previewCts = new CancellationTokenSource(); _previewCts = new CancellationTokenSource();
var token = _previewCts.Token; var token = _previewCts.Token;
PreviewEmpty.Visibility = Visibility.Collapsed; SetPreviewMeta(fileName, sourcePath, formatLabel, sizeText);
PreviewImage.Visibility = Visibility.Collapsed; ShowPreviewLoading();
PreviewReason.Visibility = Visibility.Collapsed;
PreviewLoading.Visibility = Visibility.Visible;
PreviewFileName.Text = item.FileName;
PreviewFilePath.Text = item.SourcePath;
PreviewFormatText.Text = item.FormatLabel;
PreviewSizeText.Text = item.SizeText;
PreviewDimText.Text = "—";
PreviewPageText.Text = "—";
try try
{ {
var result = await PreviewService.CreateAsync(item.SourcePath, 720, token); var result = await PreviewService.CreateAsync(sourcePath, 720, token);
if (token.IsCancellationRequested) return; if (token.IsCancellationRequested) return;
PreviewLoading.Visibility = Visibility.Collapsed; PreviewLoading.Visibility = Visibility.Collapsed;
@ -264,20 +316,47 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
catch (Exception ex) catch (Exception ex)
{ {
PreviewLoading.Visibility = Visibility.Collapsed; PreviewLoading.Visibility = Visibility.Collapsed;
PreviewReasonText.Text = "미리보기 오류: " + ex.Message; ShowPreviewReason("미리보기 오류: " + ex.Message);
PreviewReason.Visibility = Visibility.Visible;
} }
} }
private void SetPreviewMeta(string fileName, string filePath, string formatLabel, string sizeText)
{
PreviewFileName.Text = fileName;
PreviewFilePath.Text = filePath;
PreviewFormatText.Text = formatLabel;
PreviewSizeText.Text = sizeText;
PreviewDimText.Text = "—";
PreviewPageText.Text = "—";
}
private void ShowPreviewLoading()
{
PreviewEmpty.Visibility = Visibility.Collapsed;
PreviewImage.Visibility = Visibility.Collapsed;
PreviewReason.Visibility = Visibility.Collapsed;
PreviewLoading.Visibility = Visibility.Visible;
}
private void ShowPreviewReason(string reason)
{
PreviewEmpty.Visibility = Visibility.Collapsed;
PreviewImage.Visibility = Visibility.Collapsed;
PreviewLoading.Visibility = Visibility.Collapsed;
PreviewReasonText.Text = reason;
PreviewReason.Visibility = Visibility.Visible;
}
private void OnPreviewOpenFolder(object sender, RoutedEventArgs e) private void OnPreviewOpenFolder(object sender, RoutedEventArgs e)
{ {
if (_selectedPreviewItem is null) return; var path = _selectedPreviewPath ?? _selectedPreviewItem?.SourcePath;
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return;
try try
{ {
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{ {
FileName = "explorer.exe", FileName = "explorer.exe",
Arguments = $"/select,\"{_selectedPreviewItem.SourcePath}\"", Arguments = $"/select,\"{path}\"",
UseShellExecute = true, UseShellExecute = true,
}); });
} }