1使っている画面
#
自分が毎日つまずいていたことを、道具にして片付けます。作るときに一番考えたのは、うまくいかなかったときに使う人が迷わないことです。
DesktopOrganizer
PS> Get-Item .\DesktopOrganizer | Format-List *
デスクトップに、アイコンをまとめる「棚」を置きます。フォルダの中身を映す棚、最近使ったファイルの棚、時計やメモの棚まで。

DesktopOrganizerの画面と技術的な詳細を読む(設計・つまずき・コード)
2作品の詳細
7種類の棚
手で並べる棚のほかに、中身を自分で集めてくる棚があります。デスクトップを片付けるだけでなく、デスクトップに置いておきたいものを置けるようにしました。
| 棚 | 中身 |
|---|---|
| 通常 | ドラッグで入れたアイコンを、好きな順に並べる |
| フォルダ同期 | 指定したフォルダの中身をそのまま映す。*.pdf のようなパターンで絞れて、フォルダの変化を監視して追従する |
| 最近使ったもの | Windowsの「最近使ったファイル」を監視して、新しい順に20件まで出す |
| 一時置き | 入れてから24時間たつと、自動で消える。1分ごとに期限を確認する |
| メモ | ちょっとしたテキストを書いておく |
| 時計 | 時刻を数字で出す |
| アナログ時計 | 時刻を針で出す |
デスクトップの邪魔をしない
見るときと、動かすときを分ける
ふだんは棚をドラッグしても動きません。棚の配置を変えるときだけ編集モードに切り替えます。うっかり棚を動かしてしまうことがありません。
フォーカスを奪わない
棚のウィンドウには、Win32のウィンドウ属性でフォーカスを奪わない設定(WS_EX_NOACTIVATE)と、Alt+Tabに出てこない設定(WS_EX_TOOLWINDOW)を付けました。作業中のウィンドウから入力を横取りしません。
アイコンが多くても、重くしない
見えている分だけ作る
棚の中身は、自作の VirtualizingWrapPanel で並べます。スクロールしないと見えないアイコンは、画面の部品を作りません。
アイコンはキャッシュする
一度取り出したアイコンは500件まで覚えておきます。古いものから捨てるLRU方式で、出し入れはどちらも件数に関係なく一定の手間で済みます(O(1))。
取り出しは同時に4つまで
アイコンの取り出しは非同期で、同時に走るのは4つまでに絞りました。256px、48px、32pxの順に、取れる一番大きいものを使います。
3こだわったこと
モニターをつなぎ替えても、棚は同じ場所にある
- やりたかったこと
-
ノートPCを外部モニターにつないだり外したりしても、棚がいつもの場所に戻ってきます。デスクトップの道具は、置いた場所が変わらないことがいちばん大事だと考えました。
- ぶつかった壁
-
棚の位置をピクセルで保存すると、解像度やタスクバーの位置が変わっただけでずれてしまいます。
棚があったモニターが外されると、棚ごと見えない場所に行ってしまいます。
- どう作ったか
-
棚の位置は、ピクセルではなくモニターの作業領域に対する割合で保存します。表示するときに、そのときの作業領域の大きさを掛けてピクセルに戻します。
左:位置は割合で持ちます。右:棚がいたモニターが見つからなければ、メインへ移します。 棚がどのモニターにいたかは、Windowsのデバイス名で覚えておきます。表示するときは、まず同じデバイス名のモニターを探し、無ければメイン、それも無ければ最初に見つかったモニターに置きます。
- どうなったか
-
解像度やタスクバーが変わっても、棚は同じ場所に出ます。モニターを外しても、棚は見える場所に移ってきます。
つまずいたところつかんだ棚が、マウスから逃げていく
- 起きたこと
編集モードで棚をドラッグすると、動かすほど、つかんだ位置と棚がずれていきました。
- 原因
ドラッグの始点と今の位置を、別々の物差しで測っていました。画面の実際のピクセルと、WPF の論理ピクセル(表示スケールで割った単位)のあいだの変換を途中で挟んでいたので、表示スケールが100%でないと、移動量が合いませんでした。
- 直し方
始点も今の位置も、棚を載せているウィンドウから見た論理ピクセルで測るようにしました。同じ物差しで測った2点の差なので、変換そのものが要らなくなりました。その1週間後には、複数のモニターをまたいだときのずれも直しました。
DesktopOrganizer.UI/Controls/ShelfControl.xaml.cs(44行目)
_startPoint = e.GetPosition(null);
要になったコード割合を、そのときの画面のピクセルに戻す
DesktopOrganizer.Core/Services/LayoutManager.cs(89〜92行目)
var x = (int)(waLeft + (shelf.X * waWidth));
var y = (int)(waTop + (shelf.Y * waHeight));
var w = (int)(shelf.Width * waWidth);
var h = (int)(shelf.Height * waHeight);
保存してあるのは、作業領域に対する割合(0〜1)だけです。表示するたびに、そのときの作業領域の大きさを掛けて、ピクセルに戻します。
解像度が変わっても、タスクバーの位置が変わっても、この4行が、そのときの画面での位置を計算し直します。
SimpleZipper
PS> Get-Item .\SimpleZipper | Format-List *
ファイルやフォルダをZIPにまとめるWindowsのツール。失敗したときに、次に何をすればいいかが分かることにこだわりました。

