Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` へ丸まる。**
開いただけでは書き換わらないが、丸めの差は編集として数えられるため、そのまま閉じようとすると未保存の警告が出る。
Expand Down
7 changes: 7 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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: 下限 (秒)",
Expand Down Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions spec/config/models_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions spec/config/usecase_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions spec/win_notification/usecase_spec.cr
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "../spec_helper"
require "../../src/runtime/i18n"

private def incoming(title = "件名", body = "本文", app_name = "Discord", icon = nil)
Notify::Incoming.new(
Expand Down Expand Up @@ -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)

Expand Down
41 changes: 41 additions & 0 deletions src/config/models.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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

# 本文の最大文字数として受け付ける範囲。
Expand Down
42 changes: 39 additions & 3 deletions src/runtime/settings_window.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading