2021年3月21日日曜日

Directory.CreateDirectory()を実行する前にディレクトリが作成可能かを確認する

ここ数日悩んでいた件です。

C#にはDirectory.CreateDirectory()というメソッドがあり、指定したパスにディレクトリを作ることができます。しかし、当然どんなところにでも作れるわけではなく、例えば存在しないドライブに書き込もうとした、管理者権限じゃないと書き込めないフォルダだった("C:\Program Files"など)、書き込み権限の無いフォルダだったなどの理由で、このメソッドは実行時に失敗する(例外を吐く)可能性があります。

そこで、実際にディレクトリを作るわけではなく、事前にディレクトリを作ることができるのかを確認する方法を模索していました。

 

結論を言います。

良いから「ディレクトリを1回作ってみて、作れたらそのディレクトリを削除する」という方法で片付けろ。

 

いろいろ調べていたのですが、これ以上良い方法が見つかりませんでした(逆に何かいい方法知っている人いたら教えてください…)。

フォルダの書き込み権限を確認する

一応、C#でもフォルダの書き込み権限を確認することはできます。ただし、Windows依存のコードになります。

var access = new DirectoryInfo(path).GetAccessControl();
var rules = access.GetAccessRules(true, true, typeof(NTAccount));

ただし、このGetAccessControl()はFileSystemAclExtensions.GetAccessControl()拡張メソッドで、Nuget経由でパッケージをインストールする必要があります。

このGetAccessRules()メソッドが返す値が、いわゆるフォルダを右クリック→プロパティ→セキュリティ→詳細設定で出てくるフォルダの読み書き権限に相当するものとなります。

今実行中のアプリケーションとして書き込めるかどうかを知りたいだけなのに、ファイルシステムの権限情報が全部出てくるのです。今の自分はこのどれに相当するのかを見分けなければなりません。暗礁に乗り上げてきた気がしてきました。

実行中のアプリケーションのプリンシパルを取得する

実行中のアプリケーションのプリンシパルを取得しようとしたら、WindowsIdentity.Current()を呼ぶことになります。

そのメソッドが返したWindowsIdentityインスタンスのNameプロパティを見ると現在実行中のユーザー名、Groupsプロパティを見ると自身が属するグループ名が出てきます。これらを前項のプリンシパルと突き合わせれば良いのかと思いきや、どうもそれは完全ではないようです。例えば、ネットワークドライブ上のフォルダはそれでうまくいきませんでした。

 

そもそも、OS上では充分に抽象化された「ディレクトリ」いう概念でプログラムを書いていたのに、一気に具体的なファイルシステムがどうとか、Windowsのアクセスコントロール機能がどうとか、そういうレイヤーのプログラムを書くことを強いられてしまいます。世の中には多種多様な記録媒体やファイルシステムがあり、それらすべてについて正確に書き込みができるかどうかを判断するコードをこのレベルで書くのはそう簡単にできるものではありません。

ですので、最初に書いた通り、ひとまずディレクトリを作ってみて確かめるのがやはり一番いい方法という結論に私は達しました。 

ディレクトリを作って削除するのも、実際のファイルシステムを汚している感があったり、後のDeleteで誤ったフォルダを削除してしまわないように慎重にコードを書く必要があったり、そもそも例外発生前提でプログラムを書く必要があるのでそれなりに神経を使う必要があったりと、こちらも何かと苦労があります。ですから、書き込み権限を事前に確認して終わらせようという発想になっていたはずです。

はあ、こういうの、標準でDirectory.CanCreateDirectory()みたいなメソッドが用意されてワンタッチに判定してもらえるようになったりしないのかなあ。しないんだろうなあ。抽象化(=様々なOS/ファイルシステムで正常に動作するように)するのが難しいから用意されていないんだろうなあ…。

何かいい方法ご存知の方いれば教えてください。

2021年3月14日日曜日

SliderのAutoToolTipPlacementをカスタマイズする

SliderのAutoToolTip

WPFのSliderにはAutoToolTipPlacementというプロパティがあり、これにNone以外の値を設定することで、Sliderを動かしている最中に自動的につまみ(Thumb)に連動するツールチップを表示させ、値をリアルタイムに確認できるようになります。

