2021/11/27

ソリッドなL型ミニプラグ

ヘッドホンのミニプラグをオヤイデ電気のP-3.5 SRL+スプリングに交換したので、記録として。

有線ヘッドホンは、定番のオーソドックスなものにしておこうという理由でSonyのMDR-CD900STを随分前に買って、これは実際に交換部品の入手のしやすさで正解だったのだけど、元のプラグは太い標準プラグなのでミニプラグへの改造が前提で、これを何度か交換してきた。

PCやタブレットのオーディオジャックは側面にあるので、L型のプラグの方が取り回しがよさそうということで、オヤイデのP-3.5 SRLを買ってみたものの、ハンダ付けの難易度が高そうなのと、スプリングが付いておらず、ケーブルの穴径が6mmで穴が余るので(後に穴径が4mmのものが出た)、使わずじまいになっていた。

で、前回の交換時にハンダ付けに手間取っている間に熱で樹脂部分が傷んでいたのか、プラグが壊れたので、P-3.5 SRLを出して眺めていて、ふと思いついて前のプラグに付いていたスプリングをぐいぐい押し込んでみたら、スプリングの外径がぴったり合うことが分かったので、これでやってみるかと作業開始。

黒のアースを付ける位置に迷ったが、フラックスを塗布しておくことでハンダ付けは問題なく終了。

と思ったら、かしめるためにケーブルをねじる途中で黒が切れたので付け直してから固定。ちなみに、現行製品とは端子の穴が違う。

よく見ればスプリングとはメッキの光沢が違うものの、コンパクトかつソリッドな外見で、断線防止のスプリングもあるという、個人的に理想のL型ミニプラグができた。

2021/11/14

Surface Pro 8にWindows 10をインストール

Surface Pro 8はSurface Proシリーズのユーザーが待ち望んでいた新機種ですが、発売タイミングの関係か、一般向けはWindows 11プリインストール機のみの販売となっています。

純粋に使うだけなら別にWindows 11でもいいですが、検証などを考えるとWindows 10の環境も当面必要なので、Windows 10をクリーンインストールしてみたところ、3点引っ掛かったことがあったので、メモしておきます。

1. インストール用USBメモリ

これはSurface Pro 8に特有の点ではないですが、Surface Pro 8のUSBコネクタはUSB-C(Thunderbolt 4)だけなので、インストール用USBメモリは、USB-Cのものを使うか、USB-CとUSB-Aの変換コネクタをかませる必要があります。

ここで、容量が大きめ(32GB=32768MB超)のUSBメモリを使おうとすると、フォーマットでファイルシステムが自動的にexFATになりますが、USBメモリからブートするにはFAT32である必要があるので、何らかの方法でFAT32にする必要があります。

ただ、この点はMicrosoftのメディア作成ツールでUSBメモリの作成までやってしまえば、自動的に32GBでFAT32のパーティションが作られるので、たいした問題ではありません。

2. 入力デバイス

インストール用USBメモリからブートすると、最初に言語や地域を設定する画面が出ますが、ここで本体のタッチスクリーンもSignatureキーボードも全く反応せず、何も操作できないので詰みます。

この原因が分からず、しばらく試行錯誤したのですが、ふと外付けUSBキーボードを繋いでみたら動きました。

つまり、タッチスクリーンにせよ、Signatureキーボードにせよ、これらのためのどこかのデバイスがインストーラーに含まれたインボックスドライバーでは動かないらしいというオチでした。これはOSのインストール後もWindows Updateをかけて各種ドライバーを更新するまで解決されないので、そういうことなのでしょう。

正攻法で行くならば、Surface Pro 8のドライバーパッケージ(後述)から該当するドライバーを探し出し、予めインストーラーに組み込んでおく方法で解決できるはずですが、USBキーボードを繋いだ方が百倍速いということで。

なお、使用したインストーラーは21H1のもので、Windows 11のインストーラーでも同じでしたが、いずれ修正されるかも。

3. 内蔵カメラ

OSのインストール後にWindows Updateをかければ、ほとんどのデバイスは使用可能になりますが、内蔵カメラだけは使えません。カメラが使えないということはWindows Helloの顔認証も使えないということで、実用上見過ごせない問題です。

デバイスマネージャーをWindows 11と見比べると、Windows 11では「カメラ」に存在する「Intel(R) TGL AVStream Camera」が存在せず、代わりに「ほかのデバイス」に「ISP Camera Device」があるので、これがそれらしいと目星はつきましたが、Windows Updateではドライバーが入りません。

しばらく探したところ、MicrosoftがSurface Pro 8のWindows 11用のドライバーパッケージを公開していました。
この中のWindows 10用(SurfacePro8_Win10_19042で始まるもの)をインストールし、展開された「SurfaceUpdate」フォルダーの中から「Cameras」フォルダーを指定して、「ISP Camera Device」のドライバーの更新をかけたところ、「Intel(R) TGL AVStream Camera」が出現し、カメラが使えるようになりました。これでWindows Helloの顔認証も問題なし。

