Merge pull request 'feat: add About dialog with version and license info' (#1) from feature/about into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-02-12 03:23:22 +09:00
5 changed files with 182 additions and 9 deletions

View File

@@ -9,6 +9,7 @@ TauClockは、ローカル/UTC時計、アラーム、カウントダウンタ
- 柔軟な入力形式に対応したカウントダウンタイマー(`5m30s`, `1:15`, `90` など) - 柔軟な入力形式に対応したカウントダウンタイマー(`5m30s`, `1:15`, `90` など)
- ラップ記録つきストップウォッチと履歴リスト - ラップ記録つきストップウォッチと履歴リスト
- キーボードショートカットと画面内に表示できるチートシート - キーボードショートカットと画面内に表示できるチートシート
- アプリ説明・バージョン・ライセンス本文を確認できるAboutダイアログ
- Tauri + Rust製の軽量クロスプラットフォームバイナリ - Tauri + Rust製の軽量クロスプラットフォームバイナリ
## キーボードショートカット ## キーボードショートカット
@@ -16,6 +17,7 @@ TauClockは、ローカル/UTC時計、アラーム、カウントダウンタ
| キー | 動作 | | キー | 動作 |
| ----- | ------------------------------------------ | | ----- | ------------------------------------------ |
| `H` | ショートカット一覧を開閉 | | `H` | ショートカット一覧を開閉 |
| `I` | Aboutダイアログを開閉 |
| `A` | アラームの時刻入力欄にフォーカス | | `A` | アラームの時刻入力欄にフォーカス |
| `N` | アラームを追加 | | `N` | アラームを追加 |
| `T` | タイマーの時間入力欄にフォーカス | | `T` | タイマーの時間入力欄にフォーカス |

View File

@@ -5,10 +5,21 @@
mod state; mod state;
use log::LevelFilter; use log::LevelFilter;
use serde::Serialize;
use state::{AppState, FrontendState}; use state::{AppState, FrontendState};
use std::{path::PathBuf, str::FromStr}; use std::{path::PathBuf, str::FromStr};
use tauri_plugin_log::{Target, TargetKind}; use tauri_plugin_log::{Target, TargetKind};
/// Metadata and legal text displayed by the frontend "About" dialog.
#[derive(Debug, Clone, Serialize)]
struct AboutInfo {
app_name: String,
version: String,
identifier: String,
license_name: String,
license_text: String,
}
/// Returns the current frontend snapshot without mutating any state. Used for /// Returns the current frontend snapshot without mutating any state. Used for
/// the initial UI bootstrap. /// the initial UI bootstrap.
#[tauri::command] #[tauri::command]
@@ -107,6 +118,21 @@ fn reset_stopwatch(app_state: tauri::State<AppState>) -> FrontendState {
app_state.reset_stopwatch() app_state.reset_stopwatch()
} }
/// Returns static application metadata and the bundled MIT license text for
/// display in the About dialog.
#[tauri::command]
fn get_about_info(app: tauri::AppHandle) -> AboutInfo {
let package = app.package_info();
let identifier = app.config().identifier.clone();
AboutInfo {
app_name: package.name.clone(),
version: package.version.to_string(),
identifier,
license_name: "MIT".to_string(),
license_text: include_str!("../../LICENSE").trim().to_string(),
}
}
/// Initializes the Tauri runtime, configures plugins, and wires shared /// Initializes the Tauri runtime, configures plugins, and wires shared
/// application state into every window. /// application state into every window.
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -139,7 +165,8 @@ pub fn run() {
reset_timer, reset_timer,
toggle_stopwatch, toggle_stopwatch,
add_lap, add_lap,
reset_stopwatch reset_stopwatch,
get_about_info
]) ])
.setup(move |_| { .setup(move |_| {
log::info!("Tauclock backend ready; storage_path={}", storage_display); log::info!("Tauclock backend ready; storage_path={}", storage_display);

View File

@@ -24,6 +24,7 @@
</div> </div>
<div class="header-meta"> <div class="header-meta">
<button type="button" id="shortcut-trigger" class="hint-button">ショートカット一覧 (H)</button> <button type="button" id="shortcut-trigger" class="hint-button">ショートカット一覧 (H)</button>
<button type="button" id="about-trigger" class="hint-button">About (I)</button>
</div> </div>
</header> </header>
@@ -118,6 +119,8 @@
<dl> <dl>
<dt>H</dt> <dt>H</dt>
<dd>ショートカット一覧を表示/非表示</dd> <dd>ショートカット一覧を表示/非表示</dd>
<dt>I</dt>
<dd>Aboutを表示/非表示</dd>
<dt>A</dt> <dt>A</dt>
<dd>アラーム時刻入力へフォーカス</dd> <dd>アラーム時刻入力へフォーカス</dd>
<dt>N</dt> <dt>N</dt>
@@ -139,5 +142,26 @@
</dl> </dl>
</div> </div>
</section> </section>
<section id="about-overlay" class="about-overlay" hidden>
<div class="about-card" role="dialog" aria-modal="true" aria-labelledby="about-heading" tabindex="-1">
<header>
<h2 id="about-heading">About TauClock</h2>
<button type="button" id="about-close" class="ghost">閉じる (Esc)</button>
</header>
<dl class="about-meta">
<dt>アプリ名</dt>
<dd id="about-name">-</dd>
<dt>バージョン</dt>
<dd id="about-version">-</dd>
<dt>識別子</dt>
<dd id="about-identifier">-</dd>
<dt>ライセンス</dt>
<dd id="about-license-name">-</dd>
</dl>
<h3>ライセンス本文</h3>
<pre id="about-license-text" class="about-license"></pre>
</div>
</section>
</body> </body>
</html> </html>

View File

@@ -92,6 +92,7 @@ function resolveInvoke() {
const elements = {}; const elements = {};
let tickInFlight = false; let tickInFlight = false;
let latestState = null; let latestState = null;
let aboutInfo = null;
/** Keyboard shortcut handlers keyed by `KeyboardEvent.code`. */ /** Keyboard shortcut handlers keyed by `KeyboardEvent.code`. */
const SHORTCUTS = { const SHORTCUTS = {
@@ -104,6 +105,7 @@ const SHORTCUTS = {
KeyL: () => requestState("add_lap"), KeyL: () => requestState("add_lap"),
KeyP: () => requestState("reset_stopwatch"), KeyP: () => requestState("reset_stopwatch"),
KeyH: () => toggleShortcutOverlay(), KeyH: () => toggleShortcutOverlay(),
KeyI: () => toggleAboutOverlay(),
}; };
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {
@@ -141,6 +143,14 @@ function cacheElements() {
elements.shortcutOverlay = document.getElementById("shortcut-overlay"); elements.shortcutOverlay = document.getElementById("shortcut-overlay");
elements.shortcutClose = document.getElementById("shortcut-close"); elements.shortcutClose = document.getElementById("shortcut-close");
elements.shortcutTrigger = document.getElementById("shortcut-trigger"); elements.shortcutTrigger = document.getElementById("shortcut-trigger");
elements.aboutOverlay = document.getElementById("about-overlay");
elements.aboutClose = document.getElementById("about-close");
elements.aboutTrigger = document.getElementById("about-trigger");
elements.aboutName = document.getElementById("about-name");
elements.aboutVersion = document.getElementById("about-version");
elements.aboutIdentifier = document.getElementById("about-identifier");
elements.aboutLicenseName = document.getElementById("about-license-name");
elements.aboutLicenseText = document.getElementById("about-license-text");
const missing = Object.entries(elements) const missing = Object.entries(elements)
.filter(([, value]) => !value) .filter(([, value]) => !value)
.map(([key]) => key); .map(([key]) => key);
@@ -198,6 +208,13 @@ function bindEvents() {
if (elements.shortcutTrigger) { if (elements.shortcutTrigger) {
elements.shortcutTrigger.addEventListener("click", () => toggleShortcutOverlay(true)); elements.shortcutTrigger.addEventListener("click", () => toggleShortcutOverlay(true));
} }
elements.aboutClose.addEventListener("click", () => toggleAboutOverlay(false));
elements.aboutOverlay.addEventListener("click", (event) => {
if (event.target === elements.aboutOverlay) toggleAboutOverlay(false);
});
if (elements.aboutTrigger) {
elements.aboutTrigger.addEventListener("click", () => toggleAboutOverlay(true));
}
document.addEventListener("keydown", handleShortcuts, true); document.addEventListener("keydown", handleShortcuts, true);
logInfo("Core UI events bound"); logInfo("Core UI events bound");
@@ -209,11 +226,21 @@ function bindEvents() {
*/ */
async function bootstrap() { async function bootstrap() {
logInfo("Bootstrapping state"); logInfo("Bootstrapping state");
await requestState("get_state"); await Promise.all([requestState("get_state"), loadAboutInfo()]);
setInterval(runTick, 250); setInterval(runTick, 250);
logInfo("Tick loop scheduled", { intervalMs: 250 }); logInfo("Tick loop scheduled", { intervalMs: 250 });
} }
/** Loads application metadata used by the About dialog. */
async function loadAboutInfo() {
try {
aboutInfo = await invoke("get_about_info");
renderAboutInfo(aboutInfo);
} catch (error) {
logError("Failed to load About information", error);
}
}
/** /**
* Requests a timer toggle from the backend, optionally forcing a reparse of * Requests a timer toggle from the backend, optionally forcing a reparse of
* the duration input if the user explicitly pressed Enter inside the field. * the duration input if the user explicitly pressed Enter inside the field.
@@ -401,6 +428,9 @@ function toggleShortcutOverlay(force) {
const overlay = elements.shortcutOverlay; const overlay = elements.shortcutOverlay;
if (!overlay) return; if (!overlay) return;
const next = typeof force === "boolean" ? force : overlay.hidden; const next = typeof force === "boolean" ? force : overlay.hidden;
if (next) {
toggleAboutOverlay(false);
}
overlay.hidden = !next; overlay.hidden = !next;
logInfo("Shortcut overlay toggled", { visible: next }); logInfo("Shortcut overlay toggled", { visible: next });
if (next) { if (next) {
@@ -408,13 +438,46 @@ function toggleShortcutOverlay(force) {
} }
} }
/**
* Toggles the About dialog visibility and lazily refreshes metadata when
* missing so the UI can recover from early load failures.
*/
function toggleAboutOverlay(force) {
const overlay = elements.aboutOverlay;
if (!overlay) return;
const next = typeof force === "boolean" ? force : overlay.hidden;
if (next) {
toggleShortcutOverlay(false);
if (!aboutInfo) {
loadAboutInfo();
}
}
overlay.hidden = !next;
logInfo("About overlay toggled", { visible: next });
if (next) {
overlay.querySelector(".about-card")?.focus?.();
}
}
/** Applies About metadata fields into the dialog. */
function renderAboutInfo(info) {
if (!info) return;
elements.aboutName.textContent = info.app_name || "-";
elements.aboutVersion.textContent = info.version || "-";
elements.aboutIdentifier.textContent = info.identifier || "-";
elements.aboutLicenseName.textContent = info.license_name || "-";
elements.aboutLicenseText.textContent = info.license_text || "";
}
/** /**
* Global keyboard listener that translates single-key shortcuts into higher * Global keyboard listener that translates single-key shortcuts into higher
* level actions while respecting modifier keys, focus states, and overlays. * level actions while respecting modifier keys, focus states, and overlays.
*/ */
function handleShortcuts(event) { function handleShortcuts(event) {
if (event.defaultPrevented) return; if (event.defaultPrevented) return;
const overlayVisible = elements.shortcutOverlay && !elements.shortcutOverlay.hidden; const shortcutOverlayVisible = elements.shortcutOverlay && !elements.shortcutOverlay.hidden;
const aboutOverlayVisible = elements.aboutOverlay && !elements.aboutOverlay.hidden;
const overlayVisible = shortcutOverlayVisible || aboutOverlayVisible;
const trackedKey = Boolean(SHORTCUTS[event.code]) || event.key === "Escape"; const trackedKey = Boolean(SHORTCUTS[event.code]) || event.key === "Escape";
if (trackedKey) { if (trackedKey) {
logDebug("Shortcut event received", { logDebug("Shortcut event received", {
@@ -425,11 +488,16 @@ function handleShortcuts(event) {
}); });
} }
if (event.key === "Escape") { if (event.key === "Escape") {
if (overlayVisible) { if (shortcutOverlayVisible) {
toggleShortcutOverlay(false); toggleShortcutOverlay(false);
event.preventDefault(); event.preventDefault();
return; return;
} }
if (aboutOverlayVisible) {
toggleAboutOverlay(false);
event.preventDefault();
return;
}
if (document.activeElement && document.activeElement !== document.body) { if (document.activeElement && document.activeElement !== document.body) {
document.activeElement.blur(); document.activeElement.blur();
event.preventDefault(); event.preventDefault();
@@ -437,7 +505,7 @@ function handleShortcuts(event) {
return; return;
} }
if (event.altKey || event.metaKey || event.ctrlKey) return; if (event.altKey || event.metaKey || event.ctrlKey) return;
if (overlayVisible && event.code !== "KeyH") return; if (overlayVisible && event.code !== "KeyH" && event.code !== "KeyI") return;
if (isTypingTarget(event.target)) return; if (isTypingTarget(event.target)) return;
const handler = SHORTCUTS[event.code]; const handler = SHORTCUTS[event.code];
if (handler) { if (handler) {

View File

@@ -77,6 +77,9 @@ h1 {
.header-meta { .header-meta {
font-size: 0.85rem; font-size: 0.85rem;
color: #b6c5ff; color: #b6c5ff;
display: flex;
align-items: center;
gap: 0.6rem;
} }
.hint { .hint {
@@ -396,8 +399,9 @@ button.ghost {
} }
} }
/* Keyboard shortcut modal overlay */ /* Keyboard shortcut/About modal overlay */
.shortcut-overlay { .shortcut-overlay,
.about-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
@@ -407,7 +411,8 @@ button.ghost {
justify-content: center; justify-content: center;
} }
.shortcut-card { .shortcut-card,
.about-card {
width: min(520px, 80vw); width: min(520px, 80vw);
background: rgba(4, 8, 24, 0.92); background: rgba(4, 8, 24, 0.92);
border-radius: 1.5rem; border-radius: 1.5rem;
@@ -416,7 +421,8 @@ button.ghost {
box-shadow: 0 25px 70px rgba(0, 0, 0, 0.55); box-shadow: 0 25px 70px rgba(0, 0, 0, 0.55);
} }
.shortcut-card header { .shortcut-card header,
.about-card header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -441,6 +447,48 @@ button.ghost {
color: #e5ebff; color: #e5ebff;
} }
.about-card {
width: min(760px, 92vw);
max-height: min(84vh, 780px);
overflow: auto;
}
.about-meta {
margin: 1rem 0;
display: grid;
grid-template-columns: minmax(120px, 180px) 1fr;
gap: 0.3rem 0.75rem;
}
.about-meta dt {
color: #9dc0ff;
font-weight: 600;
}
.about-meta dd {
margin: 0;
color: #e5ebff;
overflow-wrap: anywhere;
}
.about-card h3 {
margin: 0.6rem 0 0.45rem;
font-size: 0.95rem;
color: #9dc0ff;
}
.about-license {
margin: 0;
padding: 1rem;
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(1, 3, 11, 0.8);
color: #d9e5ff;
line-height: 1.45;
white-space: pre-wrap;
font-size: 0.84rem;
}
/* Responsive adjustments for tablet/phone layouts */ /* Responsive adjustments for tablet/phone layouts */
@media (max-width: 1180px) { @media (max-width: 1180px) {
.app-shell { .app-shell {
@@ -482,4 +530,8 @@ button.ghost {
.shortcut-card dl { .shortcut-card dl {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.about-meta {
grid-template-columns: 1fr;
}
} }