<Slider VerticalAlignment="Center" Minimum="0" Maximum="10" AutoToolTipPlacement="TopLeft" />

非常に手軽で強力ですね。

しかし、これが案外WPFらしからぬ機能なのです。というのもWPFはXAMLの力でUIがかなり柔軟に書けるのが特徴なはずなのですが、このツールチップのテンプレートはおろか、数値のフォーマットすら指定できません。申し訳程度にできるのは、AutoToolTipPrecisionプロパティを使って小数点以下の表示桁数を指定する程度です。

<Slider VerticalAlignment="Center" Minimum="0" Maximum="10" AutoToolTipPlacement="TopLeft" AutoToolTipPrecision="2" />

そのため、例えばDateTimeのような、単純な数値以外を扱うSliderを作っただけで詰んでしまいます。

【MainWindow.xaml】

<Window
    x:Class="SliderTest.Views.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
    xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
    xmlns:v="clr-namespace:SliderTest.Views"
    xmlns:vc="clr-namespace:SliderTest.Views.Converters"
    xmlns:vm="clr-namespace:SliderTest.ViewModels"
    Title="MainWindow"
    Width="525"
    Height="350">

    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <Window.Resources>
        <vc:DateTimeTickConverter x:Key="DateTimeTickConverter" />
    </Window.Resources>

    <i:Interaction.Triggers>
        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodName="Initialize" MethodTarget="{Binding}" />
        </i:EventTrigger>

        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction />
        </i:EventTrigger>
    </i:Interaction.Triggers>

    <Grid >
        <Slider VerticalAlignment="Center" AutoToolTipPlacement="TopLeft" AutoToolTipPrecision="0"
                Minimum="{Binding Minimum, Converter={StaticResource DateTimeTickConverter}}"
                Maximum="{Binding Maximum, Converter={StaticResource DateTimeTickConverter}}"
                Value="{Binding Value, Converter={StaticResource DateTimeTickConverter}}" />
    </Grid>
</Window>

【DateTimeTickConverter.cs】

[ValueConversion(typeof(DateTime), typeof(long))]
public class DateTimeTickConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if(value is DateTime val)
            return val.Ticks;
        else
            return DependencyProperty.UnsetValue;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if(value is double val)
            return new DateTime((long)val);
        else
            return DependencyProperty.UnsetValue;
    }
}

【MainWindowViewModel.cs】

public class MainWindowViewModel : ViewModel
{
    public void Initialize()
    {
        var now = DateTime.Now;
        Minimum = now;
        Value = now;
        Maximum = now + TimeSpan.FromDays(1);
    }