以上で、とりあえずのところ、Windows 10の動作に支障は出ていません。

[追記]
11/19にWindows 10用のドライバーパッケージが追加されたので、Windows 11に一旦インストールする工程は不要になりました。

4. おまけ

今回のSurface Pro 8はi7のモデルを買いましたが、季節柄か普段使いではファンはほとんど回らないので静かです。

ストレージは512GBですが、ベンダーは予想外のKIOXIA

型番のKBG40ZNSで検索してみると、BG4というシリーズのSSDのようです。ホワイトペーパーによると、CrystalDiskMark 6.0による計測でシーケンシャルリードが最大2,336MB/s、シーケンシャルライトが最大1,815MB/sということなので、以下のCrystalDiskMark 8.0の結果は大体こんなものかと思います。

SignatureキーボードはUS版をAmazon USから購入。色はSurface Pro 7で使ってきたプラチナという名のグレイの使用感が今一だったのでブラックにしましたが、地味だったかも。

2021/10/08

Winget 1.1

CUIからソフトウェアパッケージのインストールができるWindows Package Manager、別名WingetのVer 1.1がリリースされています。
Wingetは活発に開発が進められていますが、今回の大きな変化としてMicrosoftストアのアプリにもアクセスできるようになっています。これがどういうことかというと、従来はアプリをWingetでインストールできるようにするには、Wingetのレポジトリに登録する作業が必要だったのですが、Microsoftストアにあるアプリはこれが不要になり、そのままでWingetから見えるようになりました。

したがって、Wingetが使える環境であれば、例えばMonitorianをインストールするのに、コマンドプロンプトから以下を実行するだけでできてしまいます。
winget install Monitorian

超簡単! 便利! 楽!

まあ実用的に使うには、どうオプションを付けるか考えて詰める必要はありますが。

既存のChocolateyもありますが、追加的な手間がかからないというのは大きいです。

2021/08/04

Microsoftストアからのアプリの更新

Microsoftストアで公開したアプリは、Desktop Bridgeの場合も含めて、新しいバージョンが公開されているときは「Microsoft Store」アプリでそのアプリのページを開くと自動的に更新がかかります。ただ、これは往々にしてエラーになり、一旦アンインストールしての再インストールを強いられたりします。

一方、ストアのAPIには、アプリからアプリ自身の更新ができるAPIが用意されています。
このAPIはてっきりUWP用かと思っていたのですが、Desktop Bridgeでも使えるようなので、試してみました。

上のページにあるサンプルを必須部分だけにして、Desktop Bridge用の処理を加えると以下のようになります。

