wintf 0.0.3

Windows Tategaki Framework - Rust UI library with Japanese vertical text support
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! 出す番の状態機械(純粋)。
//!
//! 時刻・マウスの位置・押下・足元・追っている範囲の様子を引数で受けて 1 回進め、
//! 「すること」を返す。自分では時計も OS も読まない(OS の設定も引数の関数で受ける)。

use super::geometry::{PointPx, RectPx, in_safe_zone};
use super::ranges::{TooltipArea, TooltipRangeId};
use crate::ecs::PointF;
use bevy_ecs::prelude::*;
use std::time::{Duration, Instant};
use tracing::trace;

/// OS の待ち時間の設定を読めないときに使う値(OS の既定と同じ)。
pub(crate) const FALLBACK_HOVER_TIME: Duration = Duration::from_millis(400);
/// 出す番の間の見回りの間隔。
const PATROL: Duration = Duration::from_millis(100);
/// 直前のツールチップが消えてからこの間に入れば、待ち時間を設定の 1 倍にする。
const RESHOW: Duration = Duration::from_millis(200);

/// 出す番の印(通し番号・使い回さない・中身は非公開)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TooltipTurnToken(u64);

/// 出す番が終わった・ツールチップが消えた理由。
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TooltipEndReason {
    LeftSafeZone,
    ButtonPressed,
    WindowHidden,
    WindowDestroyed,
    RangeUnregistered,
    /// 安全地帯の中のまま、別の範囲(重ねた内側・外側、通り道の上の範囲)に入った。
    /// 安全地帯から出て別の範囲に入ったときは `LeftSafeZone`。
    EnteredOtherRange,
}

/// 足元(どの窓のどの範囲に入っているか)。入っていなければ None を渡す。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Over {
    pub window: Entity,
    pub range: TooltipRangeId,
    pub area_logical: TooltipArea,
    pub has_text: bool,
    pub pos_logical: PointF,
}

/// 今追っている範囲の様子(追っていなければ渡さない)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Tracked {
    // receives=その窓の配下に PointerState がある かつ マウスの位置が hit_test_in_window に当たる
    // かつ 窓が見えている(足元の候補 Over と同じ 3 つの条件)。
    Present { range_px: RectPx, receives: bool },
    RangeUnregistered,
    WindowDestroyed,
    WindowHidden,
}

#[derive(Debug)]
pub(crate) struct StepInput {
    pub now: Instant,
    /// 画面の位置。読めなければ None(待ちを進めず、出す番も終わらせない)。
    pub cursor: Option<PointPx>,
    /// 押している、または前回の判定の後に押された。
    pub button_down: bool,
    pub over: Option<Over>,
    pub tracked: Option<Tracked>,
}

/// 状態機械が殻に頼む「すること」。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Effect {
    /// 期限を預ける(待ちの期限・出す番の間の見回り)。
    ArmDeadline(Instant),
    /// 来た。stored が真なら預けた文字を出す。
    TurnStarted {
        token: TooltipTurnToken,
        window: Entity,
        range: TooltipRangeId,
        pos_logical: PointF,
        stored: bool,
    },
    /// 出ているツールチップを消す。
    Hide {
        token: TooltipTurnToken,
        reason: HideReason,
    },
    /// 終わった。
    TurnEnded {
        token: TooltipTurnToken,
        window: Entity,
        range: TooltipRangeId,
        reason: TooltipEndReason,
    },
}

/// 消した理由(記録用)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum HideReason {
    End(TooltipEndReason),
    /// 利用側の求め(dismiss)。
    Dismissed,
    /// 渡された文字が空。
    EmptyText,
}

/// 渡された文字をどうするか。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SupplyDecision {
    /// 出す(出ていれば置き換える)。
    Show,
    /// 空なので出さない(出ていれば消す)。
    HideEmpty,
    /// 終わった印なので出さない。
    Stale,
}

