概要
2020年にもなってファイル名変更ソフトを新しく作りました。名前はFileRenamerDiffです。
この記事では、ソフトウェアの記述解説をします。
ソフトウェアの内容やスクリーンショットは前回の記事を参照ください。
ざっくりいうと、WPF on .NET Core by C#です。
MVVMライブラリはLivetとReactivePropertyを使用しています。
ソースコード全体はGitHubにあります。
使用している主なライブラリ
ライブラリ名 | 使用目的 |
---|---|
ReactiveProperty | MVVMライブラリとして、主にViewModelで使用 |
Livet | MVVMライブラリとして、ViewとViewModelの連携やINotifyPropertyChanged実装基底クラスなど |
DiffPlex | 差分表示、ViewModelで使用。ViewではDataGrid内のRichTextBoxにBindingされます。 |
MahApps | Viewのデザインライブラリ |
MaterialDesignThemes | Viewのデザインライブラリ |
Markdig | Viewのライセンス表記などのMarkdownの表示 |
Utf8Json | JSON設定ファイルの読込/保存 |
Serilog | ロギング |
全体構成
MVVMパターンですので、Model-ViewModel-Viewの3層に分割して、ViewとViewModelの間はBindingによってつながっています。
メインとなるクラスだけ抜き出したクラス図は以下のようになっています。PlantUMLで書きました。
各層ごとに解説します。
Model
Model層ではファイルの探索、正規表現を使ったリネーム、リネーム保存、設定ファイル読み書きといったことを行っています。
ファイル探索
指定されたパス以下のファイルパスを取得します。このとき設定によって、隠しファイルやフォルダ・(狭義の)ファイルの両方か片方だけリストにするか切り替えます。
探索されたファイルの情報をFileElementModel
にまとめて、そこにリネーム後のファイル名もあとで加えます。
リネーム処理(プレビュー)
設定はSettingAppModel
にまとめてあり、このクラス全体をJSONにシリアライズして設定ファイルとして保存しています。設定には探索対象ディレクトリや複数の削除パターン・置換パターンが含まれています。
リネームの実行は.NETのRegex
に置換後の文字列を加えたReplaceRegex
クラスで行います。複数のファイル名に対して行うのを想定しているので、RegexOption
はCompiled
にしておきます。
ソフトウェアの設定では削除パターンと置換パターンで2種類がありますが、Regexを生成する際には、この削除パターンは全て|
で結合した1つのRegexにし、置換後の文字列は""
にしておきます。
Regex.Replace("a1b2c3", "(a|b|c)", "") //結果"123"
重複判定
リネーム処理後に同じファイルパスが含まれていた場合、ファイルパスが重複しているとみなし、リネーム保存を禁止しています。なお、Windowsは大文字・小文字は同じとみなします。
リネーム後のファイルパスだけでなく、リネーム前のファイルパスとも重複していないかを判定しています。これは保存の順番によっては失敗する場合があるためです。
例)"A.txt", "B.txt"というファイル名が同じディレクトリにあり、"A"を"b"に、"B"を"c"に置き換えるリネームをした場合、先に"A.txt"→"b.txt"が保存されると既存の"B.txt"と重複して失敗してしまいます。
リネーム保存
リネーム保存時は深い階層のファイルから保存します。フォルダもリネームする場合、保存前のファイルが移動したことになり失敗してしまいます。
例)"/Main/Sub/"という階層のフォルダがあり、"/Main"と"/Sub"をリネームする場合、"/Main"を先にリネーム保存してしまうと、"/Main/Sub"が見つからなくなります。
また、リネーム処理自体もファイルかフォルダかで処理が微妙に異なります。これは過去記事を参照ください。
C#で確実にファイル名を変更する
ReactiveProperty(Model)
シリアライズされるSettingAppModel
クラス以外はViewModelから参照される可変プロパティはだいたいReactivePropertyにしてあります。
これにより、Modelでの変更がRxなストリームとしてViewModel・Viewに伝わります。
ViewModel
基本的にはModelの上記機能を呼び出すためのReactiveCommandとModelの状態を反映するReactivePropertyがメインです。
ViewModelはなるべく薄く作るべきなのですが、差分表示(DiffPlex)やDataGrid用のCollectionViewなどで、ふくらんでいます。
ReactiveProperty(ViewModel)
リネーム保存などの時間のかかる処理は非同期で行うべきですが、ViewにBindingされるプロパティはUIスレッド以外から変更すると例外が発生してしまいます。
こういった事情もReactivePropertyのSchedulerやRxのObserveOnUIDispatcher()
を使うと解決できます。
例えば、Modelでの非同期処理の結果、リネームファイル数プロパティCountReplaced
が変更になり、それをViewにも表示するためViewModelで同名のプロパティを作る場合はこうします。
//プロパティ宣言
public IReadOnlyReactiveProperty<int> CountConflicted { get; }
//コンストラクタ内で
this.CountReplaced = model.CountReplaced.ObserveOnUIDispatcher().ToReadOnlyReactivePropertySlim();
これにより、Modelのプロパティ変更時のスレッドに関係なく、ViewModelのプロパティ変更はUIスレッドから行われます。
差分表示
DiffPlexというライブラリを使用して、リネーム前後のファイル名の差分情報を生成します。
DiffPlexの詳しい解説は過去記事を参照ください。
WPFでGitのDiffっぽい差分表示をするDiffPlexライブラリ。~あるいは、あるジェダイの変遷
ファイル名の性質上、1行全体のハイライトをしても意味がないので、ワード境界を細かく区切りました。
デフォルトの差分表示時のワード境界が全角文字などに対応していなかったので、追加しました。
public static SideBySideDiffModel CreateDiff(string inputText, string outText)
{
char[] wordSeparaters =
{
' ', '\t', '.', '(', ')', '{', '}', ',', '!', '?', ';', //MarkDiffデフォルトからコピー
'_','-','[',']','~','+','=','^', //半角系
' ','、','。','「','」','(',')','{','}','・','!','?',';',':','_','ー','-','~','‐','+','*','/','=','^', //全角系
};
var diff = new SideBySideDiffBuilder(new Differ(), wordSeparaters);
return diff.BuildDiffModel(inputText, outText);
}
これにより、どこが変更されたかが分かりやすくなります。
$\style{background-color:pink;}{報告書「最終版」}$.txt
→ $\style{background-color:LightGreen;}{報告書「最終手前版」}$.txt
👇
報告書「$\style{background-color:pink;}{最終版}$」.txt
→ 報告書「$\style{background-color:LightGreen;}{最終手前版}$」.txt
ファイル情報DataGrid用ViewModel
Modelでのファイル情報クラスFileElementModel
に対応したFileElementViewModel
を作成し、上記差分情報もその中に含めています。
ただのDataGridにBindingするなら、これをObservableCollection
などに入れればよいのですが、並び替えや行フィルタ処理に対応するためにはICollectionView
に変換する必要があります。
変換自体はCollectionViewSource.GetDefaultView()
を呼ぶだけでよいですが、行フィルタを使用する場合はICollectionViewer
のFilter
プロパティに表示判定用コールバックを指定して、行フィルタ基準が変わるたびにRefresh()
メソッドを呼ぶ必要があります。
実際のコードのうち関係する部分を抜き出すと以下のようになります。
/// <summary>
/// ファイル情報コレクションのDataGrid用のICollectionView
/// </summary>
public ReadOnlyReactivePropertySlim<ICollectionView> CViewFileElementVMs { get; }
/// <summary>
/// 置換前後で差があったファイルのみ表示するか
/// </summary>
public ReactivePropertySlim<bool> IsVisibleReplacedOnly { get; } = new ReactivePropertySlim<bool>(false);
public FileElementsGridViewModel()
{
this.CViewFileElementVMs = model
.ObserveProperty(x => x.FileElementModels)
.Select(x => CreateFilePathVMs(x))
.ObserveOnUIDispatcher()
.Select(x => CreateCollectionViewFilePathVMs(x))
.ToReadOnlyReactivePropertySlim();
//表示基準に変更があったら、表示判定対象に変更があったら、CollectionViewの表示を更新する
new[]
{
this.IsVisibleReplacedOnly,
...
}
.CombineLatest()
.Throttle(TimeSpan.FromMilliseconds(100))
.ObserveOnUIDispatcher()
.Subscribe(_ => RefleshCollectionViewSafe());
}
private ICollectionView CreateCollectionViewFilePathVMs(ObservableCollection<FileElementViewModel> fVMs)
{
var cView = CollectionViewSource.GetDefaultView(fVMs);
cView.Filter = (x => GetVisibleRow(x));
return cView;
}
/// <summary>
/// 2つの表示切り替えプロパティと、各行の値に応じて、その行の表示状態を決定する
/// </summary>
/// <param name="row">行VM</param>
/// <returns>表示状態</returns>
private bool GetVisibleRow(object row)
{
if (!(row is FileElementViewModel pathVM))
return true;
var replacedVisible = !IsVisibleReplacedOnly.Value || pathVM.IsReplaced.Value;
var conflictedVisible = !IsVisibleConflictedOnly.Value || pathVM.IsConflicted.Value;
return replacedVisible && conflictedVisible;
}
private void RefleshCollectionViewSafe()
{
if (!(CViewFileElementVMs?.Value is ListCollectionView currentView))
return;
//なぜかCollectionViewが追加中・編集中のことがある。
if (currentView.IsAddingNew)
{
LogTo.Warning("CollectionView is Adding");
currentView.CancelNew();
}
if (currentView.IsEditingItem)
{
LogTo.Warning("CollectionView is Editing");
currentView.CommitEdit();
}
currentView.Refresh();
}
View
MahAppsとMaterialDesignThemes
MahAppsとMaterialDesignThemesという2つのライブラリを使用して、見た目をカッチョイイ感じにします。
MahAppsはウインドウのタイトル部分とウインドウの周りにボヤッとした色をつける機能 GlowBrush
を使用しています。
ウインドウの周りがちょっと青いのがわかりますでしょうか。
それ以外のコントロールはMaterialDesignThemesのテーマが適用されています。
今回はプリセット色は使わず、すべてカラーコードを指定したかったので、プリセットの色に上書きします。
<Color x:Key="Primary900">#1A537C</Color>
<Color x:Key="Primary800">#286591</Color>
...
<Color x:Key="Primary50">#E8EFF4</Color>
MaterialDesignThemesの詳しい内容は # 参考 を参照してください。
テーマ変更
Ligth/Darkテーマ切り替えをやってみたかったので、作ってみました。
MaterialDesignThemesのPaletteHelper
を使ってデフォルトのテーマをGet、変更後にSetします。
メイン色と前景色のペアで設定します。両者はある程度の明暗差がないと見づらいです。
private static void ChangeTheme()
{
var paletteHelper = new PaletteHelper();
var theme = paletteHelper.GetTheme();
bool isDark = Model.Instance.Setting.IsAppDarkTheme;
theme.SetBaseTheme(
isDark
? Theme.Dark
: Theme.Light);
theme.PrimaryDark = new ColorPair((Color)Current.Resources["Primary700"], Colors.White);
theme.PrimaryMid = new ColorPair((Color)Current.Resources["Primary500"], Colors.White);
theme.PrimaryLight = (Color)Current.Resources["Primary300"];
theme.Paper = AppExtention.ToColorOrDefault(isDark
? "#272E33"
: "#E6EDF2");
//ベース色とのコントラストが
Current.Resources["HighContrastBrush"] =
(isDark ? theme.PrimaryLight : theme.PrimaryDark)
.Color.ToSolidColorBrush(true);
paletteHelper.SetTheme(theme);
}
ただし、各種色やBrushはStaticResourceで指定されているので、再起動後に反映されます。
ファイル情報DataGrid(差分以外)
ViewModelのファイル情報クラスFileElementViewModel
を含んだICollectionViewをDataGridにBindingしています。
重複判定とリネーム有無の表示列のヘッダにはカウント数と行フィルタを切り替えるToggleSwitchを配置しておきます。
<DataGridTemplateColumn.Header>
<materialDesign:Badged
BadgeColorZoneMode="PrimaryMid"
DataContext="{Binding DataContext, ElementName=rootObj}">
<materialDesign:Badged.Badge>
<TextBlock Text="{Binding DataContext.CountReplaced.Value, ElementName=rootObj}" />
</materialDesign:Badged.Badge>
<ToggleButton IsChecked="{Binding IsVisibleReplacedOnly.Value}">
<materialDesign:PackIcon Kind="CheckBold" />
</ToggleButton>
</materialDesign:Badged>
</DataGridTemplateColumn.Header>
ディレクトリ表示列は中身をButtonにして、クリックされたらエクスプローラーで開くようにします。特定のファイルだけ手修正したい場合などに使用します。
<DataGridTemplateColumn>
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button
Command="{Binding OpenInExploreCommand, Mode=OneTime}"
Style="{StaticResource MaterialDesignFlatButton}"
ToolTip="{Binding DirectoryPath, Mode=OneTime}">
<StackPanel Orientation="Horizontal">
<materialDesign:PackIcon Kind="FolderEditOutline" />
<TextBlock Text="{Binding DirectoryPath, Mode=OneTime}" />
</StackPanel>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
DataGridで使用している細かい技術は以前の記事を参照ください。
DataGridやListBox内でクリックされたら自身の行を上下に移動するButton
DataGridやListBox内でクリックされたら自身の行を削除するButton
C#のColor関連の便利拡張メソッド+α 24選(HSV色空間への変換も)
差分表示
ViewModel内のDiffPlexで作成した差分情報をDataGrid内のRichTextBoxにBindingしています。
<DataGridTemplateColumn Header="{x:Static properties:Resources.Grid_OldText}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<RichTextBox v:RichTextBoxHelper.Document="{Binding Diff.Value.OldText, Converter={StaticResource DiffPaneModelToFlowDocumentConverter}}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
独自のConverterを使用して、DiffPlexの差分情報からRichTextBoxの中身のFlowDocumentに変換しています。
差分情報内のワード境界ごとに無変更・削除(Deleted)・挿入(Inserted)・挿入前(Imaginary)・修正(Modified)のどれかに判定されます。ただし1行単位で比較していないため、挿入前・修正は実際には使われないようです。
削除の場合はピンク、挿入の場合は黄緑色を指定しています。
public class DiffPaneModelToFlowDocumentConverter : IValueConverter
{
private static readonly Brush unchangeBrush = Colors.Transparent.ToSolidColorBrush(true);
private static readonly Brush deletedBrush = AppExtention.ToColorOrDefault($"#FFAFD1").ToSolidColorBrush(true);
private static readonly Brush insertedBrush = AppExtention.ToColorOrDefault($"#88E6A7").ToSolidColorBrush(true);
private static readonly Brush imaginaryBrush = Colors.SkyBlue.ToSolidColorBrush(true);
private static readonly Brush modifiedBrush = Colors.Orange.ToSolidColorBrush(true);
private static readonly Brush changedTextBrush = Colors.Black.ToSolidColorBrush(true);
private static readonly Brush normalTextBrush = (SolidColorBrush)App.Current.Resources["MaterialDesignBody"];
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
if (!(value is DiffPaneModel diffVM))
return Binding.DoNothing;
if (!diffVM.Lines.Any())
return Binding.DoNothing;
if (diffVM.Lines.Count > 1)
LogTo.Warning("Lines Count is over. {@LinesCount}", diffVM.Lines.Count);
List<Run> lineView = ConvertLinveVmToRuns(diffVM.Lines.First());
var paragraph = new Paragraph();
paragraph.Inlines.AddRange(lineView);
return new FlowDocument(paragraph);
}
private static List<Run> ConvertLinveVmToRuns(DiffPiece lineVM) =>
lineVM.Type switch
{
//ChangeType.Modifiedだったら変更された部分だけハイライトしたいのでSubPieceからいろいろやる
ChangeType.Modified => lineVM
.SubPieces
.Select(x => ConvertPieceVmToRun(x))
.ToList(),
//ChangeType.Modified以外は行全体で同じ書式
_ => new List<Run> { ConvertPieceVmToRun(lineVM) },
};
private static Run ConvertPieceVmToRun(DiffPiece pieceVM) =>
new Run
{
Text = pieceVM.Text,
Foreground = (pieceVM.Type == ChangeType.Unchanged)
? normalTextBrush
: changedTextBrush,
//差分タイプによって、背景色を決定
Background = (pieceVM.Type switch
{
ChangeType.Deleted => deletedBrush,
ChangeType.Inserted => insertedBrush,
ChangeType.Imaginary => imaginaryBrush,
ChangeType.Modified => modifiedBrush,
_ => unchangeBrush
}),
};
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
LogTo.Error("Not Implemented");
return Binding.DoNothing;
}
}
RichTextBoxにFlowDocumentをBindingする方法は過去記事を参照ください。
WPFのRichTextBoxにBindingする
ファイルダイアログ
Livet.Extensionsを使用しています。
ButtonのTriggerに設定することで、Viewだけでダイアログ処理を完結することができます。
ViewModelにはユーザーが指定したファイルパス等の必要な情報だけが伝わります。
<Button Style="{StaticResource MaterialDesignRaisedButton}">
<behaviors:Interaction.Triggers>
<behaviors:EventTrigger EventName="Click">
<l:FolderBrowserDialogInteractionMessageAction>
<l:DirectInteractionMessage CallbackCommand="{Binding LoadFilesFromDialogCommand, Mode=OneTime}">
<l:FolderSelectionMessage
Description="Select Target Folder"
DialogPreference="None"
SelectedPath="{Binding SettingVM.Value.SearchFilePath.Value}" />
</l:DirectInteractionMessage>
</l:FolderBrowserDialogInteractionMessageAction>
</behaviors:EventTrigger>
</behaviors:Interaction.Triggers>
<materialDesign:PackIcon Kind="FolderOpen" />
</Button>
多言語対応
ResX Resource Managerを使用しました。
各言語を横に並べて入力できるので便利です。
デフォルトの言語はOSに合わせて変わります。ただし、日本語OS以外は持っていないので検証できていません。
設定メニューから変更することもできます。ただし、リソース文字列はx:Static
で取得しているので再起動後でないと反映されないです。
Markdown表示
アプリケーション情報ページではMarkdownを表示しています。
サードパーティライセンスのMarkdownファイルをリソースとして追加した上で、MarkDigライブラリで表示しています。
MarkDigの詳しい解説は以前の記事を参照ください。
C#でMarkdownを表示するライブラリMarkDigの紹介
ログ
ロギングはSerilogを使用しています。
メッセージテンプレートを使用して、以下の内容を保存しています。
現在時刻、ログレベル、スレッドID・名称、メッセージ本文、呼び出し元名前空間+クラス名、呼び出し元メソッドシグネチャ、行番号、使用メモリ量、(あれば例外)
より詳しくは過去記事を参照ください。
C#でSerilogとFody.Anotarを使って、全部盛りのログをとる
エラー時のログなどをMicrosoftStoreに送ったりも出来るらしいですが、やり方がわかっていません。
アイコン
パワーポイントでがんばりました。
デプロイ
Release
単一ファイルかつ、自己完結型で生成しました。
これにより、エンドユーザーは.NET Coreを事前にインストールする必要がありません。
Store配布
Microsoft Storeにアプリケーションを登録しました。
意外と簡単にできて、コストも2000円弱を初回に払うだけです。
有料アプリの場合は売上から割合で取るようです。
詳しい内容は過去記事、、、ではなくてそのうち書く記事を参考にしてください(もはや登録方法を忘れつつある)。
環境
VisualStudio 2019
C# 8
.NET Core 3.1
<ItemGroup>
<PackageReference Include="DiffPlex" Version="1.6.3" />
<PackageReference Include="LivetCask" Version="3.2.3.1" />
<PackageReference Include="LivetExtensions" Version="3.2.3.1" />
<PackageReference Include="MahApps.Metro" Version="2.1.1" />
<PackageReference Include="Markdig" Version="0.18.0" />
<PackageReference Include="Markdig.Wpf" Version="0.3.1" />
<PackageReference Include="MaterialDesignColors" Version="1.2.6" />
<PackageReference Include="MaterialDesignThemes" Version="3.1.3" />
<PackageReference Include="MaterialDesignThemes.MahApps" Version="0.1.4" />
<PackageReference Include="ReactiveProperty" Version="6.2.0" />
<PackageReference Include="System.Interactive" Version="4.1.1" />
<PackageReference Include="System.Reactive.Compatibility" Version="4.4.1" />
<PackageReference Include="Anotar.Serilog.Fody" Version="5.1.3" />
<PackageReference Include="Serilog" Version="2.9.0" />
<PackageReference Include="Serilog.Enrichers.Memory" Version="1.0.4" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<PackageReference Include="Serilog.Exceptions" Version="5.6.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="1.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="4.1.0" />
<PackageReference Include="Utf8Json" Version="1.3.7" />
</ItemGroup>
参考
MVVMとリアクティブプログラミングを支援するライブラリ「ReactiveProperty v2.0」オーバービュー
Livet/README.md
方法: DataGrid コントロールでデータをグループ化、並べ替え、およびフィルター処理する - WPF | Microsoft Docs
自己完結型アプリケーションのトリミング - .NET Core | Microsoft Docs
MaterialDesignInXamlToolkit/README.md
余談
クラス図のBindingの書き方はこれで良いか、あまり自信ありません。クラス図には書かないほうがよいのかな?でも書かないとViewとViewModelの対応関係わからんしな。
UML詳しい人いたら教えてほしいです。