// using Windows.Services.Store;
public async Task DownloadAndInstallAllUpdatesAsync(Window window)
{
StoreContext context = StoreContext.GetDefault();
// Get the updates that are available.
IReadOnlyList<StorePackageUpdate> updates = await context.GetAppAndOptionalStorePackageUpdatesAsync();
if (updates.Count > 0)
{
SetOwnerWindow(context, window);
// Download and install the updates.
StorePackageUpdateResult result = await context.RequestDownloadAndInstallStorePackageUpdatesAsync(updates);
Debug.WriteLine($"Result - {result.OverallState}");
}
}
[ComImport]
[Guid("3E68D4BD-7135-4D10-8018-9FB6D9F33FA1")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IInitializeWithWindow
{
void Initialize(IntPtr hwnd);
}
public void SetOwnerWindow(StoreContext context, Window window)
{
var handle = new WindowInteropHelper(window).Handle;
if (handle == IntPtr.Zero)
throw new InvalidOperationException();
var initWindow = (IInitializeWithWindow)(object)context;
initWindow.Initialize(handle);
}
view raw StoreSample2.cs hosted with ❤ by GitHub
見てのとおり、
  1. StoreContext.GetAppAndOptionalStorePackageUpdatesAsyncメソッドで新しいバージョンのあるパッケージ情報(StorePackageUpdate)を取得して、この有無をチェックする
  2. この情報を使ってStoreContext.RequestDownloadAndInstallStorePackageUpdatesAsyncメソッドを実行する
これだけ見ると簡単です。が、テストしてみると全然ダメでした。

1.は、常にパッケージ情報が1つ返ってくるが、このパッケージのバージョン(StorePackageUpdate.Package.Id.Version)は、新しいパッケージのバージョンではなく、現在実行中のパッケージのバージョンを示すので、新しいパッケージがあるかは判別できない。

2.は、このパッケージ情報を使って実行しても現在のパッケージが再インストールされるだけのようで、更新にならない。

これは使い物にならないかなと思いつつ、数少ない先例を見てみると、少し気になることが。 この下の方で、Microsoftの人がどの環境(テスト用か、本番用か)で実行したか聞いているので、環境によって動作が違うのかも。と、自分もローカルでWindows Application Packaging Projectからインストールした場合と、ストアでPackage flight(特定ユーザーへの限定公開=テスト用)として公開した場合で試しただけだったので、この際、ストアでSubmission(一般ユーザーへの公開=本番用)として公開してみました(これを実行する古いパッケージと、新しいパッケージの両方)。

その結果、この状態であれば期待どおりに更新に使えることが分かりました。

1.は、新しいパッケージがないときはパッケージ情報が返ってこないので、その有無を捉えればよい。すなわち、以下のように、上のメソッドの前半だけで判別可能。

// using Windows.Services.Store;
public async Task<bool> CheckAllUpdatesAsync()
{
StoreContext context = StoreContext.GetDefault();
IReadOnlyList<StorePackageUpdate> updates = await context.GetAppAndOptionalStorePackageUpdatesAsync();
return (updates.Count > 0);
}
view raw StoreSample3.cs hosted with ❤ by GitHub
2.は、このパッケージ情報を使って実行すると新しいパッケージがインストールされる。

ということで、Desktop Bridgeでも使えることが確認できました。このAPIを利用すればアプリに手動/自動での更新機能を付けられるので便利、なのですが、デバッグを本番環境でやらなければならない、そのために修正の度にパッケージをSubmissonとして公開しなければならないのは、時間的に勘弁してほしいとこです。

2021/05/30

タフな2.5インチドライブ用ケース

2.5インチドライブ用の外付けケースとして最もタフな部類に入るSilverStoneのSST-MMS01Bを使っていましたが、StarTech.comのS251BRU31C3に買い替えたので、記録として。

この2つ、ベンダーは違いますが、コントローラー基板以外の筐体は見たとおり全く同じなので、ODM元は同じなのでしょう。

インターフェイスとコネクタは以下のとおり。
  • SST-MMS01B : USB 3.0 : Micro-B 3.0
  • S251BRU31C3 : USB 3.1 : USB-C
Micro-B 3.0はとにかく華奢で、SST-MMS01Bでもいかにもタフな筐体に見合っていない感がありましたが、どうもコネクタが歪んでしまったようで、わざと傾けて力をかけないと認識されなくなったので、引退となりました。その点、S251BRU31C3のUSB-Cはコネクタがカチリと固定されて安定感があります。

筐体の中はどんなものかというと、ドライブはコネクタ以外の全方向がゴム素材で支えられる構造になっていて、縁には防水のパッキンがあります。

蓋は六角ネジで固定するようになっています。外側のゴムジャケットも合わせて厚みは30mmとなるので、ドライブが9.5mm厚として上下に10mmの緩衝スペースがある計算になります。見えませんが、ネジ穴部分の内側にワッシャー型のパッキンがあり、ネジ穴からの防水になっています。

コネクタにはパッキン付きの蓋があります。コネクタの貫通部はシールされているようには見えないので、蓋は閉めておかないと防水性は発揮されないかと。

一応性能確認のため、古いものですがSanDiskのExtreme Proを繋いでMBP2019からAmorphousDiskMarkで計測した結果。コントローラーはasmediaのASM235CMでした。

性能的には十分かと思います。

ということで、データを物理的要因から保護するケースとして現状不満点はないです。なお、難点というほどではないですが、アクセスランプはなく、HDDでも動作音はほとんど漏れてこないので、外からは動作状況が分からないのが注意点ではあります。

2021/05/04

Hello Switcherのサービス化

ノートやタブレットPCでWindows Hello対応の内蔵カメラがある場合、Windows Hello対応のWebカメラを接続しても、Windows Helloに使われるカメラを切り換えられない問題は、Microsoftでも認識されているようです。 最近ようやくというか、周辺機器メーカーから普及帯のWindows Hello対応Webカメラが出ましたし(エレコムのUCAM-CF20FBBK)、既にDellやLenovoからWindows Hello対応カメラ付きのモニターも出ています。中でもMicrosoft Teams専用機能を謳ったC2422HEについて、Dellのサポートに問い合わせてみたところ、やはりPC本体にWindows Hello対応カメラがある場合はモニターのカメラは使えないとの回答でした。この問題にぶち当たるケースが増えるにつれ、Microsoftでもいつまでも放置はされないかもと想像しています。
ともあれ、この問題を解決するためのHello Switcherですが、これまでの実績上、切換え機能自体には問題なさそうです。 一方で、ログイン後という起動タイミングに起因する問題は如何ともしがたく、未解決の問題として残っていました。すなわち、電源オフの間やサスペンド中にUSBカメラの着脱があった場合、その後のログイン前にカメラの切換えは当然できません。

試しにタスクスケジューラでの開始タイミングをスタートアップ時に変えてみましたが(この状態だとUIを出せないので、実用的ではない)、ログイン前にカメラの切換えが間に合う場合もあれば合わない場合もあり、解決策にはならず。

よって、Windowsサービスの実行ファイルも作成し、通常のアプリと併用する(アプリの実行中はサービスはPausedにしておく)方針に変えました。
この結果、切換えが間に合わなくなる場面はほぼなくなりました。ということで、ようやくアプリとして所期の要求を満たすようになりました、パチパチ。

実際、サービスとして動かしている限り、表に出ることもなく自動的に切り換えるので、OSの標準機能と変わりません。これで、この問題は実質的には解決かなと。

2021/05/03

Windowsサービスでデバイスイベントを捕捉する

実行ファイルをWindowsサービスとして動かすために.NETではSystem.ServiceProcess名前空間にServiceContollerServiceBaseクラスが用意されていて、これらに則れば比較的容易にサービスを実装できますが、このServiceBaseの派生クラスからデバイスイベント(USBデバイスの着脱)を捕捉するにはどうすればいいか、鉄板的なものが見当たらなかったので、メモしておきます。

1. 基本

Win32的には、Windowsサービスは大体以下のような仕組みになっているようです。
  1. まずサービス名を指定してCreateService関数でサービスを生成する。
  2. このサービス名とコールバック関数を指定してRegisterServiceCtrlHandler関数を実行すると、サービス自体の管理用(開始、停止等)のコントロールコードがこのコールバック関数に流れてくるようになる。また、この関数の実行時にサービスステータスハンドルが返ってくるので、これをサービスステータスの管理等に利用する。
  3. または、このサービス名とコールバック関数を指定してRegisterServiceCtrlHandlerEx関数を実行すると、2のコントロールコードに加えて、デバイスイベントを含めたシステムイベントのコントロールコードも流れてくるようになる(デバイスイベントの場合は、さらにサービスステータスハンドルを指定してイベントへの登録が必要。この部分はWindowを持つアプリでWindowメッセージを処理するのと基本同じ)。具体的なイベントについては、このコールバック関数(LPHANDLER_FUNCTION_EX)を参照。
ServiceBaseはこの2と、3のシステムイベントについては電源イベント(SERVICE_CONTROL_POWEREVENT)とセッションイベント(SERVICE_CONTROL_SESSIONCHANGE)まで実装していますが、デバイスイベント(SERVICE_CONTROL_DEVICEEVENT)は実装していません。というか、ソースを見ると、実装しかけたまま放置されたようで、これも実装してあれば手間が省けたのですが。

2. 実装

デバイスイベントの捕捉については、ざっと探して以下の先例がありました。
このコードをダウンロードして見ると、ServiceBaseの派生クラス上で上記の3を行い、イベントを独自のコールバック関数で捕捉するようにしています。つまり、ServiceBase内と二重にRegisterServiceCtrlHandlerEx関数を実行しているわけですが、こうするとServiceBase内のコールバック関数へのポインターが上書きされるようで、ServiceBase内のサービスステータス管理用の処理が実行されなくなります。このため、独自のコールバック関数の方でSERVICE_CONTROL_STOPを捕捉してServiceBase.Stopメソッドを呼んでいます。

とりあえず確認したところ、デバイスイベントの捕捉は問題なくできましたが、致命的な問題が。サスペンド時(Windows 10では、標準では「シャットダウン」がサスペンドになる)にAccessViolationExceptionが発生します。これは少し確認したところ、RegisterServiceCtrlHandlerEx関数を実行しただけで起こり、たぶんアンマネージドなハンドル絡みだと思うので、ほぼ対処不能。

ではどうするかというと、既にServiceBase内にコールバックの仕組みはあるので、これを利用できないかとソースを改めて見ると、コールバック関数に来たコントロールコードは途中で捕捉されなければ最終的にServiceBase.OnCustomCommandメソッドに流れてくるので、これを捕捉すればよいと発見(LPHANDLER_FUNCTION_EXにおけるdwControl = ServiceBase.OnCustomCommandにおけるcommand)。カスタムコマンドは128から255までという決まりがあり、SERVICE_CONTROL_DEVICEEVENTは11なので、この範囲に入りませんが、わざわざフィルターしているわけでもないので。

ということで、これなら簡単に実装できます。デバイスイベントへの登録はWindowメッセージの場合とDEVICE_NOTIFY_SERVICE_HANDLE以外は同じ。
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.ServiceProcess;
public partial class DeviceDetectService : ServiceBase
{
public DeviceDetectService()
{
InitializeComponent();
}
private static readonly Guid GUID_DEVINTERFACE_USB_DEVICE = new Guid("A5DCBF10-6530-11D2-901F-00C04FB951ED");
private IntPtr _notificationHandle;
protected override void OnStart(string[] args)
{
DeviceNotification.Unregister(_notificationHandle);
_notificationHandle = DeviceNotification.Register(this.ServiceHandle, GUID_DEVINTERFACE_USB_DEVICE);
}
protected override void OnStop()
{
DeviceNotification.Unregister(_notificationHandle);
_notificationHandle = IntPtr.Zero;
}
protected override void OnCustomCommand(int command)
{
switch (command)
{
case DeviceNotification.SERVICE_CONTROL_DEVICEEVENT:
Debug.WriteLine("USB device event received!");
this.EventLog.WriteEntry("USB device event received!");
break;
}
}
}
public static class DeviceNotification
{
[DllImport("User32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr RegisterDeviceNotification(
IntPtr hRecipient,
IntPtr NotificationFilter,
uint Flags);
[DllImport("User32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UnregisterDeviceNotification(IntPtr Handle);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct DEV_BROADCAST_DEVICEINTERFACE
{
public uint dbcc_size;
public uint dbcc_devicetype;
public uint dbcc_reserved;
public Guid dbcc_classguid;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 255)]
public string dbcc_name;
}
private const int DEVICE_NOTIFY_SERVICE_HANDLE = 0x00000001;
private const int DBT_DEVTYP_DEVICEINTERFACE = 0x00000005;
public const int SERVICE_CONTROL_DEVICEEVENT = 0x0000000B;
/// <summary>
/// Registers to device events.
/// </summary>
/// <param name="serviceHandle">Service status handle</param>
/// <param name="classGuid">Device interface class Guid</param>
/// <returns>Device notification handle</returns>
public static IntPtr Register(IntPtr serviceHandle, Guid classGuid)
{
var dbcc = new DEV_BROADCAST_DEVICEINTERFACE
{
dbcc_size = (uint)Marshal.SizeOf<DEV_BROADCAST_DEVICEINTERFACE>(),
dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE,
dbcc_classguid = classGuid
};
var buffer = IntPtr.Zero;
try
{
buffer = Marshal.AllocHGlobal((int)dbcc.dbcc_size);
Marshal.StructureToPtr(dbcc, buffer, true);
return RegisterDeviceNotification(
serviceHandle,
buffer,
DEVICE_NOTIFY_SERVICE_HANDLE);
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to register.\r\n{ex}");
return IntPtr.Zero;
}
finally
{
if (buffer != IntPtr.Zero)
Marshal.FreeHGlobal(buffer);
}
}
/// <summary>
/// Unregisters from device events.
/// </summary>
/// <param name="notificationHandle">Device notification handle</param>
public static void Unregister(IntPtr notificationHandle)
{
if (notificationHandle != IntPtr.Zero)
UnregisterDeviceNotification(notificationHandle);
}
}
なお、OnStartでRegisterの前にUnregisterしているのは、OnStartの後に必ずOnStopが来るとは限らないため。

惜しむらくは、一緒に来るdwEventType(WM_DEVICECHANGEにおけるwParamと同じ)とlpEventData(同じくlParamと同じ)は流れてこないので、デバイスイベントが起きたことしか分からないことですが、イベントがあったことが分かればやりようはあるので。

このサンプルのレポジトリは以下。 以上で用には足りますが、ServiceBaseクラスは.NETでサービスを使うには必須の基底クラスなので、もう少し拡張にオープンにしてほしかったというのが率直な印象でした。

2021/04/10

ストライプ付きプログレスバー

プログレスバーの最近の流行りは、コンテンツを邪魔しないように細くして、存在感を主張しないものだと思いますが、控えめにアクセントを付ける方法として、ストライプを付けるものがあります。それで、進行中はストライプをアニメーションさせると。

WPFというかXAMLのアニメーションはその辺柔軟なので、それほど難しくはないです。
<SolidColorBrush x:Key="ProgressBar.Background" Color="#FFE6E6E6"/>
<SolidColorBrush x:Key="ProgressBar.Progress" Color="#FF1C61F3"/>
<SolidColorBrush x:Key="ProgressBar.Stripe" Color="#33FFFFFF"/>
<Style x:Key="StripedProgressBarStyle" TargetType="{x:Type ProgressBar}">
<Setter Property="Background" Value="{StaticResource ProgressBar.Background}"/>
<Setter Property="Foreground" Value="{StaticResource ProgressBar.Progress}"/>
<Setter Property="Height" Value="20"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ProgressBar}">
<Grid x:Name="TemplateRoot">
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Determinate">
<Storyboard RepeatBehavior="Forever">
<RectAnimation Storyboard.TargetName="Stripe"
Storyboard.TargetProperty="(Shape.Fill).(TileBrush.Viewport)"
Duration="0:0:1"
From="0,0,20,20" To="-20,0,20,20"/>
</Storyboard>
</VisualState>
<VisualState x:Name="Indeterminate"/>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<Border Background="{TemplateBinding Background}"/>
<Rectangle x:Name="PART_Track"/>
<Grid x:Name="PART_Indicator"
ClipToBounds="true"
HorizontalAlignment="Left">
<Rectangle x:Name="Indicator" Fill="{TemplateBinding Foreground}"/>
<Rectangle x:Name="Stripe">
<Rectangle.Fill>
<DrawingBrush TileMode="Tile" Stretch="Uniform"
Viewport="0,0,20,20" ViewportUnits="Absolute">
<DrawingBrush.Drawing>
<GeometryDrawing Brush="{StaticResource ProgressBar.Stripe}">
<GeometryDrawing.Geometry>
<PathGeometry>
<PathGeometry.Figures>
<PathFigureCollection>
<PathFigure StartPoint="0,0">
<LineSegment Point="5,0"/>
<LineSegment Point="10,5"/>
<LineSegment Point="10,10"/>
</PathFigure>
<PathFigure StartPoint="0,5">
<LineSegment Point="5,10"/>
<LineSegment Point="0,10"/>
</PathFigure>
</PathFigureCollection>
</PathGeometry.Figures>
</PathGeometry>
</GeometryDrawing.Geometry>
</GeometryDrawing>
</DrawingBrush.Drawing>
</DrawingBrush>
</Rectangle.Fill>
</Rectangle>
</Grid>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="Value" Value="100">
<Setter TargetName="Stripe" Property="Visibility" Value="Collapsed"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

ポイントとしては、連続して描画するタイプの要素をアニメーションさせるときは、その開始位置の値を変えていくようにすれば、比較的簡単にできます。上の例ではDrawingBrushをTileMode="Tile"を指定して敷き詰め、位置をViewportで決めていますが、このXの値を変えてアニメーションさせています。

ここでもう一つポイントとして、アニメーションにはプリミティブな値を変えるDoubleAnimationやColorAnimationをよく使いますが、Viewportの型はRectなので、この中のXはDoubleAnimationでは対象に指定できません。こういう場合は、複合的な値であるRectを変えるRectAnimationが用意されているので、これを使えばいいわけです。

同じようにThickness(Marginの型)を変えるThicknessAnimation、Pointを変えるPointAnimation、Sizeを変えるSizeAnimationもあるので、幾何学的に変化させるアニメーションは大体カバーできます。これら以外にも、AnimationTimelineの派生クラスを見ると、予め用意されているAnimationをチェックできます。

2021/02/11

マルチタッチによるクリックの判別

WPF上のタッチ操作でシングルタッチによるものか、マルチタッチによる(指を複数使う)ものかは、Manipulation系のイベントであれば直接判別できるようになっていますが、移動を伴わないTouch系のイベントのときは直接分かるものがないので、どうするか考えてみた話です。

そんなに難しいことでもなく、Touch系のイベントで来るTouchEventArgsのTouchDeviceがイベントを起こした個々のデバイス(指)を示すので、このIdを記録して、これが一連のイベントが終わるまでに一つしか来ていなければシングルタッチ、複数来ていればマルチタッチと判別できます。

以下のButtonの例では、PreviewTouchDownイベントごとにTouchDeviceのIdをHashSetに記録していき、Clickイベントのときに複数来ているか判別した後で、HashSetを初期化しています。なお、タッチ操作の終わりに常にClickイベントが来るわけではないので、PreviewTouchUpイベントを引っ掛けた上で、これはClickイベントより先に来るので、1秒の猶予をおいてから初期化するようにしています。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows.Input;
public class MultiTouchButton : Button
{
public bool IsMultiTouch => (_touchDeviceIds.Count > 1);
private readonly HashSet<int> _touchDeviceIds = new HashSet<int>();
protected override void OnPreviewTouchDown(TouchEventArgs e)
{
base.OnPreviewTouchDown(e);
_touchDeviceIds.Add(e.TouchDevice.Id);
}
protected override void OnPreviewTouchUp(TouchEventArgs e)
{
base.OnPreviewTouchUp(e);
Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(1));
_touchDeviceIds.Clear();
});
}
protected override void OnClick()
{
base.OnClick();
Trace.WriteLine($"{(IsMultiTouch ? "Multi" : "Single")}");
_touchDeviceIds.Clear();
}
}
なお、一つのTouchDeviceがPreviewTouchDownで何回も来ることはないと割り切れば、単なるカウンターでも十分な気はします。

