From 20e5dc89114623d27c41dd3b9039549aa81ccd01 Mon Sep 17 00:00:00 2001 From: "KOSMOS, Tzushih.K" <48860861+kisaraki@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:50:18 +0800 Subject: [PATCH] feat: add Markdown navigation pane --- crates/edit/src/bin/edit/draw_menubar.rs | 15 + crates/edit/src/bin/edit/draw_navigation.rs | 497 ++++++++++++++++++++ crates/edit/src/bin/edit/main.rs | 5 + crates/edit/src/bin/edit/state.rs | 7 + crates/edit/src/tui.rs | 49 +- i18n/edit.toml | 22 + 6 files changed, 589 insertions(+), 6 deletions(-) create mode 100644 crates/edit/src/bin/edit/draw_navigation.rs diff --git a/crates/edit/src/bin/edit/draw_menubar.rs b/crates/edit/src/bin/edit/draw_menubar.rs index 401614786a1..e8e61c451d8 100644 --- a/crates/edit/src/bin/edit/draw_menubar.rs +++ b/crates/edit/src/bin/edit/draw_menubar.rs @@ -6,6 +6,7 @@ use edit::input::{kbmod, vk}; use edit::tui::*; use stdext::arena_format; +use crate::draw_navigation::document_is_markdown; use crate::localization::*; use crate::settings::Settings; use crate::state::*; @@ -123,6 +124,7 @@ fn draw_menu_edit(ctx: &mut Context, state: &mut State) { fn draw_menu_view(ctx: &mut Context, state: &mut State) { if let Some(doc) = state.documents.active() { + let markdown_path = doc.path.clone().filter(|_| document_is_markdown(doc)); let mut tb = doc.buffer.borrow_mut(); let word_wrap = tb.is_word_wrap_enabled(); @@ -130,6 +132,19 @@ fn draw_menu_view(ctx: &mut Context, state: &mut State) { if ctx.menubar_menu_button(loc(LocId::ViewFocusStatusbar), 'S', vk::NULL) { state.wants_statusbar_focus = true; } + // EN: Keep Navigation second in View, but disable it unless the active file is Markdown. + // 中文:「導覽視窗」固定為檢視選單第二項,非 Markdown 文件時僅反灰停用。 + if let Some(markdown_path) = markdown_path { + if ctx.menubar_menu_button(loc(LocId::ViewNavigation), 'N', vk::NULL) { + if state.navigation_path.as_ref() != Some(&markdown_path) { + state.navigation_collapsed.clear(); + state.navigation_path = Some(markdown_path); + } + state.wants_navigation = true; + } + } else { + ctx.menubar_menu_button_disabled(loc(LocId::ViewNavigation), 'N', vk::NULL); + } if ctx.menubar_menu_button(loc(LocId::ViewGoToFile), 'F', kbmod::CTRL | vk::P) { state.wants_go_to_file = true; } diff --git a/crates/edit/src/bin/edit/draw_navigation.rs b/crates/edit/src/bin/edit/draw_navigation.rs new file mode 100644 index 00000000000..1e52ec4c86c --- /dev/null +++ b/crates/edit/src/bin/edit/draw_navigation.rs @@ -0,0 +1,497 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use edit::buffer::TextBuffer; +use edit::helpers::*; +use edit::oklab::StraightRgba; +use edit::tui::*; + +use crate::documents::Document; +use crate::localization::*; +use crate::state::*; + +const ACTIVE_BACKGROUND: StraightRgba = StraightRgba::from_rgba(0xadd8e6ff); +const ACTIVE_FOREGROUND: StraightRgba = StraightRgba::from_rgba(0x000000ff); + +#[derive(Debug, PartialEq, Eq)] +enum NavigationItemKind { + Heading(String), + Content, +} + +#[derive(Debug, PartialEq, Eq)] +struct NavigationItem { + level: u8, + line: CoordType, + column: CoordType, + kind: NavigationItemKind, +} + +impl NavigationItem { + fn is_heading(&self) -> bool { + matches!(self.kind, NavigationItemKind::Heading(_)) + } +} + +pub fn document_is_markdown(doc: &Document) -> bool { + // EN: The navigation command is enabled exclusively for files ending in .md. + // 中文:導覽功能僅對副檔名為 .md 的文件啟用。 + doc.path + .as_deref() + .and_then(|path| path.extension()) + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) +} + +pub fn draw_dialog_navigation(ctx: &mut Context, state: &mut State) { + // EN: Render a six-level clickable outline with folding and the ESC hint at the bottom. + // 中文:繪製可點選、可折疊的六層大綱,並將 ESC 提示固定放在視窗下緣。 + let Some(doc) = state.documents.active() else { + state.wants_navigation = false; + return; + }; + if !document_is_markdown(doc) { + state.wants_navigation = false; + return; + } + + let (items, cursor_line) = { + let tb = doc.buffer.borrow(); + let text = buffer_text(&tb); + (parse_markdown(&String::from_utf8_lossy(&text)), tb.cursor_logical_pos().y) + }; + let active = active_display_item(&items, cursor_line, &state.navigation_collapsed); + + let size = ctx.size(); + let width = (size.width - 6).clamp(12, 80); + let height = (size.height - 9).max(4); + let mut toggle = None; + let mut jump = None; + + ctx.modal_begin_centered_title("markdown-navigation", loc(LocId::NavigationDialogTitle)); + { + ctx.scrollarea_begin("navigation-scrollarea", Size { width, height }); + ctx.inherit_focus(); + { + if items.is_empty() { + ctx.label("navigation-empty", loc(LocId::NavigationNoContent)); + ctx.attr_padding(Rect::two(0, 1)); + } else { + ctx.table_begin("navigation-tree"); + ctx.inherit_focus(); + ctx.table_set_columns(&[1, COORD_TYPE_SAFE_MAX]); + ctx.table_set_cell_gap(Size { width: 1, height: 0 }); + ctx.attr_padding(Rect::two(0, 1)); + { + let mut hidden_below = None; + for (idx, item) in items.iter().enumerate() { + if let Some(level) = hidden_below { + if item.level > level { + continue; + } + hidden_below = None; + } + + let has_children = item.is_heading() + && items.get(idx + 1).is_some_and(|next| next.level > item.level); + let collapsed = has_children + && state.navigation_collapsed.contains(&(item.line as usize)); + + ctx.table_next_row(); + ctx.next_block_id_mixin(idx as u64 + 1); + if has_children { + if ctx.button( + "navigation-fold", + if collapsed { "+" } else { "-" }, + ButtonStyle::default().bracketed(false), + ) { + toggle = Some(item.line as usize); + } + } else { + ctx.label("navigation-fold-spacer", " "); + } + + let text = tree_text(&items, idx); + ctx.next_block_id_mixin(idx as u64 + 1); + match &item.kind { + NavigationItemKind::Heading(_) => { + if ctx.button( + "navigation-heading", + &text, + ButtonStyle::default().bracketed(false), + ) { + jump = Some(Point { x: item.column, y: item.line }); + } + ctx.attr_overflow(Overflow::TruncateTail); + if active == Some(idx) { + ctx.attr_background_rgba(ACTIVE_BACKGROUND); + ctx.attr_foreground_rgba(ACTIVE_FOREGROUND); + } + } + NavigationItemKind::Content => { + if ctx.button( + "navigation-content", + &text, + ButtonStyle::default().bracketed(false), + ) { + jump = Some(Point { x: 0, y: item.line }); + } + ctx.attr_overflow(Overflow::TruncateTail); + } + } + + if collapsed { + hidden_below = Some(item.level); + } + } + } + ctx.table_end(); + } + } + ctx.scrollarea_end(); + + ctx.label("navigation-escape-hint", loc(LocId::NavigationEscapeHint)); + ctx.attr_position(Position::Center); + ctx.attr_padding(Rect::three(1, 0, 0)); + } + let close = ctx.modal_end(); + + if let Some(line) = toggle { + if !state.navigation_collapsed.remove(&line) { + state.navigation_collapsed.insert(line); + } + ctx.needs_rerender(); + } + + if let Some(pos) = jump + && let Some(doc) = state.documents.active_mut() + { + // EN: A selected tree row moves the editor cursor before the modal closes. + // 中文:點選樹狀項目後,先移動編輯器游標,再關閉導覽視窗。 + let mut tb = doc.buffer.borrow_mut(); + jump_to_navigation_target(&mut tb, pos); + state.wants_navigation = false; + ctx.needs_rerender(); + } + + if close { + state.wants_navigation = false; + ctx.needs_rerender(); + } +} + +fn jump_to_navigation_target(tb: &mut TextBuffer, pos: Point) { + tb.cursor_move_to_logical(pos); + tb.make_cursor_visible(); +} + +fn buffer_text(tb: &TextBuffer) -> Vec { + let mut text = Vec::with_capacity(tb.text_length()); + while text.len() < tb.text_length() { + let chunk = tb.read_forward(text.len()); + if chunk.is_empty() { + break; + } + text.extend_from_slice(chunk); + } + text +} + +fn parse_markdown(text: &str) -> Vec { + // EN: Parse ATX headings # through ###### and summarize other non-empty regions. + // 中文:解析 # 至 ###### 的 ATX 標題,其餘非空白文字區域以概括項目表示。 + let mut items = Vec::new(); + let mut content_start = None; + let mut last_heading_level = 0; + let mut fence = None; + + for (line_number, raw_line) in text.lines().enumerate() { + let line = raw_line.strip_suffix('\r').unwrap_or(raw_line); + let marker = fence_marker(line); + + if let Some((fence_char, fence_count)) = fence { + note_content(&mut content_start, line_number, last_heading_level, line); + if marker.is_some_and(|(ch, count, rest)| { + ch == fence_char && count >= fence_count && rest.trim().is_empty() + }) { + fence = None; + } + continue; + } + + if let Some((fence_char, fence_count, _)) = marker { + fence = Some((fence_char, fence_count)); + note_content(&mut content_start, line_number, last_heading_level, line); + continue; + } + + if let Some((level, column, title)) = parse_heading(line) { + flush_content(&mut items, &mut content_start); + items.push(NavigationItem { + level, + line: line_number as CoordType, + column: column as CoordType, + kind: NavigationItemKind::Heading(title), + }); + last_heading_level = level; + } else { + note_content(&mut content_start, line_number, last_heading_level, line); + } + } + + flush_content(&mut items, &mut content_start); + items +} + +fn note_content( + content_start: &mut Option<(CoordType, u8)>, + line_number: usize, + last_heading_level: u8, + line: &str, +) { + if content_start.is_none() && !line.trim().is_empty() { + *content_start = Some(( + line_number as CoordType, + if last_heading_level == 0 { 1 } else { last_heading_level + 1 }, + )); + } +} + +fn flush_content(items: &mut Vec, content_start: &mut Option<(CoordType, u8)>) { + if let Some((line, level)) = content_start.take() { + items.push(NavigationItem { level, line, column: 0, kind: NavigationItemKind::Content }); + } +} + +fn fence_marker(line: &str) -> Option<(u8, usize, &str)> { + let bytes = line.as_bytes(); + let mut offset = 0; + while offset < bytes.len() && offset < 3 && bytes[offset] == b' ' { + offset += 1; + } + let fence_char = *bytes.get(offset)?; + if !matches!(fence_char, b'`' | b'~') { + return None; + } + let mut end = offset; + while bytes.get(end) == Some(&fence_char) { + end += 1; + } + let count = end - offset; + (count >= 3).then_some((fence_char, count, &line[end..])) +} + +fn parse_heading(line: &str) -> Option<(u8, usize, String)> { + let bytes = line.as_bytes(); + let mut offset = 0; + while bytes.get(offset).is_some_and(|byte| matches!(*byte, b' ' | b'\t')) { + offset += 1; + } + + let hashes_start = offset; + while bytes.get(offset) == Some(&b'#') { + offset += 1; + } + let level = offset - hashes_start; + if !(1..=6).contains(&level) { + return None; + } + if bytes.get(offset).is_some_and(|byte| !matches!(*byte, b' ' | b'\t')) { + return None; + } + + while bytes.get(offset).is_some_and(|byte| matches!(*byte, b' ' | b'\t')) { + offset += 1; + } + let column = offset; + let mut title_end = bytes.len(); + while title_end > column && matches!(bytes[title_end - 1], b' ' | b'\t') { + title_end -= 1; + } + + let mut closing_start = title_end; + while closing_start > column && bytes[closing_start - 1] == b'#' { + closing_start -= 1; + } + if closing_start < title_end + && closing_start > 0 + && matches!(bytes[closing_start - 1], b' ' | b'\t') + { + title_end = closing_start - 1; + while title_end > column && matches!(bytes[title_end - 1], b' ' | b'\t') { + title_end -= 1; + } + } + + let title = + if title_end == column { "#".repeat(level) } else { line[column..title_end].to_string() }; + let logical_column = line[..column].chars().count(); + Some((level as u8, logical_column, title)) +} + +fn active_display_item( + items: &[NavigationItem], + cursor_line: CoordType, + collapsed: &std::collections::BTreeSet, +) -> Option { + let active = items + .iter() + .enumerate() + .rev() + .find(|(_, item)| item.is_heading() && item.line <= cursor_line) + .map(|(idx, _)| idx)?; + + for (idx, item) in items.iter().enumerate().take(active) { + if item.is_heading() + && collapsed.contains(&(item.line as usize)) + && is_descendant(items, idx, active) + { + return Some(idx); + } + } + Some(active) +} + +fn is_descendant(items: &[NavigationItem], parent: usize, child: usize) -> bool { + if child <= parent || items[child].level <= items[parent].level { + return false; + } + !items[parent + 1..=child].iter().any(|item| item.level <= items[parent].level) +} + +fn tree_text(items: &[NavigationItem], idx: usize) -> String { + let item = &items[idx]; + let has_later_sibling = items[idx + 1..] + .iter() + .take_while(|next| next.level >= item.level) + .any(|next| next.level == item.level); + let mut text = "│ ".repeat(item.level.saturating_sub(1) as usize); + text.push_str(if has_later_sibling { "├─ " } else { "└─ " }); + match &item.kind { + NavigationItemKind::Heading(title) => text.push_str(title), + NavigationItemKind::Content => text.push_str("[......]"), + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_six_heading_levels_and_content_regions() { + let items = parse_markdown( + "intro\n# One\nbody\n## Two\n### Three\n#### Four\n##### Five\n###### Six\ndeep text\n####### Seven\ntail\n# Last\n", + ); + let headings: Vec<_> = items + .iter() + .filter_map(|item| match &item.kind { + NavigationItemKind::Heading(title) => Some((item.level, title.as_str(), item.line)), + NavigationItemKind::Content => None, + }) + .collect(); + + assert_eq!( + headings, + vec![ + (1, "One", 1), + (2, "Two", 3), + (3, "Three", 4), + (4, "Four", 5), + (5, "Five", 6), + (6, "Six", 7), + (1, "Last", 11), + ] + ); + assert!(items.iter().all(|item| { + !matches!(&item.kind, NavigationItemKind::Heading(title) if title == "Seven") + })); + } + + #[test] + fn ignores_hashes_without_spaces_and_inside_fences() { + let items = + parse_markdown("# Valid ###\n#invalid\n```markdown\n## Hidden\n```\n ### Visible\n"); + + let headings: Vec<_> = items + .iter() + .filter_map(|item| match &item.kind { + NavigationItemKind::Heading(title) => Some((item.level, title.as_str())), + NavigationItemKind::Content => None, + }) + .collect(); + assert_eq!(headings, vec![(1, "Valid"), (3, "Visible")]); + } + + #[test] + fn parses_indented_hash_navigation_levels_and_rejects_asterisks() { + let items = parse_markdown( + "這是開頭\n\n# A\n ## A-1\n ### A-1-1\n ### A-1-2\n ## A-2\n ## A-3\n# B\n\n這是其他文字\n\n# C\n這是測試\n* 非標題\n** 也非標題\n*** 仍非標題\n", + ); + let headings: Vec<_> = items + .iter() + .filter_map(|item| match &item.kind { + NavigationItemKind::Heading(title) => Some((item.level, title.as_str())), + NavigationItemKind::Content => None, + }) + .collect(); + assert_eq!( + headings, + vec![ + (1, "A"), + (2, "A-1"), + (3, "A-1-1"), + (3, "A-1-2"), + (2, "A-2"), + (2, "A-3"), + (1, "B"), + (1, "C"), + ] + ); + assert_eq!( + items.iter().filter(|item| matches!(item.kind, NavigationItemKind::Content)).count(), + 3 + ); + + assert_eq!( + items + .iter() + .filter_map(|item| match &item.kind { + NavigationItemKind::Heading(title) => Some((title.as_str(), item.column)), + NavigationItemKind::Content => None, + }) + .collect::>(), + vec![ + ("A", 2), + ("A-1", 8), + ("A-1-1", 14), + ("A-1-2", 14), + ("A-2", 8), + ("A-3", 8), + ("B", 2), + ("C", 2), + ] + ); + } + + #[test] + fn collapsed_parent_represents_the_active_descendant() { + let items = parse_markdown("# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\n"); + let collapsed = std::collections::BTreeSet::from([0]); + assert_eq!(active_display_item(&items, 5, &collapsed), Some(0)); + } + + #[test] + fn indented_heading_position_moves_the_document_cursor() { + let text = "開頭\n ###### 第六層\n尾端\n"; + let items = parse_markdown(text); + let heading = items.iter().find(|item| item.is_heading()).unwrap(); + let target = Point { x: heading.column, y: heading.line }; + + let mut tb = TextBuffer::new(false).unwrap(); + tb.write_raw(text.as_bytes()); + jump_to_navigation_target(&mut tb, target); + + assert_eq!(tb.cursor_logical_pos(), Point { x: 12, y: 1 }); + } +} diff --git a/crates/edit/src/bin/edit/main.rs b/crates/edit/src/bin/edit/main.rs index 27ae7fab6c0..cde8a3a68e6 100644 --- a/crates/edit/src/bin/edit/main.rs +++ b/crates/edit/src/bin/edit/main.rs @@ -6,6 +6,7 @@ mod documents; mod draw_editor; mod draw_filepicker; mod draw_menubar; +mod draw_navigation; mod draw_statusbar; mod localization; mod settings; @@ -18,6 +19,7 @@ use std::{env, process}; use draw_editor::*; use draw_filepicker::*; use draw_menubar::*; +use draw_navigation::*; use draw_statusbar::*; use edit::framebuffer::{self, IndexedColor}; use edit::helpers::*; @@ -365,6 +367,9 @@ fn draw(ctx: &mut Context, state: &mut State) { if state.wants_go_to_file { draw_go_to_file(ctx, state); } + if state.wants_navigation { + draw_dialog_navigation(ctx, state); + } if state.wants_about { draw_dialog_about(ctx, state); } diff --git a/crates/edit/src/bin/edit/state.rs b/crates/edit/src/bin/edit/state.rs index 13a1cefbbea..7e10a5bbbdc 100644 --- a/crates/edit/src/bin/edit/state.rs +++ b/crates/edit/src/bin/edit/state.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use std::borrow::Cow; +use std::collections::BTreeSet; use std::ffi::{OsStr, OsString}; use std::mem; use std::path::{Path, PathBuf}; @@ -166,6 +167,9 @@ pub struct State { pub wants_statusbar_focus: bool, pub wants_indentation_picker: bool, pub wants_go_to_file: bool, + pub wants_navigation: bool, + pub navigation_collapsed: BTreeSet, + pub navigation_path: Option, pub wants_about: bool, pub wants_close: bool, pub wants_exit: bool, @@ -216,6 +220,9 @@ impl State { wants_encoding_change: StateEncodingChange::None, wants_indentation_picker: false, wants_go_to_file: false, + wants_navigation: false, + navigation_collapsed: Default::default(), + navigation_path: None, wants_about: false, wants_close: false, wants_exit: false, diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 9826c91cfc7..a97878acc73 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -617,7 +617,7 @@ impl Tui { // This root is modal and swallows all clicks, // no matter whether the click was inside it or not. - if matches!(root.borrow().content, NodeContent::Modal(_)) { + if matches!(root.borrow().content, NodeContent::Modal(..)) { break; } } @@ -965,7 +965,7 @@ impl Tui { self.framebuffer.replace_attr(outer_clipped, Attributes::All, Attributes::None); - if matches!(node.content, NodeContent::Modal(_)) { + if matches!(node.content, NodeContent::Modal(..)) { let rect = Rect { left: 0, top: 0, right: self.size.width, bottom: self.size.height }; let dim = self.indexed_alpha(IndexedColor::Background, 1, 2); @@ -988,10 +988,19 @@ impl Tui { } match &mut node.content { - NodeContent::Modal(title) if !title.is_empty() => { + NodeContent::Modal(title, centered) if !title.is_empty() => { + let title_left = if *centered { + let title_width = unicode::MeasurementConfig::new(&title.as_bytes()) + .goto_visual(Point { x: CoordType::MAX, y: 0 }) + .visual_pos + .x; + node.outer.left + (node.outer.width() - title_width) / 2 + } else { + node.outer.left + 2 + }; self.framebuffer.replace_text( node.outer.top, - node.outer.left + 2, + title_left.max(node.outer.left + 1), node.outer.right - 1, title, ); @@ -1781,6 +1790,16 @@ impl<'a> Context<'a, '_> { /// Begins a modal window. Call [`Context::modal_end()`]. pub fn modal_begin(&mut self, classname: &'static str, title: &str) { + self.modal_begin_internal(classname, title, false); + } + + /// EN: Begins a modal whose title is centered in the top border; call [`Context::modal_end()`]. + /// 中文:建立標題置於上框線中央的對話框;結束時呼叫 [`Context::modal_end()`]。 + pub fn modal_begin_centered_title(&mut self, classname: &'static str, title: &str) { + self.modal_begin_internal(classname, title, true); + } + + fn modal_begin_internal(&mut self, classname: &'static str, title: &str, centered_title: bool) { self.block_begin(classname); self.attr_float(FloatSpec { anchor: Anchor::Root, @@ -1801,7 +1820,7 @@ impl<'a> Context<'a, '_> { } else { arena_format!(self.arena(), " {} ", title) }; - last_node.content = NodeContent::Modal(title); + last_node.content = NodeContent::Modal(title, centered_title); self.last_modal = Some(self.tree.last_node); } @@ -3268,6 +3287,24 @@ impl<'a> Context<'a, '_> { self.menubar_menu_checkbox(text, accelerator, shortcut, false) } + /// EN: Appends a visible but non-interactive disabled button to the current menu. + /// 中文:在目前選單加入可見但不可互動的反灰按鈕。 + pub fn menubar_menu_button_disabled( + &mut self, + text: &str, + accelerator: char, + shortcut: InputKey, + ) { + self.table_next_row(); + self.attr_foreground_rgba(self.indexed(IndexedColor::BrightBlack)); + self.button_label( + "menu_button_disabled", + text, + ButtonStyle::default().bracketed(false).checked(false).accelerator(accelerator), + ); + self.menubar_shortcut(shortcut); + } + /// Appends a checkbox to the current menu. /// Returns true if the checkbox was activated. pub fn menubar_menu_checkbox( @@ -3793,7 +3830,7 @@ enum NodeContent<'a> { #[default] None, List(ListContent<'a>), - Modal(BString<'a>), // title + Modal(BString<'a>, bool), // title, centered Table(TableContent<'a>), Text(TextContent<'a>), Textarea(TextareaContent<'a>), diff --git a/i18n/edit.toml b/i18n/edit.toml index 11039636308..3b3d6398f5e 100644 --- a/i18n/edit.toml +++ b/i18n/edit.toml @@ -925,6 +925,28 @@ vi = "Xem" zh-hans = "视图" zh-hant = "檢視" +# EN: Markdown navigation pane labels. +# 中文:Markdown 導覽視窗文字。 +[ViewNavigation] +en = "Navigation Pane…" +zh-hans = "导航窗口…" +zh-hant = "導覽視窗…" + +[NavigationDialogTitle] +en = "[Navigation Pane]" +zh-hans = "[导航窗口]" +zh-hant = "[導覽視窗]" + +[NavigationNoContent] +en = "No headings or text" +zh-hans = "没有标题或文字" +zh-hant = "沒有標題或文字" + +[NavigationEscapeHint] +en = "ESC Cancel" +zh-hans = "ESC取消" +zh-hant = "ESC取消" + [ViewFocusStatusbar] en = "Focus Statusbar" ar = "تركيز شريط الحالة"