WPFでシンプルなスクラッチパッドを作る(コードビハインド中心)

コンピュータ

ちょっとしたテキストを一時的に書き留めておくために、
WPFでシンプルなスクラッチパッドを作りました。

用途はかなり限定的で、
主に PowerShell のワンライナーやコマンド断片の記録用です。

メモ帳やノートアプリのように、
整理・分類・検索といった機能は持たせていません。
「とりあえず書く」「あとで消してもいい」
そんな 作業中の思考置き場として使うことを目的にしています。

実装は コードビハインド中心で、
MVVM を厳密に適用することはせず、
View / State / Helper を最低限に分けるだけの構成にしました。

保存先はシンプルに テキストファイルで、
タイトルは本文の先頭行から自動生成されます。
名前を考える手間を省き、
「書くこと」に集中できる作りです。

ソースコード

ファイル名:App.xaml

<Application x:Class="ScratchPad.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:ScratchPad"
             StartupUri="MainWindow.xaml">
    <Application.Resources>
         
    </Application.Resources>
</Application>

ファイル名:App.xaml.cs

using System.Configuration;
using System.Data;
using System.Windows;

namespace ScratchPad;

/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}


ファイル名:Helpers\AppPathHelper.cs

// アプリケーション固有の保存パスを一元管理する Helper。
using System.Reflection;

namespace Maywork.WPF.Helpers;

public static class AppPathHelper
{
    /// アプリケーション名。
    public static string AppName { get; } = GetDefaultAppName();

    // ------------------------------------------------------------
    // static ctor
    // ------------------------------------------------------------

    static AppPathHelper()
    {
        EnsureDirectories();
    }

    // ------------------------------------------------------------
    // public paths
    // ------------------------------------------------------------

    /// 設定ファイル・ユーザー設定用(APPDATA)
    public static string Roaming =>
        System.IO.Path.Combine(
            Environment.GetFolderPath(
                Environment.SpecialFolder.ApplicationData),
            AppName);

    /// キャッシュ・ログ・一時データ用(LOCALAPPDATA)
    public static string Local =>
        System.IO.Path.Combine(
            Environment.GetFolderPath(
                Environment.SpecialFolder.LocalApplicationData),
            AppName);

    public static string SettingsFile =>
        System.IO.Path.Combine(Roaming, "settings.json");

    public static string CacheDir =>
        System.IO.Path.Combine(Local, "cache");

    public static string LogDir =>
        System.IO.Path.Combine(Local, "log");

    public static string TempDir =>
        System.IO.Path.Combine(Local, "temp");

    // ------------------------------------------------------------
    // helpers
    // ------------------------------------------------------------

    private static string GetDefaultAppName()
    {
        var asm = Assembly.GetEntryAssembly();
        return asm?.GetName().Name ?? "Application";
    }

    /// <summary>
    /// 必要なディレクトリをすべて作成する。
    /// </summary>
    public static void EnsureDirectories()
    {
        System.IO.Directory.CreateDirectory(Roaming);
        System.IO.Directory.CreateDirectory(Local);
        System.IO.Directory.CreateDirectory(CacheDir);
        System.IO.Directory.CreateDirectory(LogDir);
        System.IO.Directory.CreateDirectory(TempDir);
    }
}

/*

 【APPDATA (Roaming)】
 ・ユーザー設定ファイル
 ・UIレイアウト
 ・履歴情報
 ・ユーザー辞書など

 ※PC移行・ドメイン環境ではローミング対象。


 【LOCALAPPDATA (Local)】
 ・キャッシュデータ
 ・ログファイル
 ・一時ファイル
 ・画像サムネイル

 ※削除されても再生成可能なデータを保存する。
*/

ファイル名:Helpers\ViewModelBase.cs

// 汎用的な ViewModel 基底クラス実装。
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace Maywork.WPF.Helpers;

public abstract class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

ファイル名:MainWindow.xaml

<Window x:Class="ScratchPad.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:ScratchPad"
        mc:Ignorable="d"
        Title="スクラッチパッド" Height="800" Width="400">
        <TabControl
                x:Name="TabHost"
                TabStripPlacement="Bottom"/>
</Window>

ファイル名:MainWindow.xaml.cs

using System.Windows;
using System.Windows.Controls;