より実用的に、ClickイベントからBehaviorのCallMethodActionを実行するようにしている場合、そのIsEnabled依存関係プロパティとbindingを張ってもいいですが、そのために妙にコードが増えるのもうまくないので、CallMethodActionの分も含めてBehaviorにまとめたのが以下。
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using Microsoft.Xaml.Behaviors;
public class MultiTouchBehavior : Behavior<ButtonBase>
{
public object TargetObject
{
get { return (object)GetValue(TargetObjectProperty); }
set { SetValue(TargetObjectProperty, value); }
}
public static readonly DependencyProperty TargetObjectProperty =
DependencyProperty.Register(
"TargetObject",
typeof(object),
typeof(MultiTouchBehavior),
new PropertyMetadata(
null,
(d, e) => ((MultiTouchBehavior)d).SetMethods()));
public string SingleTouchClickMethodName { get; set; }
public string MultiTouchClickMethodName { get; set; }
private MethodInfo _singleTouchClickMethod;
private MethodInfo _multiTouchClickMethod;
private void SetMethods()
{
if (TargetObject is null)
return;
var targetType = TargetObject.GetType();
if (!string.IsNullOrEmpty(SingleTouchClickMethodName))
_singleTouchClickMethod = targetType.GetMethod(SingleTouchClickMethodName, Type.EmptyTypes);
if (!string.IsNullOrEmpty(MultiTouchClickMethodName))
_multiTouchClickMethod = targetType.GetMethod(MultiTouchClickMethodName, Type.EmptyTypes);
}
protected override void OnAttached()
{
base.OnAttached();
this.AssociatedObject.PreviewTouchDown += OnPreviewTouchDown;
this.AssociatedObject.PreviewTouchUp += OnPreviewTouchUp;
this.AssociatedObject.Click += OnClick;
}
protected override void OnDetaching()
{
base.OnDetaching();
this.AssociatedObject.PreviewTouchDown -= OnPreviewTouchDown;
this.AssociatedObject.PreviewTouchUp -= OnPreviewTouchUp;
this.AssociatedObject.Click -= OnClick;
}
private readonly HashSet<int> _touchDeviceIds = new HashSet<int>();
private void OnPreviewTouchDown(object sender, TouchEventArgs e)
{
_touchDeviceIds.Add(e.TouchDevice.Id);
}
private void OnPreviewTouchUp(object sender, TouchEventArgs e)
{
Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(1));
_touchDeviceIds.Clear();
});
}
private void OnClick(object sender, RoutedEventArgs e)
{
var isMultiTouch = (_touchDeviceIds.Count > 1);
_touchDeviceIds.Clear();
if (!isMultiTouch)
{
_singleTouchClickMethod?.Invoke(TargetObject, null);
}
else
{
_multiTouchClickMethod?.Invoke(TargetObject, null);
}
}
}
サンプル全体は以下。 以上でシングルタッチかマルチタッチかに応じて実行するメソッドを切り替えられるようになりましたが、実際に試してみると、2つの指を合わせて、それぞれ認識されるまでタッチしてから離す(完全に同時にタッチする必要はない)操作は慣れが必要で、タッチデバイスにもよるでしょうが、この操作の実用性自体が少し微妙なことに気づきました。