見どころ
SimpleZipperの画面と技術的な詳細を読む(設計・つまずき・コード)
1使っている画面
2作品の詳細
重い圧縮でも、画面を止めない
圧縮は別のスレッドで動かし、進み具合を画面に返します。いつでも中止できて、中止したことも結果として残ります。
日本語のファイル名を壊さない
新しく作るZIPは、ファイル名をShift_JISで書き込みます。Windowsの標準機能で開いたときに、日本語の名前が化けないようにするためです。大きなファイルは必要なときだけZip64にし、分割サイズは64KB未満や2GBを超える指定を自動で直します。
3こだわったこと
失敗したときに、次にすることが分かる
- やりたかったこと
-
圧縮がうまくいかなかったとき、使う人が自分で次の手を打てること。
- ぶつかった壁
-
ファイル操作の失敗は、種類がとても多いです。「エラーが発生しました」とだけ出しても、使う人は何もできません。
しかも、「空き容量が足りない」と「ほかのアプリがファイルを使っている」は、プログラムには同じ種類のエラー(
IOException)として届きます。 - どう作ったか
-
失敗を種類ごとに受け止めて、そのときに取れる行動を書きました。どのファイルを処理していたときの失敗かも添えます。
起きたこと 画面に出す案内 ファイルが見つからない ファイルが存在するか確認してください フォルダが見つからない パスを確認してください パスが長すぎる 名前を短くするか、より浅い階層に移動してください 空き容量が足りない 少なくとも(圧縮する元の合計サイズ)程度の空きを確保してください ほかのアプリが使っている 関連するプログラムを終了するか、しばらく待ってから再度お試しください アクセスする権限がない 管理者として実行するか、対象のアクセス権限を確認してください ZIPそのものの問題 パスワード・CRCの不一致・ZIP形式のサイズ上限など、原因の見当を一言添える 「空き容量が足りない」と「ほかのアプリが使っている」は、どちらも同じ
IOExceptionとして届きます。そこで、例外の中に入っているWindowsのエラーコード(HRESULT)を取り出して見分けています。容量不足は0x80070070と0x80070027、使用中は0x80070020と0x80070021。 - どうなったか
-
失敗しても、画面の案内を読めば次に何をすればいいかが分かります。うまくいったときだけでなく、失敗したときに迷わないことも品質だと考えています。
要になったコード同じ IOException を、中の番号で見分ける
Form1.cs(1761〜1775行目)
private bool IsDiskFull(System.IO.IOException ex)
{
const int HR_ERROR_HANDLE_DISK_FULL = unchecked((int)0x80070027);
const int HR_ERROR_DISK_FULL = unchecked((int)0x80070070);
int hr = System.Runtime.InteropServices.Marshal.GetHRForException(ex);
return hr == HR_ERROR_DISK_FULL || hr == HR_ERROR_HANDLE_DISK_FULL;
}
private bool IsSharingViolation(System.IO.IOException ex)
{
int hr = System.Runtime.InteropServices.Marshal.GetHRForException(ex);
const int HR_ERROR_SHARING_VIOLATION = unchecked((int)0x80070020);
const int HR_ERROR_LOCK_VIOLATION = unchecked((int)0x80070021);
return hr == HR_ERROR_SHARING_VIOLATION || hr == HR_ERROR_LOCK_VIOLATION;
}
空き容量が足りないときも、ほかのアプリがファイルを使っているときも、届くのは同じ IOException です。そこで、例外が持っている Windows のエラー番号(HRESULT)を取り出して比べます。
原因を見分けられるので、「空きを作ってください」と「そのアプリを閉じてください」を出し分けられます。
GitPR Fluent
PS> Get-Item .\GitTooljp | Format-List *
Gitに慣れていない学生が、コミットからPR作成までを事故なく進めるためのGitクライアント。