namespace ScratchPad;

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        // タブのアイテムを動的に追加するループ
        TabItem? firstTab = null;
        foreach (var factory in TabViewRegistry.Tabs)
        {
            var view = factory();

            var tab = new TabItem
            {
                Header = view.Title,
                Content = view   // ← UserControl
            };
            if (firstTab is null)
            {
                firstTab = tab;
            }
            TabHost.Items.Add(tab);
        }
        TabHost.SelectedItem = firstTab;
    }
}

ファイル名:ScratchPad.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net10.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
  </PropertyGroup>

</Project>

ファイル名:TabViewRegistry.cs


using ScratchPad.Views;


namespace ScratchPad;

public static class TabViewRegistry
{
    public static IReadOnlyList<Func<ITabView>> Tabs { get; } =
        [
            () => new ScratchPadView(),
        ];
}

ファイル名:Views\ITabView.cs

namespace ScratchPad.Views;

public interface ITabView
{
    string Title { get; }
}

ファイル名:Views\ScratchPadItem.cs

using System.IO;

using Maywork.WPF.Helpers;
public class ScratchPadItem : ViewModelBase
{
    string _title = "";
    string _content = "";

    public string Title
    {
        get => _title;
        set
        {
            if (_title == value) return;
            _title = value;
            OnPropertyChanged(nameof(Title));
        }
    }
    public string Content
    {
        get => _content;
        set
        {
            if (_content == value) return;
            _content = value;
            OnPropertyChanged(nameof(Content));
        }
    }
    private static string SanitizeFileName(string name)
    {
        var invalidChars = Path.GetInvalidFileNameChars();
        return new string(name
            .Select(c => invalidChars.Contains(c) ? '_' : c)
            .ToArray());
    }
    public ScratchPadItem()
    {
        
    }

    public static ScratchPadItem
    Create(string content)
    {
        string title = content.Split("\r\n")[0];
        title = SanitizeFileName(title);
        if (String.IsNullOrEmpty(title))
        {
            title = DateTime.Now.ToString("yyyyMMdd_HHmmss");
        }

        var obj = new ScratchPadItem
        {
            Title = title,
            Content = content,
        };

        return obj;
    }
}

ファイル名:Views\ScratchPadState.cs


using System.Collections.ObjectModel;

using Maywork.WPF.Helpers;

namespace ScratchPad.Views;

public class ScratchPadState : ViewModelBase
{
    public ObservableCollection<ScratchPadItem> Items = [];

    public ScratchPadItem? SelectedItem {get; set;}
    
}

ファイル名:Views\ScratchPadView.xaml

<UserControl x:Class="ScratchPad.Views.ScratchPadView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             FontSize="14">

    <Grid>
        <Grid.RowDefinitions>
            <!-- テキストエリア -->
            <RowDefinition Height="*"/>
            <!-- リストビュー -->
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>        
        
        <!-- テキストエリア -->
        <Grid Grid.Row="0">
            <Grid.RowDefinitions>
                <!-- テキストボックス -->
                <RowDefinition Height="*"/>
                <!-- 操作ボタン -->
                <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>
            <Grid Grid.Row="0">
                <TextBox x:Name="TextBox1"
                         AcceptsReturn="True"
                         TextWrapping="Wrap"/>
            </Grid>
            <Grid Grid.Row="1">
                <StackPanel Orientation="Horizontal"
                            HorizontalAlignment="Center">
                    <Button x:Name="NewButton"
                            Margin="4"
                            Padding="4"
                            Content="新規" />
                    <Button x:Name="UpdateButton"
                            Margin="4"
                            Padding="4"
                            Content="更新" />
                    <Button x:Name="DeleteButton"
                            IsEnabled="False"
                            Margin="4"
                            Padding="4"
                            Content="削除" />
                    <Button x:Name="CopyButton"
                            IsEnabled="False"
                            Margin="4"
                            Padding="4"
                            Content="コピー" />
                </StackPanel>
            </Grid>
        </Grid>

        <!-- リストビュー -->
        <Grid Grid.Row="1">
            <ListView x:Name="List"
                      SelectedItem="{Binding SelectedItem, Mode=TwoWay}"
                      SelectionMode="Single">
            <ListView.ItemContainerStyle>
                <Style TargetType="ListViewItem">
                    <Setter
                        Property="FontSize"
                        Value="14"/>
                    <Setter
                        Property="Padding"
                        Value="4"/>
                </Style>
            </ListView.ItemContainerStyle>
            <ListView.View>
                <GridView AllowsColumnReorder="False">
                    <GridViewColumn
                        Header="名前"
                        Width="300"
                        DisplayMemberBinding="{Binding Title}"/>
                </GridView>
            </ListView.View>            
            </ListView>
        </Grid>
    </Grid>

