From 36eb234e55e0a48c31714810f313d58501a6884f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 16:18:10 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(settings):=20dynamic=20=E3=81=AE?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E6=99=82=E9=96=93=E3=81=AB=205=20=E6=AE=B5?= =?UTF-8?q?=E9=9A=8E=E3=81=AE=E7=B0=A1=E5=8D=98=E8=A8=AD=E5=AE=9A=E3=82=92?= =?UTF-8?q?=E7=94=A8=E6=84=8F=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dynamic の表示時間は 4 つの係数で決まる。 表示時間を少し変えたいだけの利用者に、読字速度とクランプ範囲を個別に決めさせるのは重い。 既定の通知設定タブへ簡単設定の選択肢を足し、最短から最長までの 5 段階を選べるようにする。 段階を選ぶと 4 つの係数がまとめて入り、係数の入力欄は編集できなくなる。 カスタムに戻すと入力欄が有効になり、今までどおり係数ごとに編集できる。 最短はテスト通知が fixed の既定と同じ 1 秒になるところを起点にしてある。 「テスト通知を送る」で今の見え方と比べながら、そこから段階を上げて決められる。 テスト通知の文字数では min まで持ち上がるため、この 1 秒は ja と en のどちらでも変わらない。 辞書の文言を伸ばして起点がずれたときに気付けるよう、両ロケールを spec で確かめる。 設定ファイルへ書くのは段階の名前ではなく係数の実値であり、スキーマは変えない。 名前を持たせると、rules の係数ごとの上書きを解決する経路が名前と実値の 2 通りになる。 画面は開いたときに係数から段階を引き直し、どれとも一致しないものをカスタムとして扱う。 アプリ別ルールタブの上書き行は対象外とする。 あちらは係数単位の部分上書きであり、4 つ組であるプリセットとの対応が取れない。 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Bzwo9brhbZyAyjVuzEj1mb --- locales/en.json | 7 +++++ locales/ja.json | 7 +++++ spec/config/models_spec.cr | 29 ++++++++++++++++++ spec/config/usecase_spec.cr | 13 +++++++++ spec/win_notification/usecase_spec.cr | 33 +++++++++++++++++++++ src/config/models.cr | 41 ++++++++++++++++++++++++++ src/runtime/settings_window.cr | 42 +++++++++++++++++++++++++-- 7 files changed, 169 insertions(+), 3 deletions(-) diff --git a/locales/en.json b/locales/en.json index c11999e..6af3af5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -62,6 +62,7 @@ "settings.sinks.udp_port": "UDP port (legacy)", "settings.defaults.timeout_mode": "Timeout mode", "settings.defaults.timeout": "Fixed timeout (s)", + "settings.defaults.dynamic_preset": "dynamic: preset", "settings.defaults.dynamic_base": "dynamic: base (s)", "settings.defaults.dynamic_reading_speed": "dynamic: reading speed (chars/s)", "settings.defaults.dynamic_min": "dynamic: min (s)", @@ -116,6 +117,12 @@ "settings.choice.update_channel.test": "test: include prereleases", "settings.choice.timeout_mode.dynamic": "dynamic: fit the body length", "settings.choice.timeout_mode.fixed": "fixed: constant duration", + "settings.choice.dynamic_preset.shortest": "Shortest (1 to 3 s)", + "settings.choice.dynamic_preset.short": "Shorter (2 to 8 s)", + "settings.choice.dynamic_preset.standard": "Standard (3 to 15 s)", + "settings.choice.dynamic_preset.long": "Longer (5 to 20 s)", + "settings.choice.dynamic_preset.longest": "Longest (8 to 30 s)", + "settings.choice.dynamic_preset.custom": "Custom (set each coefficient)", "settings.choice.filter_mode.blacklist": "blacklist: exclude the listed apps", "settings.choice.filter_mode.whitelist": "whitelist: allow only the listed apps", "settings.choice.transport.websocket": "WebSocket", diff --git a/locales/ja.json b/locales/ja.json index 68d370c..8668eaf 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -62,6 +62,7 @@ "settings.sinks.udp_port": "UDP ポート (レガシー)", "settings.defaults.timeout_mode": "表示時間の決め方", "settings.defaults.timeout": "固定の表示時間 (秒)", + "settings.defaults.dynamic_preset": "dynamic: 簡単設定", "settings.defaults.dynamic_base": "dynamic: 基準 (秒)", "settings.defaults.dynamic_reading_speed": "dynamic: 読字速度 (文字/秒)", "settings.defaults.dynamic_min": "dynamic: 下限 (秒)", @@ -116,6 +117,12 @@ "settings.choice.update_channel.test": "test: プレリリースも受け取る", "settings.choice.timeout_mode.dynamic": "dynamic: 本文の長さから決める", "settings.choice.timeout_mode.fixed": "fixed: 固定の秒数で出す", + "settings.choice.dynamic_preset.shortest": "最短 (1 から 3 秒)", + "settings.choice.dynamic_preset.short": "短め (2 から 8 秒)", + "settings.choice.dynamic_preset.standard": "標準 (3 から 15 秒)", + "settings.choice.dynamic_preset.long": "長め (5 から 20 秒)", + "settings.choice.dynamic_preset.longest": "最長 (8 から 30 秒)", + "settings.choice.dynamic_preset.custom": "カスタム (係数を自分で決める)", "settings.choice.filter_mode.blacklist": "blacklist: 書いたアプリを除外する", "settings.choice.filter_mode.whitelist": "whitelist: 書いたアプリだけ通す", "settings.choice.transport.websocket": "WebSocket", diff --git a/spec/config/models_spec.cr b/spec/config/models_spec.cr index fcd5ba6..df2cd13 100644 --- a/spec/config/models_spec.cr +++ b/spec/config/models_spec.cr @@ -154,6 +154,35 @@ describe Config::DynamicTimeout do dynamic.seconds_for(120).should eq 12.0 dynamic.seconds_for(1000).should eq 15.0 end + + describe "簡単設定のプリセット(issue #45)" do + # 係数を触っていない利用者に、選んだ覚えのないプリセットを表示しないための一致である。 + it "標準は Defaults の既定値と同じである" do + Config::DynamicTimeout::PRESETS["standard"].should eq Config::Defaults.new.dynamic_timeout + end + + # 段階の順に長くならないと、長いほうへ選び直したのに短くなる文字数ができる。 + # 4 つの係数が絡むため、係数を 1 つ変えたときに順序が崩れても目では気付けない。 + it "どの文字数でも段階の順に表示時間が長くなる" do + presets = Config::DynamicTimeout::PRESET_NAMES.map { |name| Config::DynamicTimeout::PRESETS[name] } + + [0, 20, 35, 78, 100, 200, 500, 5000].each do |count| + seconds = presets.map(&.seconds_for(count)) + seconds.should eq(seconds.sort), "#{count} 文字で段階の順に並んでいない: #{seconds}" + end + end + + it "係数の組からプリセットの名前を引ける" do + Config::DynamicTimeout::PRESETS.each do |name, preset| + preset.preset_name.should eq name + end + end + + # 手で書いた係数はどの段階とも一致しない。画面はこの nil をカスタムの表示に使う。 + it "どのプリセットとも一致しない係数では名前を返さない" do + Config::DynamicTimeout.new(base: 2.0, reading_speed: 12.0, min: 3.0, max: 14.0).preset_name.should be_nil + end + end end describe Config::Defaults do diff --git a/spec/config/usecase_spec.cr b/spec/config/usecase_spec.cr index 2c983da..5aaa052 100644 --- a/spec/config/usecase_spec.cr +++ b/spec/config/usecase_spec.cr @@ -205,6 +205,19 @@ describe Config::Usecase do target.validate(root).should be_empty end + # 画面が入れた係数が保存の検証で弾かれると、段階を選んだ時点で保存できなくなる(issue #45)。 + it "簡単設定のどのプリセットも検証を通る" do + target, _ = usecase + + Config::DynamicTimeout::PRESETS.each do |name, preset| + root = Config::Root.default + root.defaults.timeout_mode = Config::TimeoutMode::Dynamic + root.defaults.dynamic_timeout = preset + + target.validate(root).should be_empty, "プリセット #{name} が検証を通らない" + end + end + it "match_app_id が空のルールを弾く" do target, _ = usecase root = Config::Root.default diff --git a/spec/win_notification/usecase_spec.cr b/spec/win_notification/usecase_spec.cr index 6b04f91..18cd98a 100644 --- a/spec/win_notification/usecase_spec.cr +++ b/spec/win_notification/usecase_spec.cr @@ -1,4 +1,5 @@ require "../spec_helper" +require "../../src/runtime/i18n" private def incoming(title = "件名", body = "本文", app_name = "Discord", icon = nil) Notify::Incoming.new( @@ -28,6 +29,38 @@ private def settings(**overrides) end describe WinNotification::MessageBuilder do + # 簡単設定の最短は、今の見え方と比べる起点として fixed の既定に合わせてある(issue #45)。 + # + # テスト通知の文字数では base + 文字数 / reading_speed が 1.0 を下回り、min でそこまで持ち上がる。 + # 辞書の文言を伸ばすと文字数が増え、min から浮いて 1 秒でなくなる。 + # 起点が黙ってずれないよう、辞書側を変えたときにここが落ちるようにしてある。 + it "最短のプリセットはテスト通知を fixed の既定と同じ 1 秒で出す(issue #45)" do + builder = WinNotification::MessageBuilder.new(Fakes::Icons.new) + resolved = settings( + timeout_mode: Config::TimeoutMode::Dynamic, + dynamic_timeout: Config::DynamicTimeout::PRESETS["shortest"], + ) + + begin + Runtime::I18n::LOCALES.each_key do |locale| + Runtime::I18n.locale = locale + # 件名と本文と app_name は RelayUsecase#send_test が渡すものに揃える。 + message = builder.build( + incoming( + title: Runtime::I18n.t("notify.test.title"), + body: Runtime::I18n.t("notify.test.body"), + app_name: "KxNotifyUtils", + ), + resolved, + ) + + message.timeout.should eq(Config::Defaults.new.timeout), "ロケール #{locale} で 1 秒にならない" + end + ensure + Runtime::I18n.locale = Runtime::I18n::DEFAULT_LOCALE + end + end + it "title テンプレートを展開する" do builder = WinNotification::MessageBuilder.new(Fakes::Icons.new) diff --git a/src/config/models.cr b/src/config/models.cr index 2ad9655..d8901ef 100644 --- a/src/config/models.cr +++ b/src/config/models.cr @@ -32,6 +32,47 @@ module Config speed = @reading_speed <= 0.0 ? 1.0 : @reading_speed (@base + char_count / speed).clamp(@min, @max) end + + # 簡単設定で選べる係数の組(issue #45)。 + # + # 4 つの係数を個別に決めるのは、表示時間を少し変えたいだけの利用者には重い。 + # 短いほうから長いほうへ 5 段階を並べ、どの文字数でも段階の順に表示時間が長くなるようにしてある。 + # + # shortest は、テスト通知が fixed の既定と同じ 1 秒になるところを起点とする。 + # テスト通知の文字数では base + 文字数 / reading_speed が 1.0 を下回り、min でそこまで持ち上がる。 + # ここでの reading_speed は読字速度ではなく、1 秒延ばすのに要る文字数である。 + # 視界を塞がないことを優先する段階なので、実際に読み切れる速度より大きく取ってある。 + # + # standard は Defaults の既定値と同じにする。 + # 係数を触っていない利用者に、選んだ覚えのないプリセットを表示しないためである。 + # + # 係数は辞書の選択肢の見出し(settings.choice.dynamic_preset.*)と + # docs/configuration.md にも書いてある。ここを変えるときは両方を合わせる。 + PRESETS = { + "shortest" => DynamicTimeout.new(base: 0.0, reading_speed: 100.0, min: 1.0, max: 3.0), + "short" => DynamicTimeout.new(base: 1.0, reading_speed: 40.0, min: 2.0, max: 8.0), + "standard" => DynamicTimeout.new(base: 2.0, reading_speed: 12.0, min: 3.0, max: 15.0), + "long" => DynamicTimeout.new(base: 3.0, reading_speed: 9.0, min: 5.0, max: 20.0), + "longest" => DynamicTimeout.new(base: 4.0, reading_speed: 6.0, min: 8.0, max: 30.0), + } + + # 画面へ並べる順序。Hash はキーを入れた順に返す。 + PRESET_NAMES = PRESETS.keys + + def self.preset(name : String) : DynamicTimeout? + PRESETS[name]? + end + + # 今の係数と一致するプリセットの名前。どれとも一致しなければ nil を返す。 + # + # 設定ファイルにはプリセット名ではなく係数の実値を書く。 + # 名前を持たせると、rules の係数ごとの上書きを解決する経路が名前と実値の 2 通りになり、 + # どちらが勝つかを決めなければならなくなる。 + # 画面は係数からここで名前を引き直し、一致しないものをカスタムとして扱う。 + def preset_name : String? + PRESETS.each { |name, preset| return name if preset == self } + nil + end end # 本文の最大文字数として受け付ける範囲。 diff --git a/src/runtime/settings_window.cr b/src/runtime/settings_window.cr index 4991399..8678415 100644 --- a/src/runtime/settings_window.cr +++ b/src/runtime/settings_window.cr @@ -42,6 +42,11 @@ module Runtime # 残りを既定の通知設定から埋めて 1 つの値として持たせる。 DYNAMIC_TIMEOUT_FIELDS = %w[base reading_speed min max] + # dynamic の簡単設定で選べるもの(issue #45)。 + # 係数の組であるプリセットに続けて、係数を自分で決めるためのカスタムを末尾へ置く。 + DYNAMIC_PRESET_CUSTOM = "custom" + DYNAMIC_PRESETS = ::Config::DynamicTimeout::PRESET_NAMES + [DYNAMIC_PRESET_CUSTOM] + # 前後の空白に意味が無く、落として読むフィールド。 TRIMMED_RULE_FIELDS = %w[timeout_mode timeout max_body_length opacity volume] @@ -326,6 +331,11 @@ module Runtime false, ) form.append(I18n.t("settings.defaults.timeout"), entry("defaults.timeout"), false) + form.append( + I18n.t("settings.defaults.dynamic_preset"), + combo("defaults.dynamic_preset", choices("dynamic_preset", DYNAMIC_PRESETS), -> { apply_dynamic_preset }), + false, + ) form.append(I18n.t("settings.defaults.dynamic_base"), entry("defaults.dynamic_timeout.base"), false) form.append(I18n.t("settings.defaults.dynamic_reading_speed"), entry("defaults.dynamic_timeout.reading_speed"), false) form.append(I18n.t("settings.defaults.dynamic_min"), entry("defaults.dynamic_timeout.min"), false) @@ -566,6 +576,11 @@ module Runtime set_text("defaults.dynamic_timeout.reading_speed", defaults.dynamic_timeout.reading_speed.to_s) set_text("defaults.dynamic_timeout.min", defaults.dynamic_timeout.min.to_s) set_text("defaults.dynamic_timeout.max", defaults.dynamic_timeout.max.to_s) + set_choice( + "defaults.dynamic_preset", + DYNAMIC_PRESETS, + defaults.dynamic_timeout.preset_name || DYNAMIC_PRESET_CUSTOM, + ) set_spin("defaults.max_body_length", defaults.max_body_length) set_text("defaults.title_template", defaults.title_template) set_text("defaults.icon", defaults.icon) @@ -781,15 +796,36 @@ module Runtime end # timeout_mode に応じて、使わないほうの入力欄を触れなくする。 + # dynamic の係数は、簡単設定でカスタムを選んだときだけ編集できる(issue #45)。 private def update_timeout_inputs : Nil dynamic = selected_value("defaults.timeout_mode", TIMEOUT_MODES) == "dynamic" - %w[defaults.dynamic_timeout.base defaults.dynamic_timeout.reading_speed - defaults.dynamic_timeout.min defaults.dynamic_timeout.max].each do |key| - dynamic ? @controls[key].enable : @controls[key].disable + custom = selected_value("defaults.dynamic_preset", DYNAMIC_PRESETS) == DYNAMIC_PRESET_CUSTOM + DYNAMIC_TIMEOUT_FIELDS.each do |field| + control = @controls["defaults.dynamic_timeout.#{field}"] + dynamic && custom ? control.enable : control.disable end + dynamic ? @combos["defaults.dynamic_preset"].enable : @combos["defaults.dynamic_preset"].disable dynamic ? @controls["defaults.timeout"].disable : @controls["defaults.timeout"].enable end + # 簡単設定で選ばれたプリセットの係数を入力欄へ流し込む(issue #45)。 + # + # 設定ファイルへ書くのは係数の実値であり、プリセットの名前は残らない。 + # 画面が持つのは入力欄へ値を入れるところまでで、保存も検証も今までの経路をそのまま通る。 + # + # カスタムでは何も書き込まない。 + # 直前に選んでいたプリセットの係数がそのまま残り、直したいところだけ直せる。 + private def apply_dynamic_preset : Nil + preset = ::Config::DynamicTimeout.preset(selected_value("defaults.dynamic_preset", DYNAMIC_PRESETS)) + preset.try do |values| + set_text("defaults.dynamic_timeout.base", values.base.to_s) + set_text("defaults.dynamic_timeout.reading_speed", values.reading_speed.to_s) + set_text("defaults.dynamic_timeout.min", values.min.to_s) + set_text("defaults.dynamic_timeout.max", values.max.to_s) + end + update_timeout_inputs + end + # ルールの追加と削除と並べ替えは入力欄を通らないため、 # 変更ハンドラが動かない。編集した印はここで自分で付ける。 private def add_rule : Nil From 7831ceb85343538b8421520f1abc5ce36c2e0250 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 16:18:10 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20dynamic=20=E3=81=AE=E7=B0=A1?= =?UTF-8?q?=E5=8D=98=E8=A8=AD=E5=AE=9A=E3=82=92=E8=A8=AD=E5=AE=9A=E3=83=AA?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=83=AC=E3=83=B3=E3=82=B9=E3=81=B8=E6=9B=B8?= =?UTF-8?q?=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各段階が入れる係数と、最短が fixed の既定に合わせてあることを書く。 設定ファイルには段階の名前ではなく係数の実値が入るため、 手で書いた係数がどの段階とも一致しないときにカスタムとして表示されることも添える。 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Bzwo9brhbZyAyjVuzEj1mb --- docs/configuration.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index d356bd1..db63fcb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -125,6 +125,30 @@ XSOverlay へ通知を送る。 - **volume**:通知音の音量。0.0 から 1.0 で指定する。 - **sound**:`default`、`warning`、`error`、音声ファイルのパス、または空文字列(ミュート)。 +`defaults` の `dynamic_timeout` は、設定ウィンドウの「既定の通知設定」タブでは簡単設定から選ぶ(issue #45)。 +最短から最長までの 5 段階と、係数を自分で決めるカスタムがある。 +段階を選ぶと 4 つの係数がまとめて入り、係数の入力欄は編集できなくなる。 +カスタムに戻すと入力欄が有効になり、今までどおり係数ごとに編集できる。 + +最短は、テスト通知が `fixed` の既定と同じ 1 秒になるところを起点にしてある。 +「テスト通知を送る」で今の見え方と比べながら、そこから段階を上げて決められる。 + +各段階が入れる係数は次のとおりである。 + +- **最短**:`base` 0.0、`reading_speed` 100.0、`min` 1.0、`max` 3.0 +- **短め**:`base` 1.0、`reading_speed` 40.0、`min` 2.0、`max` 8.0 +- **標準**:`base` 2.0、`reading_speed` 12.0、`min` 3.0、`max` 15.0 +- **長め**:`base` 3.0、`reading_speed` 9.0、`min` 5.0、`max` 20.0 +- **最長**:`base` 4.0、`reading_speed` 6.0、`min` 8.0、`max` 30.0 + +最短の `reading_speed` は読字速度ではなく、1 秒延ばすのに要る文字数である。 +視界を塞がないことを優先する段階なので、実際に読み切れる速度より大きく取ってある。 + +設定ファイルに書かれるのは段階の名前ではなく、係数の実値である。 +名前を書くと、`rules` の係数ごとの上書きを解決する経路が名前と実値の 2 通りになるためである。 +手で書いた係数がどの段階とも一致しない場合は、開いたときにカスタムとして表示される。 +アプリ別ルールタブの上書き行は簡単設定の対象外であり、係数ごとの編集のままである。 + `defaults` の `opacity` と `volume` は、設定ウィンドウの「既定の通知設定」タブではスライダで編集する(issue #36)。 刻みは 0.01 であり、**手で `0.755` のような細かい値を書いてあっても、この画面から保存すると `0.76` へ丸まる。** 開いただけでは書き換わらないが、丸めの差は編集として数えられるため、そのまま閉じようとすると未保存の警告が出る。