見どころ
1使っている画面


GitPR Fluentの技術的な詳細を読む(設計・つまずき・コード)
2作品の詳細
他人のリポジトリのmainに、うっかり直接pushしない
pushしようとしているのがmainかmasterで、しかもリポジトリの持ち主が自分でなければ、いったん止めて警告を出し、続けるかやめるかを選ばせます。持ち主はリモートのURLから、自分はGitHub CLIのログイン情報から取ります。
外のコマンドの出力を、正しく受け取る
両方の出力を同時に読む
標準出力と標準エラーを、イベントで並行して受け取ります。片方が詰まって、もう片方を待ち続けることがありません。
文字コードをUTF-8に揃える
呼び出すときの環境変数でGitの出力をUTF-8に固定し、日本語のファイル名やメッセージを化けさせません。ページャも切ります。
起動時に道具を確かめる
git・GitHub CLI・Visual Studio(vswhere経由)が使えるかを最初に調べ、足りなければ案内します。
3こだわったこと
押してはいけないボタンは、押せなくしておく
- やりたかったこと
-
Gitに慣れていない学生でも、コミットからPRまで事故なく進めること。コマンドは覚えなくてもいいですが、何をしているかは見えます。「Gitを隠さないが、事故らせない」を目標にしました。
- ぶつかった壁
-
Gitの事故の多くは、今の状態でやってはいけない操作をしてしまうことから起きます。変更が残ったままpullする、rebaseの途中で別の操作をする、といったこと。説明を読んでもらうだけでは防げません。
- どう作ったか
-
リポジトリの状態を、5つの組み合わせで持ちます。どれも立っていなければ、きれいな状態。
状態 意味 Dirty コミットしていない変更がある Unpushed pushしていないコミットがある NoUpstream リモートの追跡ブランチがまだない Rebase rebaseの途中(競合している可能性がある) AuthNg GitHub CLIのログインが必要 そして、ボタンを押せるかどうかを、この状態から決めます。押せる場面でも、危ない操作の前には確認を挟みます。
ボタン 押せるとき 押したときの確認 Commit 変更があって、rebase中でない メッセージが空なら止める Push pushしていないコミットがあるか、追跡ブランチがない。rebase中でない 他人のリポジトリのmainに直接pushしようとしたら警告する Pull (Rebase) rebase中でない 変更が残っていれば、先にコミットするか、stashするかを聞く Create PR rebase中でない ログインしていなければログインへ。pushしていなければ、先にpushするか聞く pullは常に
--rebaseで行います。競合したときは復旧用の画面に切り替わり、Visual Studioで解決してから続きを実行できます。 - どうなったか
-
押してはいけないボタンは、最初から押せません。いちばん上の画面では、変更はあるものの、まだコミットしておらず、pushするコミットがありません。だからPushのボタンは押せない状態になっています。
要になったコードPush を押せるかどうかを、1行で決める
ViewModels/MainViewModel.cs(1022行目)
private bool CanPush() => IsNotBusy && !IsRebaseMode && (CurrentState.HasFlag(RepoState.Unpushed) || CurrentState.HasFlag(RepoState.NoUpstream));
Push ボタンが押せるのは、作業中でなく、rebase の途中でもなく、送っていないコミットがあるか、まだ追跡ブランチがないときだけです。
ボタンは [RelayCommand(CanExecute = nameof(CanPush))] でこの条件とつながっています。押せない場面では、押す前から灰色になっています。