/// 出す番の状態機械。
#[derive(Debug, Default)]
pub(crate) struct TurnMachine {
    state: State,
    /// 最後に発行した印の番号(0 はまだ無い)。
    last_token: u64,
    /// 出ているツールチップの画面の矩形。
    tip: Option<RectPx>,
    /// 最後にツールチップが消えた時刻(出し直しの 0.2 秒の起点)。
    last_hidden: Option<Instant>,
    /// 前回の判定のマウスの位置(動いたかを見る)。
    last_cursor: Option<PointPx>,
}

#[derive(Debug, Default)]
enum State {
    #[default]
    Idle,
    Waiting {
        window: Entity,
        range: TooltipRangeId,
        deadline: Instant,
    },
    Active(Turn),
    /// 押したので、この範囲から出るまで出さない。
    Suppressed {
        range: TooltipRangeId,
    },
}

/// 続いている出す番。範囲の矩形は始まったときのものを持つ(途中の差し替えは次から効く)。
#[derive(Debug)]
struct Turn {
    token: TooltipTurnToken,
    window: Entity,
    range: TooltipRangeId,
    area: TooltipArea,
}

impl TurnMachine {
    /// 1 回進めて「すること」を返す。hover_time は待ちに入るときだけ呼ぶ。
    pub(crate) fn step(
        &mut self,
        input: &StepInput,
        hover_time: &mut dyn FnMut() -> Duration,
    ) -> Vec<Effect> {
        let mut fx = Vec::new();
        let moved = input.cursor.is_some() && input.cursor != self.last_cursor;
        // ① 追っている範囲を照らす(終われば何もない・押したので休み、へ)。
        self.state = match std::mem::take(&mut self.state) {
            State::Waiting {
                window,
                range,
                deadline,
            } => self.check_waiting(input, moved, window, range, deadline, &mut fx),
            State::Active(turn) => self.check_active(input, moved, turn, &mut fx),
            other => other,
        };
        // ② 押して休んでいる範囲から出たら、休みを解く(位置が分からない回は出たとみなさない)。
        if let State::Suppressed { range } = self.state
            && input.cursor.is_some()
            && input.over.map(|o| o.range) != Some(range)
        {
            trace!(range = ?range, "[tooltip_turn] 押した範囲から出た");
            self.state = State::Idle;
        }
        // ③ 何もなければ足元の範囲に入る(終わったその回のうちに別の範囲の待ちまで進める)。
        if let (State::Idle, Some(over)) = (&self.state, input.over) {
            self.state = if input.button_down {
                trace!(range = ?over.range, "[tooltip_turn] 押したまま入った(数えない)");
                State::Suppressed { range: over.range }
            } else {
                let deadline = input.now + self.wait_for(over.range, input.now, hover_time());
                fx.push(Effect::ArmDeadline(deadline));
                State::Waiting {
                    window: over.window,
                    range: over.range,
                    deadline,
                }
            };
        }
        if input.cursor.is_some() {
            self.last_cursor = input.cursor;
        }
        fx
    }

    /// 待ち時間=設定の 2 倍。直前のツールチップが消えてから 0.2 秒以内なら 1 倍。
    fn wait_for(&self, range: TooltipRangeId, now: Instant, setting: Duration) -> Duration {
        let reshow = self
            .last_hidden
            .is_some_and(|t| now.saturating_duration_since(t) <= RESHOW);
        let wait = if reshow { setting } else { setting * 2 };
        trace!(
            range = ?range,
            wait_ms = wait.as_millis() as u64,
            reshow, "[tooltip_turn] 範囲に入った・数え始め"
        );
        wait
    }

    fn check_waiting(
        &mut self,
        input: &StepInput,
        moved: bool,
        window: Entity,
        range: TooltipRangeId,
        deadline: Instant,
        fx: &mut Vec<Effect>,
    ) -> State {
        let reason = lost(input).or_else(|| match (input.cursor, input.over) {
            // 位置が分からない回は待ちを進めない。
            (None, _) => None,
            (Some(_), Some(o)) if o.range == range => None,
            (Some(_), _) => Some(left(input, moved)),
        });
        if let Some(reason) = reason {
            // 待ちから外れるだけなら知らせは出さない。
            trace!(range = ?range, reason = ?reason, "[tooltip_turn] 来なかった");
            return match reason {
                TooltipEndReason::ButtonPressed => State::Suppressed { range },
                _ => State::Idle,
            };
        }
        let over = input.over.filter(|_| input.cursor.is_some());
        let Some(over) = over.filter(|_| input.now >= deadline) else {
            fx.push(Effect::ArmDeadline(deadline));
            return State::Waiting {
                window,
                range,
                deadline,
            };
        };
        self.last_token += 1;
        let token = TooltipTurnToken(self.last_token);
        trace!(range = ?range, token = token.0, stored = over.has_text, "[tooltip_turn] 来た");
        fx.push(Effect::TurnStarted {
            token,
            window,
            range,
            pos_logical: over.pos_logical,
            stored: over.has_text,
        });
        fx.push(Effect::ArmDeadline(input.now + PATROL));
        State::Active(Turn {
            token,
            window,
            range,
            area: over.area_logical,
        })
    }

    fn check_active(
        &mut self,
        input: &StepInput,
        moved: bool,
        turn: Turn,
        fx: &mut Vec<Effect>,
    ) -> State {
        let reason = lost(input).or_else(|| {
            // 位置が分からない回・様子が無い回は終わらせない。
            let p = input.cursor?;
            let Some(Tracked::Present { range_px, receives }) = input.tracked else {
                return None;
            };
            // 足元を ツールチップ → 範囲 → 通り道 → それ以外 の順に見る。ツールチップの上は、
            // 下に別の範囲があっても続ける。範囲と通り道では、別の範囲に入っていれば切り替える。
            // (tip を渡さない in_safe_zone は「矩形の中か」と同じ。)
            let other = input.over.is_some_and(|o| o.range != turn.range);
            if self.tip.is_some_and(|tip| in_safe_zone(p, tip, None)) {
                None
            } else if in_safe_zone(p, range_px, None) && !receives {
                Some(left(input, moved))
            } else if !in_safe_zone(p, range_px, self.tip) {
                Some(TooltipEndReason::LeftSafeZone)
            } else {
                other.then_some(TooltipEndReason::EnteredOtherRange)
            }
        });
        let Some(reason) = reason else {
            fx.push(Effect::ArmDeadline(input.now + PATROL));
            return State::Active(turn);
        };
        self.end_turn(turn, reason, input.now, fx)
    }

    /// 出す番を終える(出ていれば消し、終わりを出す)。
    fn end_turn(
        &mut self,
        turn: Turn,
        reason: TooltipEndReason,
        now: Instant,
        fx: &mut Vec<Effect>,
    ) -> State {
        if self.tip.is_some() {
            fx.push(Effect::Hide {
                token: turn.token,
                reason: HideReason::End(reason),
            });
            self.set_tip(None, now);
        }
        trace!(range = ?turn.range, token = turn.token.0, reason = ?reason, "[tooltip_turn] 終わり");
        fx.push(Effect::TurnEnded {
            token: turn.token,
            window: turn.window,
            range: turn.range,
            reason,
        });
        match reason {
            TooltipEndReason::ButtonPressed => State::Suppressed { range: turn.range },
            _ => State::Idle,
        }
    }

    /// 印を照らして、渡された文字をどうするか決める。出す番が続いている限り時間の上限なく受ける。
    /// text_is_empty は `""` のときだけ真(範囲の表の「空」と同じ。空白だけの文字は空でない)。
    pub(crate) fn supply(
        &mut self,
        token: TooltipTurnToken,
        text_is_empty: bool,
    ) -> SupplyDecision {
        let decision = if !self.is_current(token) {
            SupplyDecision::Stale
        } else if text_is_empty {
            SupplyDecision::HideEmpty
        } else {
            SupplyDecision::Show
        };
        trace!(token = token.0, decision = ?decision, "[tooltip_turn] 文字を渡された");
        decision
    }