    public DateTime Minimum
    {
        get { return _Minimum; }
        set { 
            if(_Minimum == value)
                return;
            _Minimum = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Minimum;


    public DateTime Maximum
    {
        get { return _Maximum; }
        set { 
            if(_Maximum == value)
                return;
            _Maximum = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Maximum;


    public DateTime Value
    {
        get { return _Value; }
        set { 
            if(_Value == value)
                return;
            _Value = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Value;
}

何の変哲もない、ただ単にDateTimeをConverter経由でSliderにバインディングするだけのプログラムです。

なのに、このようにツールチップに表示される値はDateTimeのTicksの値になってしまい、アプリユーザーにとっては無意味な数の羅列となってしまうのです。変換しているので当然なのですが、それはもちろんDateTimeがそのままではSliderのMinimum/Maximum/Valueにバインディングできないからです。

通常のWPF脳ならば、BindingのStringFormatみたいなのは無いかなとか、DataTemplateでToolTipをカスタマイズできないかなとか考えるわけです。が、どんなに探してもSliderにあるこの自動ツールチップに設定できるパラメーターは「ツールチップの表示位置」と「数値の小数点以下の桁数」のみなのです。困った困った。

先人の知恵

さて、本件ちょっとググると、まあ10年以上前にこの解決策を提示してくれている人はすぐに見つかるわけです。

Modifying the auto tooltip of a Slider 

比較的シンプルなコードで作っています。Sliderクラスを継承したFormattedSliderクラスを作り、そこにAutoToolTipFormatというプロパティを作る作戦ですね。

ただし、少しコードを読んでいくとビビります。というのも、Sliderクラス内部のprivateフィールドである「_autoToolTip」をリフレクションで無理矢理取り出し、それを改造することで数値のフォーマット化を実現しているのです。 うーん、さすがに無理矢理すぎる気が。現実的にはあまり問題無いのかもしれませんが、例えば.NETの内部実装のリファクタリングで_autoToolTipという名前が変えられただけで正常に動かないソフトになってしまいます。

それが許せる人はこれを使えば良いかもしれませんが、個人的にはちょっと禁忌に触れた黒魔術感があって嫌だなー…。

Behaviorによる実装

さて、やっと本題に入ります。私は黒魔術ではなく正攻法で攻めていきます。

上のようにSliderクラスを継承した新しいクラスを作っても良いのですが、WPFの場合は強力なカスタマイズ機能によりほとんどそのような手順は不要です。既存のクラスに添付して振る舞いを変えさせたければBehaviorでしょう。

【SliderThumbToolTipBehavior.cs】

public class SliderThumbToolTipBehavior : Behavior<Slider>
{
    ToolTip? tooltip;

    protected override void OnAttached()
    {
        base.OnAttached();

        AssociatedObject.AddHandler(Thumb.DragStartedEvent, (DragStartedEventHandler)Thumb_DragStarted);
        AssociatedObject.AddHandler(Thumb.DragDeltaEvent, (DragDeltaEventHandler)Thumb_DragDelta);
        AssociatedObject.AddHandler(Thumb.DragCompletedEvent, (DragCompletedEventHandler)Thumb_DragCompleted);
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();

        AssociatedObject.RemoveHandler(Thumb.DragStartedEvent, (DragStartedEventHandler)Thumb_DragStarted);
        AssociatedObject.RemoveHandler(Thumb.DragDeltaEvent, (DragDeltaEventHandler)Thumb_DragDelta);
        AssociatedObject.RemoveHandler(Thumb.DragCompletedEvent, (DragCompletedEventHandler)Thumb_DragCompleted);
    }

    void Thumb_DragStarted(object sender, DragStartedEventArgs e)
    {
        if(Placement != AutoToolTipPlacement.None && e.OriginalSource is Thumb thumb) {
            if(tooltip == null) {
                tooltip = new ToolTip();
                tooltip.Placement = PlacementMode.Custom;
                tooltip.PlacementTarget = thumb;
                tooltip.CustomPopupPlacementCallback = ToolTip_CustomPopupPlacementCallback;
            }

            thumb.ToolTip = tooltip;
            tooltip.Content = ToolTipTemplate.LoadContent();
            tooltip.IsOpen = true;
            TooltipReposition();
        }
    }

    void Thumb_DragDelta(object sender, DragDeltaEventArgs e)
    {
        TooltipReposition();
    }

    void Thumb_DragCompleted(object sender, DragCompletedEventArgs e)
    {
        if(Placement != AutoToolTipPlacement.None && e.OriginalSource is Thumb thumb && tooltip != null) {
            tooltip.IsOpen = false;
        }
    }

    void TooltipReposition()
    {
        if(tooltip != null) {
            double temp;
            if(AssociatedObject.Orientation == Orientation.Horizontal) {
                temp = tooltip.HorizontalOffset;
                tooltip.HorizontalOffset += 0.125;
                tooltip.HorizontalOffset = temp;
            } else {
                temp = tooltip.VerticalOffset;
                tooltip.VerticalOffset += 0.125;
                tooltip.VerticalOffset = temp;
            }
        }
    }

    CustomPopupPlacement[] ToolTip_CustomPopupPlacementCallback(Size popupSize, Size targetSize, Point offset)
    {
        CustomPopupPlacement? ret = null;

        switch(Placement) {
            case AutoToolTipPlacement.TopLeft:
                if(AssociatedObject.Orientation == Orientation.Horizontal)
                    ret = new CustomPopupPlacement(new Point((targetSize.Width - popupSize.Width) * 0.5, -popupSize.Height), PopupPrimaryAxis.Horizontal);
                else
                    ret = new CustomPopupPlacement(new Point(-popupSize.Width, (targetSize.Height - popupSize.Height) * 0.5), PopupPrimaryAxis.Vertical);
                break;
            case AutoToolTipPlacement.BottomRight:
                if(AssociatedObject.Orientation == Orientation.Horizontal)
                    ret = new CustomPopupPlacement(new Point((targetSize.Width - popupSize.Width) * 0.5, targetSize.Height), PopupPrimaryAxis.Horizontal);
                else
                    ret = new CustomPopupPlacement(new Point(targetSize.Width, (targetSize.Height - popupSize.Height) * 0.5), PopupPrimaryAxis.Vertical);
                break;
        }

        if(ret != null)
            return new CustomPopupPlacement[] { ret.Value };
        else
            return Array.Empty<CustomPopupPlacement>();
    }



    public AutoToolTipPlacement Placement
    {
        get { return (AutoToolTipPlacement)GetValue(PlacementProperty); }
        set { SetValue(PlacementProperty, value); }
    }
    public static readonly DependencyProperty PlacementProperty =
        DependencyProperty.Register(nameof(Placement), typeof(AutoToolTipPlacement), typeof(SliderThumbToolTipBehavior), new PropertyMetadata(default(AutoToolTipPlacement)));


    public DataTemplate ToolTipTemplate
    {
        get { return (DataTemplate)GetValue(ToolTipTemplateProperty); }
        set { SetValue(ToolTipTemplateProperty, value); }
    }
    public static readonly DependencyProperty ToolTipTemplateProperty =
        DependencyProperty.Register(nameof(ToolTipTemplate), typeof(DataTemplate), typeof(SliderThumbToolTipBehavior), new PropertyMetadata(default(DataTemplate)));
}

【MainWindow.xaml】(抜粋)

<Slider VerticalAlignment="Center" 
        Minimum="{Binding Minimum, Converter={StaticResource DateTimeTickConverter}}"
        Maximum="{Binding Maximum, Converter={StaticResource DateTimeTickConverter}}"
        Value="{Binding Value, Converter={StaticResource DateTimeTickConverter}}" >
    <i:Interaction.Behaviors>
        <vb:SliderThumbToolTipBehavior Placement="TopLeft" >
            <vb:SliderThumbToolTipBehavior.ToolTipTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Value, StringFormat=HH:mm:ss.fff}" />
                </DataTemplate>
            </vb:SliderThumbToolTipBehavior.ToolTipTemplate>
        </vb:SliderThumbToolTipBehavior>
    </i:Interaction.Behaviors>
</Slider>

まず、OnAttatchedで3つの添付イベントをリッスンし、つまみを握ったときにツールチップ表示、動かしたときにツールチップ位置変更、離したときにツールチップ非表示を行います。ミソは、このイベントのe.OriginalSourceにつまみ(Thumbクラス)のインスタンスが入っていると言うことですね。

このビヘイビア自体には2つの依存関係プロパティを用意しています。1つ目はPlacementで、これはSlider本家のAutoToolTipPlacementと同じ役目なので説明はいらないでしょう。2つ目はToolTipTemplateで、ツールチップの中身に表示するものを定義するテンプレートです。これを用意することにより、「値のフォーマット化」にとらわれず自由自在に表示内容を設定できるようにしています。

ツールチップの位置を変える方法ですが、残念ながら直接的に再計算を要求させる方法(ToolTip.CustomPopupPlacementCallbackを呼ばせる方法)は無いようです。ですので、不本意ながら「HorizontalOffset/VerticalOffsetを変更して戻す」という手段を用いることで再計算させています。2度再計算させちゃうので効率悪いんですがね。

XAMLではSlider本家のAutoToolTipPlacementは使わず、ビヘイビアのほうのPlacementを設定します。あとは、ToolTipTemplateにツールチップの内容を設定するだけです。

これで見事ツールチップに意図した通りのものを表示できるようになりました。めでたしめでたし。