2021/01/31

階層的なロケール

.NETアプリではリソースファイル(.resx)をロケールごとに用意しておけば、実行時にユーザーの表示言語に合ったリソースが自動的に選択されます。ロケールは基本的に[言語名]-[地域名]の構成ですが、中国語だけはこれが簡体字と繁体字のために階層的になっているので、確認してみました。

各ロケールのCultureInfoのParentプロパティを辿るとInvariantCultureに辿り着きますが、その一つ前までをまとめてMarkdownの表を生成するコードが以下のとおり。
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
static string CreateLocaleTable(string cultureName)
{
var paths = CultureInfo.GetCultures(CultureTypes.AllCultures)
.Where(x => x.Name.StartsWith(cultureName))
.Select(x => (culture: x, path: GetParent(x)))
.ToArray();
var length = paths.Max(x => x.path.Length);
var lines = new StringBuilder()
.AppendLine(string.Join("|", Enumerable.Repeat("<--", length - 1).Prepend(null).Prepend(null).Append(null).Append(null)))
.AppendLine(string.Join("|", Enumerable.Repeat("---", length + 1).Prepend(null).Append(null)));
var buffer = paths.Select(x => (x.culture, path: x.path.Concat(new CultureInfo[length - 1]).Take(length).ToArray()))
.OrderBy(x => x.path[0].Name);
for (int i = 1; i < length; i++)
{
int index = i;
buffer = buffer.ThenBy(x => x.path[index]?.Name);
}
foreach (var (culture, path) in buffer)
{
lines.AppendLine(string.Join("|", path.Select(x => x?.Name).Append(culture.DisplayName).Prepend(null).Append(null)));
}
return lines.ToString();
static CultureInfo[] GetParent(CultureInfo c, List<CultureInfo> list = null)
{
list ??= new List<CultureInfo>();
list.Insert(0, c);
return (c.Parent == CultureInfo.InvariantCulture)
? list.ToArray()
: GetParent(c.Parent, list);
}
}
view raw Program.cs hosted with ❤ by GitHub
これを.NET Framework 4.8の上で実行した結果。
<-- <-- <--
zh 中国語
zh zh-Hans 簡体字中国語
zh zh-Hans zh-CHS 簡体字中国語レガシ
zh zh-Hans zh-CHS zh-CN 中国語 (簡体字、中国)
zh zh-Hans zh-CHS zh-Hans-HK 中国語 (簡体字、香港特別行政区)
zh zh-Hans zh-CHS zh-Hans-MO 中国語 (簡体字、マカオ SAR)
zh zh-Hans zh-CHS zh-SG 中国語 (簡体字、シンガポール)
zh zh-Hant 繁体字中国語
zh zh-Hant zh-CHT 繁体字中国語レガシ
zh zh-Hant zh-CHT zh-HK 中国語 (繁体字、香港)
zh zh-Hant zh-CHT zh-MO 中国語 (繁体字、マカオ)
zh zh-Hant zh-CHT zh-TW 中国語 (繁体字、台湾)
view raw zh_locale.md hosted with ❤ by GitHub
自動選択では表示言語と同じリソースがあればそれが、なければ上に辿って存在するリソースが選択されるはずなので、簡体字の場合、表示言語がzh-CN(中国本土)のときはzh-CNのリソースがあればそれが、なければ(zh-CHSは古いものなのでスルーするとして)zh-Hansのリソースがあればそれが選択されることになります。ここで、簡体字なのは中国本土だけだろうと思っていたら、実はシンガポールも簡体字を使っていて、リソースをzh-CNで作成すると表示言語がzh-SGのときには選択されません。

同じように、繁体字の場合、表示言語がzh-TW(台湾)のときはzh-TWのリソースがあればそれが、なければ(zh-CHTはスルーするとして)zh-Hantが存在すればそれが選択されますが、リソースをzh-TWで作成すると香港かマカオで表示言語に繁体字を使っているときには選択されないことになります。

したがって、簡体字のリソースはzh-Hansで、簡体字のリソースはzh-Hantで作成するのが正解で、もし分ける必要が出てきた場合に、その下のロケールで該当部分を上書きすればいいわけです。

念のため、.NET 5.0の上で実行した結果。
<-- <--
zh 中国語
zh zh-Hans 中国語 (簡体字)
zh zh-Hans zh-CN 中国語 (中国)
zh zh-Hans zh-Hans-HK 中文(简体,香港特别行政区)
zh zh-Hans zh-Hans-MO 中文(简体,澳门特别行政区)
zh zh-Hans zh-SG 中国語 (シンガポール)
zh zh-Hant 中国語 (繁体字)
zh zh-Hant zh-HK 中国語 (中華人民共和国香港特別行政区)
zh zh-Hant zh-MO 中国語 (中華人民共和国マカオ特別行政区)
zh zh-Hant zh-TW 中国語 (台湾)
view raw zh_locale.md hosted with ❤ by GitHub
これも古いzh-CHSとzh-CHTが消えた以外は同じですが、説明に簡体字か繁体字か明記していないのが不親切。