    /// 印が続いていれば真(ツールチップを消す。出す番は続く)。
    pub(crate) fn dismiss(&mut self, token: TooltipTurnToken, now: Instant) -> bool {
        let current = self.is_current(token);
        if current {
            self.set_tip(None, now);
        }
        trace!(
            token = token.0,
            current, "[tooltip_turn] 消してと求められた"
        );
        current
    }

    /// 表示の結果を覚える(Some=出た矩形・None=出ていない)。出ていたものが消えたら、その時刻を
    /// 出し直しの起点にする。表示の失敗も None で渡すだけで、出す番は続く。
    pub(crate) fn set_tip(&mut self, tip: Option<RectPx>, now: Instant) {
        if tip.is_none() && self.tip.is_some() {
            self.last_hidden = Some(now);
        }
        self.tip = tip;
    }

    /// 登録の取り消しを同期で反映する(追っている範囲なら終わらせる。待ちなら知らせなしで外れる)。
    pub(crate) fn on_unregistered(&mut self, id: TooltipRangeId, now: Instant) -> Vec<Effect> {
        let mut fx = Vec::new();
        match std::mem::take(&mut self.state) {
            State::Active(turn) if turn.range == id => {
                self.state = self.end_turn(turn, TooltipEndReason::RangeUnregistered, now, &mut fx);
            }
            State::Waiting { range, .. } if range == id => {
                // 待ちから外れるだけなので知らせは出さない(状態は何もない、のまま)。
                trace!(range = ?range, "[tooltip_turn] 来なかった(登録の取り消し)");
            }
            other => self.state = other,
        }
        fx
    }

    /// 印が今続いている出す番のものか。
    fn is_current(&self, token: TooltipTurnToken) -> bool {
        matches!(&self.state, State::Active(t) if t.token == token)
    }

    /// 今追っている範囲(待ち・出す番のとき)。殻が Tracked を作るために読む。
    pub(crate) fn tracked_range(&self) -> Option<(Entity, TooltipRangeId)> {
        match &self.state {
            State::Waiting { window, range, .. } => Some((*window, *range)),
            State::Active(t) => Some((t.window, t.range)),
            _ => None,
        }
    }

    /// 出す番の始まりの範囲の場所(出す番のときだけ)。殻が Tracked の矩形を作るために読む
    /// (途中の差し替えは次の出す番から効くので、今の登録ではなくこちらを使う)。
    pub(crate) fn active_area(&self) -> Option<TooltipArea> {
        match &self.state {
            State::Active(t) => Some(t.area),
            _ => None,
        }
    }

    /// 何もない状態か(待ち・出す番・押したので休み、のどれでもない)。
    pub(crate) fn is_idle(&self) -> bool {
        matches!(self.state, State::Idle)
    }
}

/// 位置に依らず、追っている範囲を続けられない理由(消えた・隠れた・押した)。
fn lost(input: &StepInput) -> Option<TooltipEndReason> {
    match input.tracked {
        Some(Tracked::RangeUnregistered) => Some(TooltipEndReason::RangeUnregistered),
        Some(Tracked::WindowDestroyed) => Some(TooltipEndReason::WindowDestroyed),
        Some(Tracked::WindowHidden) => Some(TooltipEndReason::WindowHidden),
        _ => input.button_down.then_some(TooltipEndReason::ButtonPressed),
    }
}

/// 範囲の外へ外れた理由。範囲の矩形の中で窓が受けなくなったとき、動いていなければ
/// 「窓が隠れた」(絵や当たり判定が消えた)、動いていれば「安全地帯から出た」(透過の穴や
/// 上に重なった別の窓へ移った)。
fn left(input: &StepInput, moved: bool) -> TooltipEndReason {
    match input.tracked {
        Some(Tracked::Present {
            receives: false, ..
        }) if !moved => TooltipEndReason::WindowHidden,
        _ => TooltipEndReason::LeftSafeZone,
    }
}

#[cfg(test)]
#[path = "turn_tests.rs"]
mod turn_tests;