</UserControl>

ファイル名:Views\ScratchPadView.xaml.cs

using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;

using Maywork.WPF.Helpers;

namespace ScratchPad.Views;
public partial class ScratchPadView : UserControl, ITabView
{
    public string Title => "スクラッチパッド";
    public ScratchPadState State;

    static string GetTextPath(string title)
    {
        var dir = AppPathHelper.Roaming;
        var txtDir = Path.Combine(dir, "text");
        
        var path = Path.Combine(txtDir, $"{title}.txt");

        return path;
    }
    public ScratchPadView()
    {
        InitializeComponent();

        State = new();

        Loaded += (_, __) => Init();

        NewButton.Click += (_, __) => NewText();
        UpdateButton.Click += (_, __) => UpdateText();
        DeleteButton.Click += (_, __) => DeleteText();
        CopyButton.Click += (_, __) => CopyText();

        List.SelectionChanged += (_, __) => SelectionChanged();
        TextBox1.TextChanged += (_, __) => TextChanged();

        List.ItemsSource = State.Items;
        this.DataContext = State;
    }
    // 初期化
    void Init()
    {
        var dir = AppPathHelper.Roaming;
        var txtDir = Path.Combine(dir, "text");
        if (!Directory.Exists(txtDir))
        {
            Directory.CreateDirectory(txtDir);
            return;
        }
        var files = Directory.GetFiles(txtDir, "*.txt");
        State.Items.Clear();
        foreach (var file in files.OrderBy(f => f))
        {
            string content = File.ReadAllText(file);
            var item = ScratchPadItem.Create(content);
            State.Items.Add(item);
        }
    }
    // 新規
    void NewText()
    {
        TextBox1.Text = String.Empty;
        List.SelectedItem = null;
    }
    // 
    void SelectionChanged()
    {
        if (State.SelectedItem is null)
        {
            DeleteButton.IsEnabled = false;
            return;
        }
        DeleteButton.IsEnabled = true;
        TextBox1.Text = State.SelectedItem.Content;
    }
    // 更新
    void UpdateText()
    {
        string content = TextBox1.Text;

        if (string.IsNullOrEmpty(content)) return;

        var item = ScratchPadItem.Create(content);
        var existing = State.Items.FirstOrDefault(x => x.Title == item.Title);

        string txtFile = GetTextPath(item.Title);
        File.WriteAllText(txtFile, item.Content);

        if (existing != null)
        {
            // 既存 → 更新
            existing.Content = item.Content;
        }
        else
        {
            // 無ければ追加
            State.Items.Add(item);

            if (List.Items.Count > 0)
            {
                List.SelectedIndex = List.Items.Count - 1;
            }            
        }
        UpdateButton.IsEnabled = false;
    }
    // 削除
    void DeleteText()
    {
        if (State.SelectedItem is null) return;

        var item = State.SelectedItem;
        var existing = State.Items.FirstOrDefault(x => x.Title == item.Title);

        if (existing is null) return;

        string txtFile = GetTextPath(existing.Title);
        if (File.Exists(txtFile))
            File.Delete(txtFile);
        State.Items.Remove(existing);
        State.SelectedItem = null;


        NewText();
    }
    // 入力変更
    void TextChanged()
    {
        string content = TextBox1.Text;

        if (string.IsNullOrEmpty(content))
        {
            NewButton.IsEnabled = false;
            UpdateButton.IsEnabled = false;
            CopyButton.IsEnabled = false;
        }
        else
        {
            NewButton.IsEnabled = true;
            UpdateButton.IsEnabled = true;  
            CopyButton.IsEnabled = true;
        }
    }
    // コピー
    void CopyText()
    {
        string content = TextBox1.Text;
        if (string.IsNullOrEmpty(content)) return;

        // クリップボードへコピー
        Clipboard.SetText(content);
    }
}

実行例

コメント