以上、中国語のロケールはそれぞれzh-Hansかzh-Hantにしておけばよい、という確認まで。

2021/01/28

Dellモニタースタンドの限界突破(下方向)

Dell U2415をサブモニターとして使っていますが、設置場所の関係でやや高い位置に置かざるを得ず、モニタースタンドの下限一杯にしてもモニターの位置が視線の高さに対して高くなるという問題がありました。

こういう場合の解決策としてモニターアームの使用が考えられますが、あれはあれで設置に手がかかり、かつ位置調整も全く融通無碍にできるわけではありません。そこでモニタースタンドの改造で何とかできないかと検討。

スタンドのモニターとの接続部のプレートを一旦取り外し、カバーを外して取り付け直したところ。

この左右の端の中央の高さにある横長の穴は、カバーのダボがはまっていた部分で、組み立て時の位置決め用と推測しますが、もしかしてこれにVESAマウントのネジを通せないかと思い付き、穴の間の距離を測ったところ、ネジの中心になる位置の間で101mm。VESAマウントは100mmなので、微妙に合いません。

穴をそれぞれ内側に削る手もなくはないですが、このスチールのプレートを手作業で加工するのは率直に言って苦行。どうしたものかと手持ちの金具を漁っていたら、ピカーンと。

モニターのVESAマウントのネジ穴に黒いクランク型の金具を少し傾けて取り付けることで、ネジ位置をスタンドのプレートの穴に合わせることができると。いずれにせよスペーサーが必要だったので、一石二鳥。何かの用に使おうと買ったまま長らく死蔵していたものですが、まさか役に立つ日が来ようとは、「こんなこともあろうかと」が本当にあるとは、自分でも少々びっくり。

とりあえずスタンドに取り付けてみたところ。横2点だけの固定なので縦の揺れはあるものの、厚みのあるスチールの金具なので、それ自体の強度には問題なさそう。

まあ大丈夫そうなので、根本のカバーを戻し、プレートの下型の爪の部分にゴムを挟んで揺れ止めにして、一応の完成。

この結果、モニターの位置を大きく4cm弱下げることに成功(スタンドの台座との隙間は8mm)。このスタンドのプレートの穴はU2720QMのスタンドにもあったので、Dellのモニターで割と汎用的に使えるテクではないかと思います。あくまで緊急避難的なものですが。