diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 2b5ed6d..815e0d3 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -65,6 +65,7 @@ "(받는 쪽이 더 새것 - 건너뜀)": "(receiving side is newer - skipped)", "(비어 있음)": "(empty)", "(설명 없음)": "(no description)", + "(없음)": "(none)", "(지워짐)": "(deleted)", "(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)": "(folder and the {0} items inside are deleted and replaced by the file of the same name)", "(현재 방식은 옵션 없음)": "(no options for the built-in method)", @@ -201,6 +202,7 @@ "Edge 에서 가져옴": "Imported from Edge", "Edge 엔진으로 바꾸기": "Switch to the Edge engine", "Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\nChrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 (Google 로그인은 막힐 수 있음).{1}{2}": "Edge: part of Windows, nothing to install. If you sign in to YouTube here, downloads use that sign-in too.\nChrome: if chosen, about {0}MB is downloaded and it can be used after a restart (Google sign-in may be blocked).{1}{2}", + "Esc 는 선택 · 빠른 필터를 닫는 키라 쓸 수 없습니다": "Esc closes selection and the quick filter, so it cannot be used", "FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다": "Downloads and installs FFmpeg · yt-dlp · aria2 · Deno if missing", "FFmpeg 준비됨: {0}{1}": "FFmpeg ready: {0}{1}", "FTP 서버 \"{0}\" 을(를) 지울까요?": "Delete FTP server \"{0}\"?", @@ -498,6 +500,7 @@ "{0} (기본): {1}": "{0} (default): {1}", "{0} (동영상 {1})": "{0} (videos: {1})", "{0} - {1} (약 {2}MB)": "{0} - {1} (about {2}MB)", + "{0} - 단축키": "{0} - shortcut", "{0} / {1} 읽음": "{0} / {1} read", "{0} / {1} 확인": "{0} / {1} checked", "{0} / {1}장": "{0} / {1} images", @@ -993,6 +996,7 @@ "다른 창에서 열기": "Open in other pane", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "When you drop onto the other pane. Either way, the copy/move confirmation (same names etc.) still appears", "다른 창으로 복사": "Copy to other pane", + "다른 창으로 옮겨 가기": "Switch to the other pane", "다른 창으로 이동": "Move to other pane", "다른 폴더": "Other folder", "다른 폴더로 이동": "Move to another folder", @@ -1050,6 +1054,7 @@ "단계": "Steps", "단어 첫 글자만 크게 (Abc)": "Capitalize words (Abc)", "단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. 그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침": "Shortcuts - Total Commander: F5 copy · F6 move · F7 new folder · F8 delete · Insert/Space mark · Ctrl+R refresh. Others: Ctrl+C · X · V (copy · cut · paste) · Ctrl+A mark all · F5 refresh", + "단축키 없음": "No shortcut", "단축키 해제": "Unregister hotkey", "닫기": "Close", "닫힌 문서입니다": "The document is closed", @@ -1262,10 +1267,12 @@ "모두 작게 (abc)": "lowercase (abc)", "모두 종료": "Quit all", "모두 지우기": "Clear all", + "모두 처음 값으로": "Reset all", "모두 초기화": "Reset all", "모두 취소 ({0}개)": "Cancel all ({0})", "모두 취소 (대기 {0}개 포함)": "Cancel all (including {0} waiting)", "모두 크게 (ABC)": "UPPERCASE (ABC)", + "모두 표시": "Mark all", "모두 풀기": "Extract all", "모든 작업 취소": "Cancel all jobs", "목": "Thu", @@ -1329,6 +1336,7 @@ "바꾸면 바로 저장됩니다": "Saved automatically", "바꾸지 못했습니다: {0}": "Could not change it: {0}", "바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)": "Couldn't save the converted PDF (check storage space)", + "바꾼 것 {0}개": "{0} changed", "바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)", "바꿀 글": "Replace with", "바꿀 글 (비우면 지움)": "Replace with (empty = remove)", @@ -1543,6 +1551,7 @@ "빈 폴더입니다": "This folder is empty", "빠르게": "Fast", "빠른 디코더 (TAESD)": "Fast decoder (TAESD)", + "빠른 보기": "Quick look", "빠른 필터": "Quick filter", "빼고 싶은 것 (선택)": "What to avoid (optional)", "사라지는 것": "What is lost", @@ -1703,6 +1712,7 @@ "쉬는 중": "Idle", "스웨덴어": "Swedish", "스타일": "Style", + "스타일대로 (바꾼 것 없음)": "As the style (nothing changed)", "스페인어": "Spanish", "시간 지정 (cron)": "Scheduled (cron)", "시간 형식 오류 {0}곳": "{0} time format errors", @@ -1765,6 +1775,7 @@ "쓸 수 없는 글자 (:)": "Invalid character (:)", "쓸 수 없는 이름": "Invalid name", "쓸 수 없는 이름입니다": "This name cannot be used", + "쓸 키를 누르세요 (Ctrl · Shift · Alt 와 함께 눌러도 됩니다)": "Press the key to use (you can combine it with Ctrl, Shift or Alt)", "아랍어": "Arabic", "아래 갤러리 줄 보이기": "Show the gallery strip", "아래 갤러리 줄 숨기기": "Hide the gallery strip", @@ -1897,6 +1908,7 @@ "열어 본 때: {0}": "Accessed: {0}", "영구 삭제": "Delete permanently", "영구 삭제 ({0}개, 되돌릴 수 없음)": "Delete permanently ({0}, cannot be undone)", + "영구 삭제 (휴지통을 거치지 않음)": "Delete permanently (skip the trash)", "영구 삭제하면 되살릴 수 없습니다.": "Permanently deleted items can't be restored.", "영상": "Video", "영상 {0}": "Video {0}", @@ -2198,6 +2210,7 @@ "이름을 바꿔 풂 {0}개": "{0} extracted with new names", "이름을 찾을 수 없습니다: {0} (DNS {1})": "Name not found: {0} (DNS {1})", "이름이 너무 김 (255자 넘음)": "Name too long (over 255 characters)", + "이미 \"{0}\" 에 쓰는 키입니다 - 저장하면 그 동작의 이 키는 없어집니다": "This key is already used for \"{0}\" - if you save, that action loses this key", "이미 그리는 중입니다": "Already drawing", "이미 동영상 목록에 있습니다 ({0}개).": "Already in the video list ({0}).", "이미 만든 MKV 가 있습니다 ({0}개)": "Some MKVs already exist ({0})", @@ -2498,6 +2511,7 @@ "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "{0} for the video you are viewing (check several in the list)", "지금 설정 그대로": "Keep current settings", "지금 설치": "Install now", + "지금 스타일 ({0}) 의 키입니다. 바꾼 키는 모든 스타일에 쓰이고, 다른 동작과 겹치면 그 동작의 키는 없어집니다. Esc 는 바꿀 수 없습니다": "Keys of the current style ({0}). Changed keys apply to all styles; if one clashes with another action, that action loses the key. Esc cannot be changed", "지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.": "It is running now. Stop it and move to lsync (live sync)? lsync will sync the rest.", "지금 쓰는 버전: v{0}": "Current version: v{0}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}", @@ -2592,6 +2606,7 @@ "채우기 (가운데 잘라냄)": "Fill (crop the center)", "처리 장치": "Processor", "처음 · 🏠 주소 (기본: Tor 연결 확인 페이지)": "Start / 🏠 address (default: the Tor connection check page)", + "처음 값으로": "Reset", "처음 보는 서버 인증서입니다": "This server certificate is new", "처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요": "This server certificate is new (self-signed). Check the fingerprint, then decide whether to trust it", "처음 사용 시 내려받기 {0}": "Downloads {0} on first use", @@ -2724,6 +2739,7 @@ "파일 탐색기 (지금 여기)": "File explorer (you are here)", "파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 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 shortcuts", "파일 탐색기 두 창 배치: 화면 모양 따라 → 좌우. 되돌리기: 파일 탐색기의 [좌우 ⇆ / 위아래 ⇅] 버튼 또는 창 배치": "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. Compact rows may be hard to tap with a finger", @@ -2808,10 +2824,12 @@ "폴더만 lsync 로 옮길 수 있습니다.": "Only folders can be moved to lsync.", "폴더와 안의 것 전체 (기본)": "The folder and everything inside (default)", "폴란드어": "Polish", + "표시": "Mark", "표시 (여러 개 고르기)": "Mark (select several)", "표시 이름": "Display name", "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "You can edit display names right here. Pressing a video row three times moves it to the first button's folder", "표시 지우기": "Clear marks", + "표시하고 다음 줄": "Mark and move down", "표준": "Standard", "표준 입력 (-) 은 쓸 수 없습니다": "Standard input (-) cannot be used", "푸는 중": "Extracting", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 92d8ccf..f92945a 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -65,6 +65,7 @@ "(받는 쪽이 더 새것 - 건너뜀)": "(受け取る側が新しい - スキップ)", "(비어 있음)": "(空)", "(설명 없음)": "(説明なし)", + "(없음)": "(なし)", "(지워짐)": "(削除)", "(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)": "(フォルダーと中の {0} 件を削除して同名のファイルに)", "(현재 방식은 옵션 없음)": "(現在の方式にはオプションなし)", @@ -201,6 +202,7 @@ "Edge 에서 가져옴": "Edge から取り込み", "Edge 엔진으로 바꾸기": "Edge エンジンに切り替える", "Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\nChrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 (Google 로그인은 막힐 수 있음).{1}{2}": "Edge: Windows に含まれているので別途インストール不要です。ここで YouTube にログインすると、ダウンロードにもそのログインが使われます。\nChrome: 選ぶと約 {0}MB をダウンロードし、再起動後に使えます(Google ログインはブロックされることがあります)。{1}{2}", + "Esc 는 선택 · 빠른 필터를 닫는 키라 쓸 수 없습니다": "Esc は選択・クイックフィルターを閉じるキーのため使えません", "FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다": "FFmpeg・yt-dlp・aria2・Deno がなければダウンロードしてインストールします", "FFmpeg 준비됨: {0}{1}": "FFmpeg 準備完了: {0}{1}", "FTP 서버 \"{0}\" 을(를) 지울까요?": "FTP サーバー「{0}」を削除しますか?", @@ -498,6 +500,7 @@ "{0} (기본): {1}": "{0}(既定): {1}", "{0} (동영상 {1})": "{0} (動画 {1})", "{0} - {1} (약 {2}MB)": "{0} - {1}(約 {2}MB)", + "{0} - 단축키": "{0} - ショートカット", "{0} / {1} 읽음": "{0} / {1} 読み込み済み", "{0} / {1} 확인": "{0} / {1} 確認", "{0} / {1}장": "{0} / {1} 枚", @@ -993,6 +996,7 @@ "다른 창에서 열기": "もう一方のペインで開く", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます", "다른 창으로 복사": "もう一方のペインへコピー", + "다른 창으로 옮겨 가기": "もう一方のペインへ移動", "다른 창으로 이동": "もう一方のペインへ移動", "다른 폴더": "別のフォルダー", "다른 폴더로 이동": "他のフォルダーに移動", @@ -1050,6 +1054,7 @@ "단계": "ステップ", "단어 첫 글자만 크게 (Abc)": "単語の先頭だけ大文字 (Abc)", "단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. 그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침": "ショートカット - Total Commander: F5 コピー · F6 移動 · F7 新しいフォルダー · F8 削除 · Insert/Space マーク · Ctrl+R 更新。その他: Ctrl+C · X · V (コピー · 切り取り · 貼り付け) · Ctrl+A すべてマーク · F5 更新", + "단축키 없음": "ショートカットなし", "단축키 해제": "ショートカット解除", "닫기": "閉じる", "닫힌 문서입니다": "文書は閉じられています", @@ -1262,10 +1267,12 @@ "모두 작게 (abc)": "すべて小文字 (abc)", "모두 종료": "すべて終了", "모두 지우기": "すべて削除", + "모두 처음 값으로": "すべて既定に戻す", "모두 초기화": "すべてリセット", "모두 취소 ({0}개)": "すべてキャンセル ({0} 件)", "모두 취소 (대기 {0}개 포함)": "すべてキャンセル (待機 {0} 件を含む)", "모두 크게 (ABC)": "すべて大文字 (ABC)", + "모두 표시": "すべて選択", "모두 풀기": "すべて展開", "모든 작업 취소": "すべての作業をキャンセル", "목": "木", @@ -1329,6 +1336,7 @@ "바꾸면 바로 저장됩니다": "変更するとすぐ保存されます", "바꾸지 못했습니다: {0}": "変更できませんでした: {0}", "바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)": "変換した PDF を保存できませんでした (空き容量を確認してください)", + "바꾼 것 {0}개": "{0} 件変更", "바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)", "바꿀 글": "置き換える文字", "바꿀 글 (비우면 지움)": "置き換える文字 (空なら削除)", @@ -1543,6 +1551,7 @@ "빈 폴더입니다": "空のフォルダーです", "빠르게": "速く", "빠른 디코더 (TAESD)": "高速デコーダー (TAESD)", + "빠른 보기": "クイックルック", "빠른 필터": "クイックフィルター", "빼고 싶은 것 (선택)": "避けたいもの (任意)", "사라지는 것": "消えるもの", @@ -1703,6 +1712,7 @@ "쉬는 중": "休止中", "스웨덴어": "スウェーデン語", "스타일": "スタイル", + "스타일대로 (바꾼 것 없음)": "スタイルどおり (変更なし)", "스페인어": "スペイン語", "시간 지정 (cron)": "時間指定 (cron)", "시간 형식 오류 {0}곳": "時間形式エラー {0}か所", @@ -1765,6 +1775,7 @@ "쓸 수 없는 글자 (:)": "使えない文字 (:)", "쓸 수 없는 이름": "使えない名前", "쓸 수 없는 이름입니다": "使用できない名前です", + "쓸 키를 누르세요 (Ctrl · Shift · Alt 와 함께 눌러도 됩니다)": "使うキーを押してください (Ctrl・Shift・Alt と組み合わせても構いません)", "아랍어": "アラビア語", "아래 갤러리 줄 보이기": "下のギャラリー列を表示", "아래 갤러리 줄 숨기기": "下のギャラリー列を隠す", @@ -1897,6 +1908,7 @@ "열어 본 때: {0}": "アクセス日時: {0}", "영구 삭제": "完全に削除", "영구 삭제 ({0}개, 되돌릴 수 없음)": "完全に削除 ({0} 件、元に戻せません)", + "영구 삭제 (휴지통을 거치지 않음)": "完全に削除 (ごみ箱を経由しない)", "영구 삭제하면 되살릴 수 없습니다.": "完全に削除すると元に戻せません。", "영상": "映像", "영상 {0}": "映像 {0}", @@ -2198,6 +2210,7 @@ "이름을 바꿔 풂 {0}개": "名前を変えて展開 {0} 件", "이름을 찾을 수 없습니다: {0} (DNS {1})": "名前が見つかりません: {0} (DNS {1})", "이름이 너무 김 (255자 넘음)": "名前が長すぎます (255 文字超)", + "이미 \"{0}\" 에 쓰는 키입니다 - 저장하면 그 동작의 이 키는 없어집니다": "このキーはすでに「{0}」に使われています - 保存するとその操作のこのキーはなくなります", "이미 그리는 중입니다": "すでに描画中です", "이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。", "이미 만든 MKV 가 있습니다 ({0}개)": "作成済みの MKV があります ({0}件)", @@ -2498,6 +2511,7 @@ "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "今見ている動画の{0}(複数はリストでチェック)", "지금 설정 그대로": "今の設定のまま", "지금 설치": "今すぐインストール", + "지금 스타일 ({0}) 의 키입니다. 바꾼 키는 모든 스타일에 쓰이고, 다른 동작과 겹치면 그 동작의 키는 없어집니다. Esc 는 바꿀 수 없습니다": "現在のスタイル ({0}) のキーです。変更したキーはすべてのスタイルに使われ、他の操作と重なるとその操作のキーはなくなります。Esc は変更できません", "지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.": "実行中です。止めて lsync (リアルタイム同期) へ移しますか? 残りは lsync が続けて同期します。", "지금 쓰는 버전: v{0}": "使用中のバージョン: v{0}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}", @@ -2592,6 +2606,7 @@ "채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)", "처리 장치": "処理装置", "처음 · 🏠 주소 (기본: Tor 연결 확인 페이지)": "最初・🏠 のアドレス (既定: Tor 接続確認ページ)", + "처음 값으로": "既定に戻す", "처음 보는 서버 인증서입니다": "初めて見るサーバー証明書です", "처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요": "初めて見るサーバー証明書です (自己署名)。指紋を確認してから信頼するか決めてください", "처음 사용 시 내려받기 {0}": "初回使用時にダウンロード {0}", @@ -2724,6 +2739,7 @@ "파일 탐색기 (지금 여기)": "ファイルエクスプローラー (現在地)", "파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "ファイルエクスプローラー中央に [モニタリング] ボタン: コピー · 移動を記憶して進捗 · 対象ディスク容量 · オプションを表示し、オプションを変えて再実行 · 再開、または lsync へ移動します (コピー · rsync と lsync は別々)。", "파일 탐색기 누르기: 한 번 누르면 선택 → 한 번 누르면 바로 실행. 되돌리기: 환경 설정 > 파일 탐색기 > 누르기": "ファイルエクスプローラーのタップ: 1 回で選択 → 1 回ですぐ開く。元に戻す: 環境設定 > ファイルエクスプローラー > タップ", + "파일 탐색기 단축키": "ファイルエクスプローラーのショートカット", "파일 탐색기 두 창 배치: 화면 모양 따라 → 좌우. 되돌리기: 파일 탐색기의 [좌우 ⇆ / 위아래 ⇅] 버튼 또는 창 배치": "ファイルエクスプローラーの 2 画面配置: 画面の形に合わせる → 左右。元に戻す: ファイルエクスプローラーの [左右 ⇆ / 上下 ⇅] ボタンまたは画面配置", "파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다": "ファイルエクスプローラー一覧の名前・サイズの文字のみ。アプリの画面サイズ・端末の文字サイズと掛け合わされます", "파일 탐색기 목록의 줄 높이. 좁게는 손가락으로 누르기 어려울 수 있습니다": "ファイルエクスプローラー一覧の行の高さ。狭くすると指でタップしにくい場合があります", @@ -2808,10 +2824,12 @@ "폴더만 lsync 로 옮길 수 있습니다.": "lsync へ移せるのはフォルダーだけです。", "폴더와 안의 것 전체 (기본)": "フォルダーと中身すべて (既定)", "폴란드어": "ポーランド語", + "표시": "選択", "표시 (여러 개 고르기)": "マーク (複数選択)", "표시 이름": "表示名", "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "表示名はその場で変更できます。動画の行を 3回押すと最初のボタンのフォルダーに移動します", "표시 지우기": "マークを解除", + "표시하고 다음 줄": "選択して次の行へ", "표준": "標準", "표준 입력 (-) 은 쓸 수 없습니다": "標準入力 (-) は使えません", "푸는 중": "展開中", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 8c730d6..fe22ff2 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -65,6 +65,7 @@ "(받는 쪽이 더 새것 - 건너뜀)": "(接收端较新 - 已跳过)", "(비어 있음)": "(空)", "(설명 없음)": "(无说明)", + "(없음)": "(无)", "(지워짐)": "(将删除)", "(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)": "(删除文件夹及其中 {0} 项,换成同名文件)", "(현재 방식은 옵션 없음)": "(当前方式没有选项)", @@ -201,6 +202,7 @@ "Edge 에서 가져옴": "从 Edge 导入", "Edge 엔진으로 바꾸기": "切换到 Edge 引擎", "Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\nChrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 (Google 로그인은 막힐 수 있음).{1}{2}": "Edge: Windows 自带,无需另外安装。在此登录 YouTube 后,下载也会使用该登录。\nChrome: 选择后会下载约 {0}MB,重新启动后可用(Google 登录可能被阻止)。{1}{2}", + "Esc 는 선택 · 빠른 필터를 닫는 키라 쓸 수 없습니다": "Esc 用于关闭选择和快速筛选,不能使用", "FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다": "如缺少 FFmpeg · yt-dlp · aria2 · Deno,将下载并安装", "FFmpeg 준비됨: {0}{1}": "FFmpeg 已就绪: {0}{1}", "FTP 서버 \"{0}\" 을(를) 지울까요?": "删除 FTP 服务器\"{0}\"?", @@ -498,6 +500,7 @@ "{0} (기본): {1}": "{0}(默认): {1}", "{0} (동영상 {1})": "{0}(视频 {1})", "{0} - {1} (약 {2}MB)": "{0} - {1}(约 {2}MB)", + "{0} - 단축키": "{0} - 快捷键", "{0} / {1} 읽음": "已读取 {0} / {1}", "{0} / {1} 확인": "已检查 {0} / {1}", "{0} / {1}장": "{0} / {1} 张", @@ -993,6 +996,7 @@ "다른 창에서 열기": "在另一窗格中打开", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)", "다른 창으로 복사": "复制到另一窗格", + "다른 창으로 옮겨 가기": "切换到另一窗格", "다른 창으로 이동": "移动到另一窗格", "다른 폴더": "其他文件夹", "다른 폴더로 이동": "移动到其他文件夹", @@ -1050,6 +1054,7 @@ "단계": "步数", "단어 첫 글자만 크게 (Abc)": "单词首字母大写 (Abc)", "단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. 그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침": "快捷键 - Total Commander: F5 复制 · F6 移动 · F7 新建文件夹 · F8 删除 · Insert/Space 标记 · Ctrl+R 刷新。其他: Ctrl+C · X · V (复制 · 剪切 · 粘贴) · Ctrl+A 全部标记 · F5 刷新", + "단축키 없음": "无快捷键", "단축키 해제": "注销快捷键", "닫기": "关闭", "닫힌 문서입니다": "文档已关闭", @@ -1262,10 +1267,12 @@ "모두 작게 (abc)": "全部小写 (abc)", "모두 종료": "全部退出", "모두 지우기": "全部清除", + "모두 처음 값으로": "全部恢复默认", "모두 초기화": "全部重置", "모두 취소 ({0}개)": "全部取消({0} 个)", "모두 취소 (대기 {0}개 포함)": "全部取消 (包括等待中的 {0} 个)", "모두 크게 (ABC)": "全部大写 (ABC)", + "모두 표시": "全部标记", "모두 풀기": "全部解压", "모든 작업 취소": "取消所有任务", "목": "四", @@ -1329,6 +1336,7 @@ "바꾸면 바로 저장됩니다": "更改后立即保存", "바꾸지 못했습니다: {0}": "无法更改: {0}", "바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)": "无法保存转换后的 PDF (请检查存储空间)", + "바꾼 것 {0}개": "已更改 {0} 个", "바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)", "바꿀 글": "替换为", "바꿀 글 (비우면 지움)": "替换为 (留空则删除)", @@ -1543,6 +1551,7 @@ "빈 폴더입니다": "文件夹为空", "빠르게": "快速", "빠른 디코더 (TAESD)": "快速解码器 (TAESD)", + "빠른 보기": "快速查看", "빠른 필터": "快速筛选", "빼고 싶은 것 (선택)": "要排除的内容 (可选)", "사라지는 것": "丢失的内容", @@ -1703,6 +1712,7 @@ "쉬는 중": "空闲", "스웨덴어": "瑞典语", "스타일": "样式", + "스타일대로 (바꾼 것 없음)": "按样式 (未更改)", "스페인어": "西班牙语", "시간 지정 (cron)": "指定时间 (cron)", "시간 형식 오류 {0}곳": "{0} 处时间格式错误", @@ -1765,6 +1775,7 @@ "쓸 수 없는 글자 (:)": "无效字符 (:)", "쓸 수 없는 이름": "无效名称", "쓸 수 없는 이름입니다": "不能使用此名称", + "쓸 키를 누르세요 (Ctrl · Shift · Alt 와 함께 눌러도 됩니다)": "请按下要使用的键 (可与 Ctrl、Shift、Alt 组合)", "아랍어": "阿拉伯语", "아래 갤러리 줄 보이기": "显示底部图库条", "아래 갤러리 줄 숨기기": "隐藏底部图库条", @@ -1897,6 +1908,7 @@ "열어 본 때: {0}": "访问时间: {0}", "영구 삭제": "永久删除", "영구 삭제 ({0}개, 되돌릴 수 없음)": "永久删除 ({0} 个,无法撤销)", + "영구 삭제 (휴지통을 거치지 않음)": "永久删除 (不经过回收站)", "영구 삭제하면 되살릴 수 없습니다.": "永久删除后无法还原。", "영상": "视频", "영상 {0}": "视频 {0}", @@ -2198,6 +2210,7 @@ "이름을 바꿔 풂 {0}개": "{0} 个以新名称解压", "이름을 찾을 수 없습니다: {0} (DNS {1})": "找不到名称: {0} (DNS {1})", "이름이 너무 김 (255자 넘음)": "名称过长 (超过 255 个字符)", + "이미 \"{0}\" 에 쓰는 키입니다 - 저장하면 그 동작의 이 키는 없어집니다": "此键已用于“{0}” - 保存后该操作将失去此键", "이미 그리는 중입니다": "已在绘制中", "이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。", "이미 만든 MKV 가 있습니다 ({0}개)": "已有制作好的 MKV({0} 个)", @@ -2498,6 +2511,7 @@ "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "当前查看视频的{0}(多个请在列表中勾选)", "지금 설정 그대로": "保持当前设置", "지금 설치": "立即安装", + "지금 스타일 ({0}) 의 키입니다. 바꾼 키는 모든 스타일에 쓰이고, 다른 동작과 겹치면 그 동작의 키는 없어집니다. Esc 는 바꿀 수 없습니다": "当前样式 ({0}) 的按键。更改的键适用于所有样式,与其他操作冲突时该操作将失去此键。Esc 不能更改", "지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.": "正在运行。停止并移到 lsync (实时同步) 吗? 剩余部分由 lsync 继续同步。", "지금 쓰는 버전: v{0}": "当前版本: v{0}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}", @@ -2592,6 +2606,7 @@ "채우기 (가운데 잘라냄)": "填充(裁切中间)", "처리 장치": "处理器", "처음 · 🏠 주소 (기본: Tor 연결 확인 페이지)": "起始 · 🏠 地址 (默认: Tor 连接检查页面)", + "처음 값으로": "恢复默认", "처음 보는 서버 인증서입니다": "这是首次见到的服务器证书", "처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요": "这是首次见到的服务器证书 (自签名)。请确认指纹后决定是否信任", "처음 사용 시 내려받기 {0}": "首次使用时下载 {0}", @@ -2724,6 +2739,7 @@ "파일 탐색기 (지금 여기)": "文件浏览器 (当前位置)", "파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "文件浏览器中间的 [监控] 按钮: 记住复制 · 移动并显示进度 · 目标磁盘容量 · 选项,可修改选项后重新运行 · 继续,或移到 lsync (复制 · rsync 与 lsync 分开显示)。", "파일 탐색기 누르기: 한 번 누르면 선택 → 한 번 누르면 바로 실행. 되돌리기: 환경 설정 > 파일 탐색기 > 누르기": "文件浏览器点击: 单击选择 → 单击直接打开。恢复: 设置 > 文件浏览器 > 点击", + "파일 탐색기 단축키": "文件管理器快捷键", "파일 탐색기 두 창 배치: 화면 모양 따라 → 좌우. 되돌리기: 파일 탐색기의 [좌우 ⇆ / 위아래 ⇅] 버튼 또는 창 배치": "文件浏览器双窗格布局: 跟随屏幕形状 → 左右。恢复: 文件浏览器中的 [左右 ⇆ / 上下 ⇅] 按钮或窗格布局", "파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다": "仅文件管理器列表中的名称和大小文字。会与应用界面大小及设备文字大小相乘", "파일 탐색기 목록의 줄 높이. 좁게는 손가락으로 누르기 어려울 수 있습니다": "文件管理器列表的行高。紧凑时可能难以用手指点按", @@ -2808,10 +2824,12 @@ "폴더만 lsync 로 옮길 수 있습니다.": "只有文件夹可以移到 lsync。", "폴더와 안의 것 전체 (기본)": "文件夹及其所有内容 (默认)", "폴란드어": "波兰语", + "표시": "标记", "표시 (여러 개 고르기)": "标记 (多选)", "표시 이름": "显示名称", "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "显示名称可直接修改。连续点击视频行三次会移到第一个按钮的文件夹", "표시 지우기": "清除标记", + "표시하고 다음 줄": "标记并移到下一行", "표준": "标准", "표준 입력 (-) 은 쓸 수 없습니다": "不能使用标准输入 (-)", "푸는 중": "正在解压", diff --git a/lib/app/settings.dart b/lib/app/settings.dart index dda7dfa..e92c0a8 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -481,6 +481,10 @@ class AppSettings { int explorerRemoteThumbCacheMb = 200; static const remoteThumbCacheChoices = [50, 100, 200, 500, 1000]; + /// X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 파일 탐색기 단축키 중 사용자가 바꾼 것만 (동작 → 키, '' = 그 동작은 단축키 없음). + /// 비어 있으면 (처음) 스타일대로 (83) - ui/explorer_keys.dart + Map explorerKeyOverrides = {}; + /// 84: 폴더 → "기준|desc" (예: "size|1"). 오래된 것부터 지워 300개까지 Map explorerFolderSorts = {}; @@ -881,6 +885,7 @@ class AppSettings { 'explorerSortByKind': explorerSortByKind, 'explorerRemoteThumbs': explorerRemoteThumbs, 'explorerRemoteThumbCacheMb': explorerRemoteThumbCacheMb, + 'explorerKeyOverrides': explorerKeyOverrides, 'explorerFolderSorts': explorerFolderSorts, 'explorerShowHidden': explorerShowHidden, 'explorerHiddenItems': explorerHiddenItems, @@ -1125,6 +1130,9 @@ class AppSettings { ..explorerSortPerFolder = j['explorerSortPerFolder'] == true ..explorerSortByKind = j['explorerSortByKind'] == true ..explorerRemoteThumbs = const {'off', 'wifi', 'always'}.contains(j['explorerRemoteThumbs']) ? j['explorerRemoteThumbs'] as String : 'wifi' + ..explorerKeyOverrides = j['explorerKeyOverrides'] is Map + ? {for (final e in (j['explorerKeyOverrides'] as Map).entries) if (e.key is String && e.value is String) e.key as String: e.value as String} + : {} ..explorerRemoteThumbCacheMb = j['explorerRemoteThumbCacheMb'] is num ? (j['explorerRemoteThumbCacheMb'] as num).round().clamp(10, 10000) : 200 diff --git a/lib/ui/explorer_keys.dart b/lib/ui/explorer_keys.dart new file mode 100644 index 0000000..b75ffdb --- /dev/null +++ b/lib/ui/explorer_keys.dart @@ -0,0 +1,139 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../l10n/tr.dart'; + +/// X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 파일 탐색기 단축키 바꾸기. +/// 동작마다 처음 키는 스타일대로 (83 - Total Commander / 그 밖) 이고, 사용자가 바꾼 것만 설정 (explorerKeyOverrides: 동작 → 키) 에 둔다. +/// 바꾼 키가 다른 동작의 키와 같으면 바꾼 쪽이 이기고 그 동작은 그 키를 잃는다 (설정 화면이 먼저 알린다). Esc 는 바꾸지 않는다. +const explorerKeyActions = [ + 'copy', 'move', 'newFolder', 'delete', 'deletePermanent', 'rename', 'refresh', 'quickLook', // + 'clipCopy', 'clipCut', 'paste', 'selectAll', 'mark', 'markNext', 'filter', 'up', 'back', 'forward', 'switchPane', +]; + +/// 동작 이름 (화면 글) +String explorerKeyLabel(String id) => switch (id) { + 'copy' => tr('다른 창으로 복사'), + 'move' => tr('다른 창으로 이동'), + 'newFolder' => tr('새 폴더'), + 'delete' => tr('삭제'), + 'deletePermanent' => tr('영구 삭제 (휴지통을 거치지 않음)'), + 'rename' => tr('이름 변경'), + 'refresh' => tr('새로 고침'), + 'quickLook' => tr('빠른 보기'), + 'clipCopy' => tr('복사 (붙여넣기로)'), + 'clipCut' => tr('이동 (붙여넣기로)'), + 'paste' => tr('붙여넣기'), + 'selectAll' => tr('모두 표시'), + 'mark' => tr('표시'), + 'markNext' => tr('표시하고 다음 줄'), + 'filter' => tr('빠른 필터'), + 'up' => tr('상위 폴더'), + 'back' => tr('뒤로'), + 'forward' => tr('앞으로'), + 'switchPane' => tr('다른 창으로 옮겨 가기'), + _ => id, + }; + +/// 스타일의 처음 키 (83 과 같음). [tc] Total Commander 스타일 +Map> defaultExplorerKeys({required bool tc}) => { + 'delete': [const SingleActivator(LogicalKeyboardKey.delete), if (tc) const SingleActivator(LogicalKeyboardKey.f8)], + 'deletePermanent': [const SingleActivator(LogicalKeyboardKey.delete, shift: true)], + 'rename': [const SingleActivator(LogicalKeyboardKey.f2)], + 'filter': [const SingleActivator(LogicalKeyboardKey.keyF, control: true)], + 'up': [const SingleActivator(LogicalKeyboardKey.backspace)], + 'back': [const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true)], + 'forward': [const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true)], + 'switchPane': [const SingleActivator(LogicalKeyboardKey.tab)], + if (tc) ...{ + 'copy': [const SingleActivator(LogicalKeyboardKey.f5)], + 'move': [const SingleActivator(LogicalKeyboardKey.f6)], + 'newFolder': [const SingleActivator(LogicalKeyboardKey.f7)], + 'refresh': [const SingleActivator(LogicalKeyboardKey.keyR, control: true)], + 'markNext': [const SingleActivator(LogicalKeyboardKey.insert)], + 'mark': [const SingleActivator(LogicalKeyboardKey.space)], + 'quickLook': [const SingleActivator(LogicalKeyboardKey.f3)], + } else ...{ + 'quickLook': [const SingleActivator(LogicalKeyboardKey.space)], + 'refresh': [const SingleActivator(LogicalKeyboardKey.f5)], + 'clipCopy': [const SingleActivator(LogicalKeyboardKey.keyC, control: true)], + 'clipCut': [const SingleActivator(LogicalKeyboardKey.keyX, control: true)], + 'paste': [const SingleActivator(LogicalKeyboardKey.keyV, control: true)], + 'selectAll': [const SingleActivator(LogicalKeyboardKey.keyA, control: true)], + }, + }; + +/// 같은 키 (같은 글쇠 · 같은 Ctrl · Shift · Alt · Meta) +bool sameKey(SingleActivator a, SingleActivator b) => + a.trigger == b.trigger && a.control == b.control && a.shift == b.shift && a.alt == b.alt && a.meta == b.meta; + +/// 설정에 두는 꼴: "c,s,a,m|글쇠 번호" (없으면 '' = 그 동작은 단축키 없음). 틀리면 null +String keySpec(SingleActivator a) => + '${[if (a.control) 'c', if (a.shift) 's', if (a.alt) 'a', if (a.meta) 'm'].join(',')}|${a.trigger.keyId}'; + +SingleActivator? parseKeySpec(String spec) { + final bar = spec.lastIndexOf('|'); + if (bar < 0) return null; + final id = int.tryParse(spec.substring(bar + 1)); + final key = id == null ? null : LogicalKeyboardKey.findKeyByKeyId(id); + if (key == null) return null; + final mods = spec.substring(0, bar).split(','); + return SingleActivator(key, + control: mods.contains('c'), shift: mods.contains('s'), alt: mods.contains('a'), meta: mods.contains('m')); +} + +/// 화면에 보일 글: "Ctrl+Shift+K" +String keyText(SingleActivator a) { + final name = a.trigger.keyLabel.isNotEmpty ? a.trigger.keyLabel : (a.trigger.debugName ?? '?'); + return [if (a.control) 'Ctrl', if (a.shift) 'Shift', if (a.alt) 'Alt', if (a.meta) 'Meta', name == ' ' ? 'Space' : name].join('+'); +} + +/// 바꾼 것을 넣은 동작마다의 키. 바꾼 키는 다른 동작에서 뺀다 +Map> effectiveExplorerKeys({required bool tc, required Map overrides}) { + final out = {for (final e in defaultExplorerKeys(tc: tc).entries) e.key: [...e.value]}; + final mine = {}; + for (final e in overrides.entries) { + if (!explorerKeyActions.contains(e.key)) continue; + mine[e.key] = e.value.isEmpty ? null : parseKeySpec(e.value); + if (e.value.isNotEmpty && mine[e.key] == null) mine.remove(e.key); // 틀린 값은 처음 키 그대로 + } + for (final e in mine.entries) { + out[e.key] = [?e.value]; + } + for (final e in mine.entries) { + final k = e.value; + if (k == null) continue; + for (final other in out.keys) { + if (other != e.key) out[other]!.removeWhere((x) => sameKey(x, k)); + } + } + return out; +} + +/// [action] 에 [key] 를 주면 키를 잃는 다른 동작 (없으면 null). Esc 는 쓸 수 없음 ('escape') +String? keyConflict(String action, SingleActivator key, {required bool tc, required Map overrides}) { + if (key.trigger == LogicalKeyboardKey.escape) return 'escape'; + final now = effectiveExplorerKeys(tc: tc, overrides: overrides); + for (final e in now.entries) { + if (e.key != action && e.value.any((x) => sameKey(x, key))) return e.key; + } + return null; +} + +/// 키를 받는 창에서 모자 키 (Ctrl · Shift · Alt · Meta) 만 누른 것은 아직 키가 아니다 +bool isModifierOnly(LogicalKeyboardKey k) => { + LogicalKeyboardKey.control, LogicalKeyboardKey.controlLeft, LogicalKeyboardKey.controlRight, // + LogicalKeyboardKey.shift, LogicalKeyboardKey.shiftLeft, LogicalKeyboardKey.shiftRight, + LogicalKeyboardKey.alt, LogicalKeyboardKey.altLeft, LogicalKeyboardKey.altRight, + LogicalKeyboardKey.meta, LogicalKeyboardKey.metaLeft, LogicalKeyboardKey.metaRight, + }.contains(k); + +/// 키 이벤트를 받는 쪽 (설정의 키 받기 창) 에서 쓰는 모자 상태 +SingleActivator activatorFromEvent(KeyEvent e) { + final hk = HardwareKeyboard.instance; + return SingleActivator(e.logicalKey, + control: hk.isControlPressed, shift: hk.isShiftPressed, alt: hk.isAltPressed, meta: hk.isMetaPressed); +} + +/// 표시용 위젯이 키 글을 고정 폭 글로 +TextStyle get keyTextStyle => const TextStyle(fontFamily: 'monospace', fontSize: 13); diff --git a/lib/ui/explorer_keys_page.dart b/lib/ui/explorer_keys_page.dart new file mode 100644 index 0000000..fde526f --- /dev/null +++ b/lib/ui/explorer_keys_page.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../app/app_controller.dart'; +import '../l10n/tr.dart'; +import 'explorer_keys.dart'; +import 'explorer_look.dart' show ExplorerStyle; +import 'theme.dart'; + +/// X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 파일 탐색기 단축키 바꾸기 - 동작마다 지금 키 · [바꾸기] (다음에 누른 키) · [처음 값으로]. +/// 지금 고른 스타일의 처음 키를 보이고, 바꾼 키는 모든 스타일에 쓰인다 +class ExplorerKeysPage extends StatelessWidget { + final AppController c; + const ExplorerKeysPage({super.key, required this.c}); + + static Future open(BuildContext context, AppController c) => + Navigator.of(context).push(MaterialPageRoute(builder: (_) => ExplorerKeysPage(c: c))); + + bool get _tc => ExplorerStyle.of(c.settings.explorerStyle) == ExplorerStyle.totalcmd; + + Future _change(BuildContext context, String id) async { + final picked = await showDialog( + context: context, + builder: (_) => _KeyCaptureDialog(c: c, action: id, tc: _tc), + ); + if (picked == null) return; + await c.updateSettings((x) => x.explorerKeyOverrides = {...x.explorerKeyOverrides, id: _none(picked) ? '' : keySpec(picked)}); + } + + static bool _none(SingleActivator a) => a.trigger == LogicalKeyboardKey.abort; + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: c, + builder: (context, _) { + final ov = c.settings.explorerKeyOverrides; + final keys = effectiveExplorerKeys(tc: _tc, overrides: ov); + return Scaffold( + appBar: AppBar( + title: Text(tr('파일 탐색기 단축키')), + actions: [ + if (ov.isNotEmpty) + TextButton( + onPressed: () => c.updateSettings((x) => x.explorerKeyOverrides = {}), + child: Text(tr('모두 처음 값으로')), + ), + ], + ), + body: ListView(children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + trf('지금 스타일 ({0}) 의 키입니다. 바꾼 키는 모든 스타일에 쓰이고, 다른 동작과 겹치면 그 동작의 키는 없어집니다. Esc 는 바꿀 수 없습니다', + [ExplorerStyle.of(c.settings.explorerStyle).label]), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + for (final id in explorerKeyActions) + ListTile( + dense: true, + title: Text(explorerKeyLabel(id)), + subtitle: Text( + keys[id]?.isNotEmpty == true ? keys[id]!.map(keyText).join(' · ') : tr('(없음)'), + style: keyTextStyle.copyWith(color: ov.containsKey(id) ? JjColors.accent : null), + ), + trailing: Wrap(spacing: 4, children: [ + if (ov.containsKey(id)) + TextButton( + onPressed: () => c.updateSettings((x) => x.explorerKeyOverrides = {...x.explorerKeyOverrides}..remove(id)), + child: Text(tr('처음 값으로')), + ), + OutlinedButton(onPressed: () => _change(context, id), child: Text(tr('바꾸기'))), + ]), + ), + ]), + ); + }, + ); +} + +/// 다음에 누른 키를 받는 창 - 겹치면 알리고 [저장] 은 그래도 됨. [단축키 없음] 은 abort 글쇠로 돌려준다 (부르는 쪽이 '' 로 저장) +class _KeyCaptureDialog extends StatefulWidget { + final AppController c; + final String action; + final bool tc; + const _KeyCaptureDialog({required this.c, required this.action, required this.tc}); + + @override + State<_KeyCaptureDialog> createState() => _KeyCaptureDialogState(); +} + +class _KeyCaptureDialogState extends State<_KeyCaptureDialog> { + SingleActivator? _key; + final _focus = FocusNode(); + + @override + void dispose() { + _focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final k = _key; + final clash = k == null ? null : keyConflict(widget.action, k, tc: widget.tc, overrides: widget.c.settings.explorerKeyOverrides); + return AlertDialog( + title: Text(trf('{0} - 단축키', [explorerKeyLabel(widget.action)])), + content: Focus( + focusNode: _focus, + autofocus: true, + onKeyEvent: (_, e) { + if (e is! KeyDownEvent || isModifierOnly(e.logicalKey)) return KeyEventResult.handled; + setState(() => _key = activatorFromEvent(e)); + return KeyEventResult.handled; + }, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(tr('쓸 키를 누르세요 (Ctrl · Shift · Alt 와 함께 눌러도 됩니다)')), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(border: Border.all(color: JjColors.border), borderRadius: BorderRadius.circular(6)), + child: Text(k == null ? '…' : keyText(k), style: keyTextStyle.copyWith(fontSize: 16)), + ), + if (clash == 'escape') + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(tr('Esc 는 선택 · 빠른 필터를 닫는 키라 쓸 수 없습니다'), style: const TextStyle(color: JjColors.danger, fontSize: 12)), + ) + else if (clash != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(trf('이미 "{0}" 에 쓰는 키입니다 - 저장하면 그 동작의 이 키는 없어집니다', [explorerKeyLabel(clash)]), + style: const TextStyle(color: Colors.orangeAccent, fontSize: 12)), + ), + ]), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, const SingleActivator(LogicalKeyboardKey.abort)), + child: Text(tr('단축키 없음')), + ), + TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), + FilledButton(onPressed: k == null || clash == 'escape' ? null : () => Navigator.pop(context, k), child: Text(tr('저장'))), + ], + ); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index b7aa73e..df04f39 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -58,6 +58,7 @@ import '../l10n/tr.dart'; import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; import 'remote_thumb_view.dart'; +import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys; import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions; import 'quick_look.dart'; import 'file_tools.dart' show showFileHash, showFolderProperties; @@ -4460,17 +4461,9 @@ class _ExplorerPageState extends State { // P0 (15 지적 10/17): 글자 칸 (빠른 필터 · 경로 등) 에 치는 중이면 파일 단축키를 쓰지 않는다 - 예전에는 CallbackShortcuts 가 // 글자 칸보다 먼저 키를 받아, 칸에서 Delete 를 누르면 파일 지우기 창이 뜨고 Ctrl+A · C · V · Space 도 파일에 쓰였다 final bindings = { - // 83: 스타일에 맞는 단축키 (Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · - // Ctrl+R 새로 고침 / Windows 탐색기 · X-plore: Ctrl+C · X · V · A · F5 새로 고침) - ..._styleKeys(), - const SingleActivator(LogicalKeyboardKey.delete): () => _button(ExplorerButton.delete), - // 65: Shift+Delete = 휴지통을 거치지 않고 (창에서 한 번 더 확인) - const SingleActivator(LogicalKeyboardKey.delete, shift: true): () { - _shiftDelete = true; - _button(ExplorerButton.delete); - }, - const SingleActivator(LogicalKeyboardKey.f2): () => _button(ExplorerButton.rename), - // 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게) + // 83 · X-plore 비교 5-1 ⑥: 스타일에 맞는 처음 키 + 사용자가 바꾼 키 ([explorer_keys.dart], 환경 설정 > 파일 탐색기 > 단축키) + ..._keyBindings(), + // 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게) - Esc 는 바꾸지 않는다 const SingleActivator(LogicalKeyboardKey.escape): () { // 76: 열린 빠른 필터부터 닫는다 if (_panes[_active].filterOpen) { @@ -4479,13 +4472,6 @@ class _ExplorerPageState extends State { _setSelecting(false); } }, - const SingleActivator(LogicalKeyboardKey.keyF, control: true): () => _openFilter(_panes[_active]), - const SingleActivator(LogicalKeyboardKey.backspace): () => _button(ExplorerButton.up), - // 75: 지금 창의 뒤로 · 앞으로 - const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _travel(_panes[_active], forward: false), - const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _travel(_panes[_active], forward: true), - const SingleActivator(LogicalKeyboardKey.tab): () => - setState(() => _active = _dual ? 1 - _active : 0), }; return Focus( canRequestFocus: false, @@ -5123,34 +5109,52 @@ class _ExplorerPageState extends State { /// 83: 지금 스타일이 Total Commander 인지 (Rsync 화면은 스타일 단축키를 쓰지 않는다 - 늘 같은 버튼) bool get _tcKeys => !widget.rsync && _look == ExplorerStyle.totalcmd; - /// 83: 스타일에 맞는 단축키 - Map _styleKeys() { - if (widget.rsync) return {const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh)}; - if (_tcKeys) { - return { - const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.copy), - const SingleActivator(LogicalKeyboardKey.f6): () => _button(ExplorerButton.move), - const SingleActivator(LogicalKeyboardKey.f7): () => _button(ExplorerButton.newFolder), - const SingleActivator(LogicalKeyboardKey.f8): () => _button(ExplorerButton.delete), - const SingleActivator(LogicalKeyboardKey.keyR, control: true): () => _button(ExplorerButton.refresh), - // Insert: 표시하고 다음 줄로 · Space: 표시 (TC 처럼) - const SingleActivator(LogicalKeyboardKey.insert): () => _markFocused(next: true), - const SingleActivator(LogicalKeyboardKey.space): () => _markFocused(next: false), - // 81: 빠른 보기 (TC 의 보기 키) - const SingleActivator(LogicalKeyboardKey.f3): () => _quickLook(_pane), - }; - } - return { - // 81: 빠른 보기 (Windows · macOS 처럼 Space) - const SingleActivator(LogicalKeyboardKey.space): () => _quickLook(_pane), - const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh), - const SingleActivator(LogicalKeyboardKey.keyC, control: true): () => _toClipboard(_targets(_pane), move: false), - const SingleActivator(LogicalKeyboardKey.keyX, control: true): () => _toClipboard(_targets(_pane), move: true), - const SingleActivator(LogicalKeyboardKey.keyV, control: true): () => _paste(_pane, _pane.current), - const SingleActivator(LogicalKeyboardKey.keyA, control: true): () { + /// 83 · X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 동작마다의 키 → 할 일. 처음 키는 스타일대로 (Total Commander: F5 복사 · F6 이동 · + /// F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침 / Windows 탐색기 · X-plore: Ctrl+C · X · V · A · F5 새로 고침), + /// 사용자가 바꾼 것은 설정 explorerKeyOverrides. Rsync 화면은 늘 같은 키 (바꾼 것을 쓰지 않음) + Map _keyBindings() { + final cb = { + 'copy': () => _button(ExplorerButton.copy), + 'move': () => _button(ExplorerButton.move), + 'newFolder': () => _button(ExplorerButton.newFolder), + 'delete': () => _button(ExplorerButton.delete), + // 65: Shift+Delete = 휴지통을 거치지 않고 (창에서 한 번 더 확인) + 'deletePermanent': () { + _shiftDelete = true; + _button(ExplorerButton.delete); + }, + 'rename': () => _button(ExplorerButton.rename), + 'refresh': () => _button(ExplorerButton.refresh), + // 81: 빠른 보기 + 'quickLook': () => _quickLook(_pane), + 'clipCopy': () => _toClipboard(_targets(_pane), move: false), + 'clipCut': () => _toClipboard(_targets(_pane), move: true), + 'paste': () => _paste(_pane, _pane.current), + 'selectAll': () { if (!_selecting) setState(() => _selecting = true); _markWhere(_pane, _pane.current, (_) => true); }, + // TC: Space 표시 · Insert 표시하고 다음 줄로 + 'mark': () => _markFocused(next: false), + 'markNext': () => _markFocused(next: true), + 'filter': () => _openFilter(_panes[_active]), + 'up': () => _button(ExplorerButton.up), + // 75: 지금 창의 뒤로 · 앞으로 + 'back': () => _travel(_panes[_active], forward: false), + 'forward': () => _travel(_panes[_active], forward: true), + 'switchPane': () => setState(() => _active = _dual ? 1 - _active : 0), + }; + final keys = widget.rsync + ? { + for (final e in defaultExplorerKeys(tc: false).entries) + if (const {'delete', 'deletePermanent', 'rename', 'refresh', 'filter', 'up', 'back', 'forward', 'switchPane'}.contains(e.key)) + e.key: e.value, + } + : effectiveExplorerKeys(tc: _tcKeys, overrides: c.settings.explorerKeyOverrides); + return { + for (final e in keys.entries) + if (cb[e.key] != null) + for (final a in e.value) a: cb[e.key]!, }; } diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index f9584a7..4daa8e0 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -33,6 +33,7 @@ import 'ftp_settings.dart'; import 'smb_settings.dart'; import 'webdav_settings.dart'; import 'explorer_look.dart' show ExplorerStyle; +import 'explorer_keys_page.dart'; import 'theme.dart'; import '../l10n/tr.dart'; import 'setting_tile.dart'; @@ -888,6 +889,16 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.explorerFolderSize = v ?? 'sort'), ), ), + // X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 단축키 바꾸기 (키보드가 있는 PC 에서만 보임) + if (desk) + ListTile( + title: Text(tr('파일 탐색기 단축키')), + subtitle: Text(s.explorerKeyOverrides.isEmpty + ? tr('스타일대로 (바꾼 것 없음)') + : trf('바꾼 것 {0}개', [s.explorerKeyOverrides.length])), + trailing: const Icon(Icons.chevron_right), + onTap: () => ExplorerKeysPage.open(context, c), + ), SettingTile( title: Text(tr('스타일')), // 83: 스타일마다 단축키 - PC 에서만 (194: 키보드가 없는 폰 · 태블릿에는 쓸모없는 긴 글, 15 지적) diff --git a/test/explorer_keys_test.dart b/test/explorer_keys_test.dart new file mode 100644 index 0000000..7e3ab7d --- /dev/null +++ b/test/explorer_keys_test.dart @@ -0,0 +1,132 @@ +import 'dart:io'; + +import 'package:flutter/material.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_keys.dart'; +import 'package:jj_mkvmaker/ui/explorer_keys_page.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; + +/// X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 파일 탐색기 단축키 바꾸기 - 처음은 83 과 같은 키 그대로, 바꾼 것만 설정에 +void main() { + const k = LogicalKeyboardKey.keyK; + List texts(List l) => l.map(keyText).toList(); + + test('바꾼 것이 없으면 (처음) 83 의 키 그대로 - Total Commander / 그 밖', () { + final tc = effectiveExplorerKeys(tc: true, overrides: {}); + expect(texts(tc['copy']!), ['F5']); + expect(texts(tc['move']!), ['F6']); + expect(texts(tc['newFolder']!), ['F7']); + expect(texts(tc['delete']!), ['Delete', 'F8']); + expect(texts(tc['refresh']!), ['Ctrl+R']); + expect(texts(tc['mark']!), ['Space']); + expect(texts(tc['markNext']!), ['Insert']); + expect(texts(tc['quickLook']!), ['F3']); + final win = effectiveExplorerKeys(tc: false, overrides: {}); + expect(texts(win['refresh']!), ['F5']); + expect(texts(win['clipCopy']!), ['Ctrl+C']); + expect(texts(win['paste']!), ['Ctrl+V']); + expect(texts(win['selectAll']!), ['Ctrl+A']); + expect(texts(win['quickLook']!), ['Space']); + expect(win['copy'], isNull, reason: 'Windows 모양은 F5 가 새로 고침 (복사 키 없음)'); + expect(texts(win['deletePermanent']!), ['Shift+Delete']); + expect(texts(win['back']!), ['Alt+Arrow Left']); + }); + + test('바꾸기: 그 동작은 새 키만 · 겹치는 다른 동작은 그 키를 잃음 · \'\' 는 없음 · 틀린 값은 처음 키 · 저장 꼴 왕복', () { + final spec = keySpec(const SingleActivator(k, control: true, shift: true)); + expect(parseKeySpec(spec), isA().having((a) => keyText(a), 'text', 'Ctrl+Shift+K')); + final a = effectiveExplorerKeys(tc: false, overrides: {'newFolder': spec, 'paste': '', 'rename': 'zzz'}); + expect(texts(a['newFolder']!), ['Ctrl+Shift+K']); + expect(a['paste'], isEmpty); + expect(texts(a['rename']!), ['F2'], reason: '틀린 값은 처음 키'); + // F5 를 복사에 주면 새로 고침은 F5 를 잃음 + final b = effectiveExplorerKeys(tc: false, overrides: {'copy': keySpec(const SingleActivator(LogicalKeyboardKey.f5))}); + expect(texts(b['copy']!), ['F5']); + expect(b['refresh'], isEmpty); + expect(keyConflict('copy', const SingleActivator(LogicalKeyboardKey.f5), tc: false, overrides: {}), 'refresh'); + expect(keyConflict('copy', const SingleActivator(LogicalKeyboardKey.escape), tc: false, overrides: {}), 'escape'); + expect(keyConflict('copy', const SingleActivator(k, control: true), tc: false, overrides: {}), isNull); + // 설정 저장 · 읽기 · 틀린 꼴은 비움 + final back = AppSettings.fromJson((AppSettings()..explorerKeyOverrides = {'copy': spec}).toJson()); + expect(back.explorerKeyOverrides, {'copy': spec}); + expect(AppSettings.fromJson({'explorerKeyOverrides': 'x'}).explorerKeyOverrides, isEmpty); + expect(AppSettings().explorerKeyOverrides, isEmpty); + }); + + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_keys_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + AppController ctl() => AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + + testWidgets('탐색기: 바꾼 키 (새 폴더 = Ctrl+Shift+K) 를 누르면 그 동작 · 처음 키도 그대로 (F2 이름 변경)', (tester) async { + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = ctl(); + c.settings + ..explorerPaths = [tmp.path, tmp.path] + ..explorerStyle = 'windows' + ..explorerKeyOverrides = {'newFolder': keySpec(const SingleActivator(k, control: true, shift: true))}; + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); + for (var i = 0; i < 20; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await tester.pump(); + } + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(k); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AlertDialog, '새 폴더'), findsOneWidget, reason: '바꾼 키로 새 폴더 창'); + await tester.tap(find.text('취소').last); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('설정 화면: [바꾸기] 에서 누른 키 저장 · 겹치면 알림 · [처음 값으로] · [단축키 없음]', (tester) async { + tester.view.physicalSize = const Size(1000, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = ctl()..settings.explorerStyle = 'windows'; + await tester.pumpWidget(MaterialApp(home: ExplorerKeysPage(c: c))); + Future change(String label) async { + await tester.tap(find.descendant(of: find.widgetWithText(ListTile, label), matching: find.text('바꾸기'))); + await tester.pumpAndSettle(); + } + + await change('다른 창으로 복사'); + await tester.sendKeyEvent(LogicalKeyboardKey.f5); + await tester.pump(); + expect(find.textContaining('이미 "새로 고침" 에 쓰는 키입니다'), findsOneWidget); + await tester.tap(find.text('저장')); + await tester.pumpAndSettle(); + expect(c.settings.explorerKeyOverrides['copy'], keySpec(const SingleActivator(LogicalKeyboardKey.f5))); + expect(find.descendant(of: find.widgetWithText(ListTile, '새로 고침'), matching: find.text('(없음)')), findsOneWidget, + reason: '겹친 동작은 키를 잃음'); + // 처음 값으로 + await tester.tap(find.descendant(of: find.widgetWithText(ListTile, '다른 창으로 복사'), matching: find.text('처음 값으로'))); + await tester.pumpAndSettle(); + expect(c.settings.explorerKeyOverrides, isEmpty); + // 단축키 없음 + await change('붙여넣기'); + await tester.tap(find.text('단축키 없음')); + await tester.pumpAndSettle(); + expect(c.settings.explorerKeyOverrides['paste'], ''); + // Esc 는 저장할 수 없음 + await change('삭제'); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + if (find.byType(AlertDialog).evaluate().isNotEmpty) { + expect(tester.widget(find.widgetWithText(FilledButton, '저장')).onPressed, isNull); + } + }); +}