From ba968cbed9c865ce161c0e14050448fa9c92b4b3 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:45:24 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20X-plore=20=EB=B9=84=EA=B5=90=2040=20(?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C=2010/20):=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=ED=83=90=EC=83=89=EA=B8=B0=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EA=B8=80=EC=9E=90=20=ED=81=AC=EA=B8=B0=20(80~150%,?= =?UTF-8?q?=20=EC=B2=98=EC=9D=8C=20100%)=20=C2=B7=20=EC=A4=84=20=EA=B0=84?= =?UTF-8?q?=EA=B2=A9=20(=EC=A2=81=EA=B2=8C=200.8=20=C2=B7=20=EB=B3=B4?= =?UTF-8?q?=ED=86=B5=20=C2=B7=20=EB=84=93=EA=B2=8C=201.25,=20=EC=B2=98?= =?UTF-8?q?=EC=9D=8C=20=EB=B3=B4=ED=86=B5)=20-=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20>=20=ED=8C=8C=EC=9D=BC=20=ED=83=90?= =?UTF-8?q?=EC=83=89=EA=B8=B0,=20=EC=84=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=20=EB=AA=A8=EB=91=90,=20=EC=95=B1=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=C2=B7=20=EA=B8=B0=EA=B8=B0=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=ED=81=AC=EA=B8=B0=EC=99=80=20=EA=B3=B1=ED=95=A8=20?= =?UTF-8?q?(=EC=B2=98=EC=9D=8C=20=EA=B0=92=EC=9D=80=20=EC=98=88=EC=A0=84?= =?UTF-8?q?=20=EC=A4=84=20=EB=86=92=EC=9D=B4=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= =?UTF-8?q?)=20=C2=B7=20=EB=B2=84=EA=B7=B8=20=EA=B3=A0=EC=B9=A8:=20412=20?= =?UTF-8?q?=ED=8F=AD=20"=ED=8F=B4=EB=8D=94=20+=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D"=20=EC=9D=98=20=EC=A2=81=EC=9D=80=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20(77px)=20=EC=97=90=EC=84=9C=20X-plore=20?= =?UTF-8?q?=C2=B7=20TC=20=EC=A4=84=EC=9D=B4=20=EB=84=98=EC=B9=98=EB=8D=98?= =?UTF-8?q?=20=EA=B2=83=20(100%=20=EC=97=90=EC=84=9C=EB=8F=84)=20-=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=B9=B8=EC=9D=84=20=EB=82=A8?= =?UTF-8?q?=EC=9D=80=20=ED=8F=AD=EB=A7=8C=ED=81=BC=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20(=EC=84=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=C3=97?= =?UTF-8?q?=20100/150=20=EB=84=93=EA=B2=8C/=EA=B8=B0=EA=B8=B0=201.3=C3=971?= =?UTF-8?q?20=20=EC=A2=81=EA=B2=8C/80)=20=C2=B7=20=EC=BA=A1=EC=B2=98=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20integration=5Ftest/explorer=5Ftext=5Fshot?= =?UTF-8?q?=5Ftest.dart=20(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 8 ++ assets/l10n/ja.json | 8 ++ assets/l10n/zh-Hans.json | 8 ++ integration_test/explorer_text_shot_test.dart | 61 ++++++++++ lib/app/settings.dart | 15 +++ lib/ui/explorer_look.dart | 38 +++++- lib/ui/explorer_page.dart | 33 ++++-- lib/ui/settings_page.dart | 28 +++++ test/explorer_text_scale_test.dart | 110 ++++++++++++++++++ 9 files changed, 293 insertions(+), 16 deletions(-) create mode 100644 integration_test/explorer_text_shot_test.dart create mode 100644 test/explorer_text_scale_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index c7fdc4d..83d6e0b 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -543,6 +543,7 @@ "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "Delete page {0}? (Saved to the file only when you save)", "{0} 펼치기": "Expand {0}", "{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "Folder {0} is missing or can't be opened, so it was removed from history", + "{0}% (기본)": "{0}% (default)", "{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "Stopped because it made no progress past {0}% for over 2 minutes. The network may be blocking Tor or be slow. Press [Try again] in a moment.", "{0}. {1} (대기)": "{0}. {1} (waiting)", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: moves this video to {1}", @@ -927,6 +928,7 @@ "내장 저장소": "Internal storage", "내장 플레이어": "Built-in player", "내장 플레이어로 재생": "Play in built-in player", + "넓게": "Wide", "넘기는 방향: 오른쪽 → 왼쪽 (만화)": "Page direction: right → left (manga)", "넘기는 방향: 왼쪽 → 오른쪽": "Page direction: left → right", "넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "If set, encrypts with AES-256. Leave empty for a normal ZIP", @@ -1257,6 +1259,7 @@ "목": "Thu", "목록": "list", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos", + "목록 글자 크기": "List text size", "목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.": "You can see them again with [Show hidden items] at the bottom of the list, or in Settings > File explorer.", "목록 보기": "View list", "목록 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the list file: {0}", @@ -1434,6 +1437,7 @@ "보조 DNS (비워 둘 수 있음)": "Secondary DNS (may be empty)", "보통": "Normal", "보통 (0.25, 기본)": "Normal (0.25, default)", + "보통 (기본)": "Normal (default)", "보통 비워 둡니다": "Usually left empty", "보통: {0}": "Normal: {0}", "복사": "Copy", @@ -2398,6 +2402,7 @@ "정해 둠. 저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다 (앱을 끌 때까지 한 번)": "Set. Asked when using saved passwords, settings or features that need a password (once until the app is closed)", "제목": "Title", "조정 전": "Before", + "좁게": "Compact", "좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)": "Height of the video list on narrow screens (Auto: as tall as its rows, keeping room for the details)", "종료": "Quit", "종료 (창 닫기 ✕ · 종료 버튼) 를 누르면": "When you press quit (close window ✕ · quit button)", @@ -2430,6 +2435,7 @@ "준비 중": "Preparing", "준비 중…": "Preparing…", "준비하는 중": "Preparing", + "줄 간격": "Row spacing", "줄 번호": "Line numbers", "줄 삭제": "Delete line", "중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.": "Not connected because this could be an interception attack. Only if you're sure the server certificate was changed, use [Forget trusted certificate] in the server settings and connect again.", @@ -2691,6 +2697,8 @@ "파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "[Monitor] button in the middle of the file explorer: remembers copies · moves and shows progress · target disk space · options; change options to run again · resume, or move to lsync (copy · rsync and lsync shown separately).", "파일 탐색기 누르기: 한 번 누르면 선택 → 한 번 누르면 바로 실행. 되돌리기: 환경 설정 > 파일 탐색기 > 누르기": "File explorer tap: single tap selects → single tap opens. To revert: Settings > File explorer > Tap", "파일 탐색기 두 창 배치: 화면 모양 따라 → 좌우. 되돌리기: 파일 탐색기의 [좌우 ⇆ / 위아래 ⇅] 버튼 또는 창 배치": "File explorer two-pane layout: follow screen shape → side by side. To revert: the [Columns ⇆ / Rows ⇅] button in the file explorer, or Pane layout", + "파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다": "Only the name and size text in the file explorer list. Multiplied by the app screen size and the device text size", + "파일 탐색기 목록의 줄 높이": "Row height in the file explorer list", "파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "You can also change it from the ⋮ menu at the top of the file explorer (side by side · top and bottom · button bar position · buttons)", "파일 탐색기 창: 두 창 → 화면 크기 따라 (폰 세로는 한 창). 되돌리기: 환경 설정 > 파일 탐색기 > 창 배치": "File explorer panes: two panes → by screen size (one pane on a portrait phone). To revert: Settings > File explorer > Pane layout", "파일 탐색기: 파일을 한 번 누르면 바로 엽니다 (고르기 · 메뉴는 길게 누르기). 바꾸기: 환경 설정 > 파일 탐색기 > 누르기": "File explorer: a single tap now opens a file right away (long-press to select or for the menu). To change: Settings > File explorer > Tap", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index a36da9e..876e41a 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -543,6 +543,7 @@ "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "{0} ページを削除しますか? (保存するとファイルに反映されます)", "{0} 펼치기": "{0} を展開", "{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "{0} フォルダーが存在しないか開けないため、履歴から削除しました", + "{0}% (기본)": "{0}% (既定)", "{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "{0}% から 2 分以上進まなかったため停止しました。ネットワークが Tor をブロックしているか、遅い可能性があります。少し待ってから [再試行] してください。", "{0}. {1} (대기)": "{0}. {1}(待機)", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: この動画を {1} に移動します", @@ -927,6 +928,7 @@ "내장 저장소": "内部ストレージ", "내장 플레이어": "内蔵プレーヤー", "내장 플레이어로 재생": "内蔵プレーヤーで再生", + "넓게": "広く", "넘기는 방향: 오른쪽 → 왼쪽 (만화)": "めくる方向: 右 → 左 (マンガ)", "넘기는 방향: 왼쪽 → 오른쪽": "めくる方向: 左 → 右", "넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "入力すると AES-256 で暗号化します。空欄なら通常の ZIP", @@ -1257,6 +1259,7 @@ "목": "木", "목록": "一覧", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します", + "목록 글자 크기": "一覧の文字サイズ", "목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.": "リスト最下部の [隠した項目を表示] または 設定 > ファイルエクスプローラー で再び表示できます。", "목록 보기": "リストを見る", "목록 파일은 전체 경로로 적어 주세요: {0}": "リストファイルはフルパスで書いてください: {0}", @@ -1434,6 +1437,7 @@ "보조 DNS (비워 둘 수 있음)": "代替 DNS (空欄可)", "보통": "普通", "보통 (0.25, 기본)": "普通 (0.25、既定)", + "보통 (기본)": "標準 (既定)", "보통 비워 둡니다": "通常は空欄", "보통: {0}": "普通: {0}", "복사": "コピー", @@ -2398,6 +2402,7 @@ "정해 둠. 저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다 (앱을 끌 때까지 한 번)": "設定済み。保存したパスワード・設定・パスワードが必要な機能を使うときに尋ねます (アプリを終了するまで 1 回)", "제목": "タイトル", "조정 전": "調整前", + "좁게": "狭く", "좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)": "狭い画面で動画リストが占める高さ (自動: 行の数だけ、詳細欄の場所は残す)", "종료": "終了", "종료 (창 닫기 ✕ · 종료 버튼) 를 누르면": "終了(ウィンドウを閉じる ✕・終了ボタン)を押したとき", @@ -2430,6 +2435,7 @@ "준비 중": "準備中", "준비 중…": "準備中…", "준비하는 중": "準備中", + "줄 간격": "行の間隔", "줄 번호": "行番号", "줄 삭제": "行を削除", "중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.": "中間者攻撃の可能性があるため接続しませんでした。サーバー証明書を変えたことが確かな場合だけ、サーバー設定の [信頼した証明書を削除] の後に再接続してください。", @@ -2691,6 +2697,8 @@ "파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "ファイルエクスプローラー中央に [モニタリング] ボタン: コピー · 移動を記憶して進捗 · 対象ディスク容量 · オプションを表示し、オプションを変えて再実行 · 再開、または lsync へ移動します (コピー · rsync と lsync は別々)。", "파일 탐색기 누르기: 한 번 누르면 선택 → 한 번 누르면 바로 실행. 되돌리기: 환경 설정 > 파일 탐색기 > 누르기": "ファイルエクスプローラーのタップ: 1 回で選択 → 1 回ですぐ開く。元に戻す: 環境設定 > ファイルエクスプローラー > タップ", "파일 탐색기 두 창 배치: 화면 모양 따라 → 좌우. 되돌리기: 파일 탐색기의 [좌우 ⇆ / 위아래 ⇅] 버튼 또는 창 배치": "ファイルエクスプローラーの 2 画面配置: 画面の形に合わせる → 左右。元に戻す: ファイルエクスプローラーの [左右 ⇆ / 上下 ⇅] ボタンまたは画面配置", + "파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다": "ファイルエクスプローラー一覧の名前・サイズの文字のみ。アプリの画面サイズ・端末の文字サイズと掛け合わされます", + "파일 탐색기 목록의 줄 높이": "ファイルエクスプローラー一覧の行の高さ", "파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "ファイルエクスプローラー上部の ⋮ メニューからも変えられます (左右 · 上下 · ボタン列の位置 · ボタン構成)", "파일 탐색기 창: 두 창 → 화면 크기 따라 (폰 세로는 한 창). 되돌리기: 환경 설정 > 파일 탐색기 > 창 배치": "ファイルエクスプローラーの画面: 2 画面 → 画面サイズに合わせる (縦向きのスマホは 1 画面)。元に戻す: 環境設定 > ファイルエクスプローラー > 画面配置", "파일 탐색기: 파일을 한 번 누르면 바로 엽니다 (고르기 · 메뉴는 길게 누르기). 바꾸기: 환경 설정 > 파일 탐색기 > 누르기": "ファイルエクスプローラー: ファイルを 1 回タップするとすぐ開きます (選択 · メニューは長押し)。変更: 環境設定 > ファイルエクスプローラー > タップ", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 22d5464..5157828 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -543,6 +543,7 @@ "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "删除第 {0} 页? (保存后才会写入文件)", "{0} 펼치기": "展开 {0}", "{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "文件夹 {0} 不存在或无法打开,已从历史记录中移除", + "{0}% (기본)": "{0}% (默认)", "{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "在 {0}% 停滞超过 2 分钟,已停止。网络可能在阻止 Tor 或速度较慢。请稍后点 [重试]。", "{0}. {1} (대기)": "{0}. {1}(等待)", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: 将此视频移到 {1}", @@ -927,6 +928,7 @@ "내장 저장소": "内部存储", "내장 플레이어": "内置播放器", "내장 플레이어로 재생": "用内置播放器播放", + "넓게": "宽松", "넘기는 방향: 오른쪽 → 왼쪽 (만화)": "翻页方向: 右 → 左 (漫画)", "넘기는 방향: 왼쪽 → 오른쪽": "翻页方向: 左 → 右", "넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "填写后使用 AES-256 加密。留空则为普通 ZIP", @@ -1257,6 +1259,7 @@ "목": "四", "목록": "列表", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域", + "목록 글자 크기": "列表文字大小", "목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.": "可通过列表底部的 [显示隐藏项] 或 设置 > 文件管理器 重新查看。", "목록 보기": "查看列表", "목록 파일은 전체 경로로 적어 주세요: {0}": "请填写列表文件的完整路径: {0}", @@ -1434,6 +1437,7 @@ "보조 DNS (비워 둘 수 있음)": "备用 DNS (可留空)", "보통": "普通", "보통 (0.25, 기본)": "普通 (0.25,默认)", + "보통 (기본)": "正常 (默认)", "보통 비워 둡니다": "通常留空", "보통: {0}": "普通:{0}", "복사": "复制", @@ -2398,6 +2402,7 @@ "정해 둠. 저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다 (앱을 끌 때까지 한 번)": "已设置。使用已保存的密码 · 设置 · 需要密码的功能时询问 (在关闭应用前只问一次)", "제목": "标题", "조정 전": "调整前", + "좁게": "紧凑", "좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)": "窄屏时视频列表所占高度 (自动: 按行数,并为详情区留出空间)", "종료": "退出", "종료 (창 닫기 ✕ · 종료 버튼) 를 누르면": "点击退出(关闭窗口 ✕ · 退出按钮)时", @@ -2430,6 +2435,7 @@ "준비 중": "准备中", "준비 중…": "准备中…", "준비하는 중": "正在准备", + "줄 간격": "行间距", "줄 번호": "行号", "줄 삭제": "删除行", "중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.": "可能是中间人攻击,因此未连接。仅当确定服务器证书已更换时,在服务器设置中 [删除已信任的证书] 后重新连接。", @@ -2691,6 +2697,8 @@ "파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "文件浏览器中间的 [监控] 按钮: 记住复制 · 移动并显示进度 · 目标磁盘容量 · 选项,可修改选项后重新运行 · 继续,或移到 lsync (复制 · rsync 与 lsync 分开显示)。", "파일 탐색기 누르기: 한 번 누르면 선택 → 한 번 누르면 바로 실행. 되돌리기: 환경 설정 > 파일 탐색기 > 누르기": "文件浏览器点击: 单击选择 → 单击直接打开。恢复: 设置 > 文件浏览器 > 点击", "파일 탐색기 두 창 배치: 화면 모양 따라 → 좌우. 되돌리기: 파일 탐색기의 [좌우 ⇆ / 위아래 ⇅] 버튼 또는 창 배치": "文件浏览器双窗格布局: 跟随屏幕形状 → 左右。恢复: 文件浏览器中的 [左右 ⇆ / 上下 ⇅] 按钮或窗格布局", + "파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다": "仅文件管理器列表中的名称和大小文字。会与应用界面大小及设备文字大小相乘", + "파일 탐색기 목록의 줄 높이": "文件管理器列表的行高", "파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "也可以在文件浏览器顶部的 ⋮ 菜单中更改 (左右 · 上下 · 按钮栏位置 · 按钮配置)", "파일 탐색기 창: 두 창 → 화면 크기 따라 (폰 세로는 한 창). 되돌리기: 환경 설정 > 파일 탐색기 > 창 배치": "文件浏览器窗格: 双窗格 → 按屏幕大小 (竖屏手机为单窗格)。恢复: 设置 > 文件浏览器 > 窗格布局", "파일 탐색기: 파일을 한 번 누르면 바로 엽니다 (고르기 · 메뉴는 길게 누르기). 바꾸기: 환경 설정 > 파일 탐색기 > 누르기": "文件浏览器:单击文件会立即打开(选择 · 菜单请长按)。更改:设置 > 文件浏览器 > 点按", diff --git a/integration_test/explorer_text_shot_test.dart b/integration_test/explorer_text_shot_test.dart new file mode 100644 index 0000000..cb4edd9 --- /dev/null +++ b/integration_test/explorer_text_shot_test.dart @@ -0,0 +1,61 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 40 (관리자 지시 10/20): 412 폭 · 목록 글자 150% · 넓게 (그리고 처음 값) 를 실제 글꼴로 그려 PNG 로 (JJ_SHOT_DIR). +/// 세 스타일 모두. 넘치면 (RenderFlex overflow) 시험이 실패한다 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('40: 목록 글자 크기 · 줄 간격 화면 그림 (412 폭)', (tester) async { + final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; + final tmp = Directory.systemTemp.createTempSync('jj_textshot_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + File(p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 글자 크기 시험용 2026 최종본.mp4')).writeAsStringSync('v' * 123456); + File(p.join(tmp.path, 'Very long English file name for the text size check.txt')).writeAsStringSync('t' * 4321); + File(p.join(tmp.path, 'a.jpg')).writeAsStringSync('x'); + Directory(p.join(tmp.path, '폴더 하나')).createSync(); + Directory(p.join(tmp.path, 'Another folder with a long name')).createSync(); + final c = AppController(PlatformServices.create()); + c.settings.explorerPaths = [tmp.path, tmp.path]; + final key = GlobalKey(); + for (final layout in ['single', 'split']) { + for (final style in ['xplore', 'windows', 'totalcmd']) { + for (final (scale, spacing) in [(100, 'normal'), (150, 'wide'), (80, 'compact')]) { + c.settings + ..explorerLayout = layout + ..explorerStyle = style + ..explorerTextScale = scale + ..explorerRowSpacing = spacing; + tester.view.physicalSize = const Size(412, 900); + tester.view.devicePixelRatio = 1; + await tester.runAsync(() => tester.pumpWidget(MaterialApp( + theme: buildTheme(), + home: RepaintBoundary(key: key, child: ExplorerPage(c: c)), + ))); + for (var i = 0; i < 12; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 250))); + await tester.pump(); + } + expect(tester.takeException(), isNull, reason: '$layout $style $scale% $spacing 넘치지 않음'); + final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await tester.runAsync(() => boundary.toImage()); + final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); + File('$out/explorer_text_${layout}_${style}_${scale}_$spacing.png').writeAsBytesSync(bytes!.buffer.asUint8List()); + await tester.pumpWidget(const SizedBox()); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 300))); + } + } + } + tester.view.reset(); + }); +} diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 1b828e4..e2ae8f1 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -418,6 +418,14 @@ class AppSettings { /// 다시 · 두 번 눌러도 아래 줄이 움직이지 않게) 와 부딪혀 설정으로만. 켜도 두 번 누르기의 두 번째는 접지 않는다 bool explorerRetapCollapse = false; + /// X-plore 비교 40 (관리자 지시 10/20): 파일 탐색기 목록만의 글자 크기 (%, 80~150, 처음 100 = 지금 그대로). + /// 앱 화면 크기 · 기기 글자 크기와 곱해진다 + int explorerTextScale = 100; + static const explorerTextScaleMin = 80, explorerTextScaleMax = 150; + + /// X-plore 비교 40: 목록 줄 간격 'compact' 좁게 · 'normal' 보통 (처음 값 = 지금 그대로) · 'wide' 넓게 + String explorerRowSpacing = 'normal'; + /// X-plore 비교 i: 일괄 이름 바꾸기의 마지막 규칙 (다음에 창이 그 값으로 열림, 비면 처음 값) Map batchRenameLast = {}; @@ -832,6 +840,8 @@ class AppSettings { 'explorerLayout': explorerLayout, 'explorerOnePaneCopy': explorerOnePaneCopy, 'explorerRetapCollapse': explorerRetapCollapse, + 'explorerTextScale': explorerTextScale, + 'explorerRowSpacing': explorerRowSpacing, 'batchRenameLast': batchRenameLast, 'explorerRootsInTree': explorerRootsInTree, 'explorerAlwaysMarks': explorerAlwaysMarks, @@ -1063,6 +1073,11 @@ class AppSettings { // 'auto' = 넓은 화면은 두 창, 좁은 화면 (폰 세로) 은 한 창. 예전 기본 'dual' 은 한 번 'auto' 로 ..explorerOnePaneCopy = j['explorerOnePaneCopy'] == 'other' ? 'other' : 'clip' ..explorerRetapCollapse = j['explorerRetapCollapse'] == true + // 값이 없거나 틀리면 지금 그대로 (100% · 보통), 범위 밖은 끝으로 + ..explorerTextScale = j['explorerTextScale'] is num + ? (j['explorerTextScale'] as num).round().clamp(explorerTextScaleMin, explorerTextScaleMax) + : 100 + ..explorerRowSpacing = const {'compact', 'normal', 'wide'}.contains(j['explorerRowSpacing']) ? j['explorerRowSpacing'] as String : 'normal' ..batchRenameLast = j['batchRenameLast'] is Map ? Map.from(j['batchRenameLast'] as Map) : {} ..explorerRootsInTree = j['explorerRootsInTree'] != false ..explorerAlwaysMarks = j['explorerAlwaysMarks'] != false diff --git a/lib/ui/explorer_look.dart b/lib/ui/explorer_look.dart index 6259118..525c0dc 100644 --- a/lib/ui/explorer_look.dart +++ b/lib/ui/explorer_look.dart @@ -22,10 +22,11 @@ enum ExplorerStyle { double get rowHeight => switch (this) { xplore => 52, windows => 34, totalcmd => 26 }; - /// 253: 줄 높이 - 기기 글자 크기를 키우면 그만큼 (고정 높이라 1.6배에서 이름 · 크기 두 줄이 넘쳤다) - double rowHeightOf(BuildContext context) { - final s = MediaQuery.textScalerOf(context).scale(fontSize) / fontSize; - return s <= 1 ? rowHeight : (rowHeight * s).roundToDouble(); + /// 253: 줄 높이 - 기기 글자 크기를 키우면 그만큼 (고정 높이라 1.6배에서 이름 · 크기 두 줄이 넘쳤다). + /// X-plore 비교 40: [textScale] 목록 글자 크기 (기기 글자 크기와 곱함) · [spacing] 줄 간격 ([rowSpacingFactor]) + double rowHeightOf(BuildContext context, {double textScale = 1, double spacing = 1}) { + final s = MediaQuery.textScalerOf(context).scale(fontSize) * textScale / fontSize; + return ((s <= 1 ? rowHeight : rowHeight * s) * spacing).roundToDouble(); } double get iconSize => switch (this) { xplore => 30, windows => 20, totalcmd => 16 }; double get indent => switch (this) { xplore => 16, windows => 14, totalcmd => 12 }; @@ -48,6 +49,35 @@ const _sheets = {'xls', 'xlsx', 'ods'}; const _slides = {'ppt', 'pptx', 'odp'}; const _programs = {'exe', 'msi', 'bat', 'cmd', 'ps1', 'apk'}; +/// X-plore 비교 40: 줄 간격 설정 → 줄 높이 배수 (좁게 · 보통 (지금 그대로) · 넓게) +double rowSpacingFactor(String spacing) => switch (spacing) { 'compact' => 0.8, 'wide' => 1.25, _ => 1.0 }; + +/// X-plore 비교 40: 목록 글자만 [factor] 배 (앱 화면 크기 · 기기 글자 크기에 곱함). 1 이면 그대로 +Widget scaleListText(BuildContext context, double factor, Widget child) { + if (factor == 1) return child; + final mq = MediaQuery.of(context); + return MediaQuery(data: mq.copyWith(textScaler: _TimesScaler(mq.textScaler, factor)), child: child); +} + +class _TimesScaler extends TextScaler { + final TextScaler base; + final double factor; + const _TimesScaler(this.base, this.factor); + + @override + double scale(double fontSize) => base.scale(fontSize) * factor; + + // ignore: deprecated_member_use + @override + double get textScaleFactor => base.scale(14) / 14 * factor; + + @override + bool operator ==(Object other) => other is _TimesScaler && other.base == base && other.factor == factor; + + @override + int get hashCode => Object.hash(base, factor); +} + /// 파일 종류별 아이콘 · 색 (스타일마다 다르게) (IconData, Color) fileIcon(ExplorerStyle style, FileEntry e, {bool open = false}) { final x = e.ext; diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 0e62a08..2400dbf 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:io'; +import 'dart:math' as math; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart' show defaultTargetPlatform; @@ -1076,7 +1077,7 @@ class _ExplorerPageState extends State { final pos = pane.scroll.position; // 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다. // 넘치면 다음 배치 때 실제 끝으로 맞춰진다 - pane.scroll.jumpTo((i * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity)); + pane.scroll.jumpTo((i * _rowH() - pos.viewportDimension / 4).clamp(0.0, double.infinity)); } /// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때) @@ -1145,6 +1146,10 @@ class _ExplorerPageState extends State { /// 지금 모양 (환경 설정 > 파일 탐색기 > 스타일) ExplorerStyle get _look => ExplorerStyle.of(c.settings.explorerStyle); + /// X-plore 비교 40: 목록 글자 크기 (설정 %) · 줄 간격을 넣은 줄 높이 + double get _listTextScale => c.settings.explorerTextScale / 100; + double _rowH() => _look.rowHeightOf(context, textScale: _listTextScale, spacing: rowSpacingFactor(c.settings.explorerRowSpacing)); + /// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치 bool get _split => !widget.rsync && c.settings.explorerLayout == 'split'; @@ -4916,16 +4921,16 @@ class _ExplorerPageState extends State { ), Expanded( child: Stack(children: [ - ListView.builder( + scaleListText(context, _listTextScale, ListView.builder( controller: pane.scroll, itemCount: rows.length, - itemExtent: look.rowHeightOf(context), + itemExtent: _rowH(), // 76: 줄 위젯은 항목을 따라간다 (아래 [_rowKey]) itemBuilder: (_, k) => KeyedSubtree( key: _rowKey(rows[k]), child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : _rowView(pane, rows[k], compact: foldersOnly)), findChildIndexCallback: _rowIndexOf(rows), - ), + )), // 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다) if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)), ]), @@ -4985,14 +4990,14 @@ class _ExplorerPageState extends State { ? const Center(child: CircularProgressIndicator()) : entries.isEmpty && !up ? Center(child: Text(tr('비어 있음'), style: const TextStyle(color: JjColors.textDim))) - : ListView.builder( + : scaleListText(context, _listTextScale, ListView.builder( controller: pane.listScroll, itemCount: rows.length, - itemExtent: look.rowHeightOf(context), + itemExtent: _rowH(), itemBuilder: (_, k) => KeyedSubtree( key: _rowKey(rows[k]), child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : _rowView(pane, rows[k], list: true)), findChildIndexCallback: _rowIndexOf(rows), - ), + )), ), if (_selecting) _marksBar(pane, listDir: dir), ])); @@ -5495,7 +5500,7 @@ class _ExplorerPageState extends State { // 손가락으로 누르는 기기 (Android) 는 폭도 넓게 Widget chevron() => SizedBox( width: chevronWidth, - height: look.rowHeightOf(context), + height: _rowH(), child: (e.isDir || _canBrowseArchive(e)) && !list && !row.isUp ? (loading ? const Center( @@ -5521,15 +5526,19 @@ class _ExplorerPageState extends State { Widget nameCell() => LayoutBuilder(builder: (context, box) { final fixed = (list ? 0 : chevronWidth) + (look.rich ? 64 : look.iconSize + 12) + 48.0; final indent = list ? 0.0 : (row.depth * look.indent).clamp(0.0, (box.maxWidth - fixed).clamp(0.0, double.infinity)); + // 아주 좁은 창 (412 폭 "폴더 + 파일 목록" 의 트리, 77px 쯤): 아이콘 칸 · 틈을 남은 폭만큼만 (아이콘은 줄여 그림) - 넘치지 않게 + final room = (box.maxWidth - indent - (list ? 0 : chevronWidth)).clamp(0.0, double.infinity); + final iconW = math.min(look.rich ? 56.0 : look.iconSize + 6, room); + final gapW = math.min(look.rich ? 8.0 : 6.0, room - iconW); return Row(children: [ SizedBox(width: indent), if (!list) chevron(), SizedBox( - width: look.rich ? 56 : look.iconSize + 6, - height: look.rowHeightOf(context) - 8, - child: Center(child: _icon(e, row.isRoot, open, up: row.isUp)), + width: iconW, + height: _rowH() - 8, + child: Center(child: FittedBox(fit: BoxFit.scaleDown, child: _icon(e, row.isRoot, open, up: row.isUp))), ), - SizedBox(width: look.rich ? 8 : 6), + SizedBox(width: gapW), Expanded( child: look.rich && !e.isDir && !row.isUp ? Column( diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index fb538df..7778aa0 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -896,6 +896,34 @@ class _SettingsPageState extends State { value: s.explorerRetapCollapse, onChanged: (v) => c.updateSettings((x) => x.explorerRetapCollapse = v), ), + // X-plore 비교 40 (관리자 지시 10/20): 목록만의 글자 크기 · 줄 간격 (세 스타일 모두, 앱 화면 크기 · 기기 글자 크기와 곱함) + SettingTile( + title: Text(tr('목록 글자 크기')), + subtitle: Text(tr('파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다')), + trailing: DropdownButton( + value: s.explorerTextScale, + items: [ + for (var v = AppSettings.explorerTextScaleMin; v <= AppSettings.explorerTextScaleMax; v += 10) + DropdownMenuItem(value: v, child: Text(v == 100 ? trf('{0}% (기본)', [v]) : '$v%')), + if (s.explorerTextScale % 10 != 0) + DropdownMenuItem(value: s.explorerTextScale, child: Text('${s.explorerTextScale}%')), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerTextScale = v!), + ), + ), + SettingTile( + title: Text(tr('줄 간격')), + subtitle: Text(tr('파일 탐색기 목록의 줄 높이')), + trailing: DropdownButton( + value: s.explorerRowSpacing, + items: [ + DropdownMenuItem(value: 'compact', child: Text(tr('좁게'))), + DropdownMenuItem(value: 'normal', child: Text(tr('보통 (기본)'))), + DropdownMenuItem(value: 'wide', child: Text(tr('넓게'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerRowSpacing = v!), + ), + ), // X-plore 숨기기: 목록에서 숨긴 것 보기 · 풀기 (관리자 결정 10/18) ListTile( title: Text(trf('목록에서 숨긴 항목 ({0}개)', [s.explorerHiddenItems.length])), diff --git a/test/explorer_text_scale_test.dart b/test/explorer_text_scale_test.dart new file mode 100644 index 0000000..2c2a70f --- /dev/null +++ b/test/explorer_text_scale_test.dart @@ -0,0 +1,110 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 40 (관리자 지시 10/20): 파일 탐색기 목록만의 글자 크기 (80~150%) · 줄 간격 (좁게 · 보통 · 넓게) +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_textscale_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('설정: 처음은 100% · 보통 (지금 그대로), 저장 · 읽기, 틀린 값은 그대로 쪽으로 · 범위 밖은 끝으로', () { + final s = AppSettings(); + expect((s.explorerTextScale, s.explorerRowSpacing), (100, 'normal')); + s + ..explorerTextScale = 130 + ..explorerRowSpacing = 'wide'; + final back = AppSettings.fromJson(s.toJson()); + expect((back.explorerTextScale, back.explorerRowSpacing), (130, 'wide')); + expect(AppSettings.fromJson({}).explorerTextScale, 100); + expect(AppSettings.fromJson({'explorerTextScale': 'x', 'explorerRowSpacing': 'huge'}).explorerRowSpacing, 'normal'); + expect(AppSettings.fromJson({'explorerTextScale': 300}).explorerTextScale, 150); + expect(AppSettings.fromJson({'explorerTextScale': 10}).explorerTextScale, 80); + }); + + /// [name] 글자의 실제 글자 크기 (기기 · 목록 글자 크기를 곱한 것) + double shownSize(WidgetTester tester, String name) { + final para = tester.renderObject(find.text(name).last); + final size = para.text.style?.fontSize ?? 14; + return para.textScaler.scale(size); + } + + /// 이름 줄 높이 + double rowHeight(WidgetTester tester, String name) => + tester.getSize(find.ancestor(of: find.text(name).last, matching: find.byType(KeyedSubtree)).first).height; + + Future open(WidgetTester tester, String style, String wait, + {double device = 1, int scale = 100, String spacing = 'normal'}) async { + const pp = MethodChannel('plugins.flutter.io/path_provider'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, (_) async => tmp.path); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, null)); + File(p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 글자 크기 시험용 2026 최종본.mp4')).writeAsStringSync('v' * 2000); + File(p.join(tmp.path, 'a.txt')).writeAsStringSync('a'); + Directory(p.join(tmp.path, 'sub')).createSync(); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..explorerPaths = [tmp.path, tmp.path] + // 폴더 + 파일 목록: 오른쪽 목록은 그 폴더의 것만이라 짧다 (트리는 Temp 의 다른 폴더가 많아 아래로 밀린다) + ..explorerLayout = 'split' + ..explorerStyle = style + ..explorerTextScale = scale + ..explorerRowSpacing = spacing; + await tester.runAsync(() => tester.pumpWidget(MaterialApp( + builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(device)), child: child!), + home: ExplorerPage(c: c)))); + // 목록을 다 읽을 때까지 (다른 시험과 함께 돌면 느리다 - 10초까지) + for (var i = 0; i < 200 && (i < 15 || find.text(wait).evaluate().isEmpty); i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(); + } + return c; + } + + Future close(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + } + + for (final style in ['xplore', 'windows', 'totalcmd']) { + final a = style == 'totalcmd' ? 'a' : 'a.txt'; + testWidgets('40: $style - 처음 값 (100% · 보통) 은 지금 그대로 · 150% · 넓게는 글자 1.5배 · 줄 높이 늘어남 · 412 폭에서 넘치지 않음', (tester) async { + tester.view.physicalSize = const Size(412, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await open(tester, style, a); + expect(tester.takeException(), isNull); + final base = shownSize(tester, a), baseH = rowHeight(tester, a); + expect(baseH, {'xplore': 52.0, 'windows': 34.0, 'totalcmd': 26.0}[style], reason: '처음 값은 예전 줄 높이 그대로'); + await close(tester); + + await open(tester, style, a, scale: 150, spacing: 'wide'); + expect(tester.takeException(), isNull, reason: '412 폭 · 150% · 넓게에서 넘치지 않는다'); + expect(shownSize(tester, a), closeTo(base * 1.5, 0.01)); + expect(rowHeight(tester, a), (baseH * 1.5 * 1.25).roundToDouble()); + await close(tester); + + // 기기 글자 크기 1.3 과 곱해진다 · 좁게 + await open(tester, style, a, device: 1.3, scale: 120, spacing: 'compact'); + expect(tester.takeException(), isNull); + expect(shownSize(tester, a), closeTo(base * 1.3 * 1.2, 0.01)); + expect(rowHeight(tester, a), (baseH * 1.3 * 1.2 * 0.8).roundToDouble()); + await close(tester); + + // 80% 는 글자만 작게 (줄은 예전 높이 - 누르기 크기를 줄이지 않음) + await open(tester, style, a, scale: 80); + expect(shownSize(tester, a), closeTo(base * 0.8, 0.01)); + expect(rowHeight(tester, a), baseH); + await close(tester); + }); + } +}