mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-03 08:02:02 +00:00
fix(terminal): keep output without a trailing newline through a resize
zsh's PROMPT_SP pads dangling output until it wraps onto the prompt's row. The grid took that wrap for the prompt's own first row, so the next resize at the prompt cleared the output and widening joined it onto the prompt. At the prompt marks, a padded wrap after dangling output is turned back into a hard break. Also: editor breadcrumbs, breadcrumb reveal and Problems labels match files against the project root's real path, so a project under a symlink (macOS /tmp) no longer shows the absolute path; the terminal font size setting says points, which is what its stepper shows.
This commit is contained in:
+1
-1
@@ -19,7 +19,7 @@ mod loopback;
|
||||
pub mod palette;
|
||||
pub(crate) mod pane_liveness;
|
||||
pub(crate) mod parked_cursor;
|
||||
mod prompt_reflow;
|
||||
pub(crate) mod prompt_reflow;
|
||||
mod remote;
|
||||
mod reverse_search;
|
||||
pub(crate) mod scrollbar;
|
||||
|
||||
@@ -48,17 +48,9 @@ pub(super) fn resize<T: EventListener>(term: &mut Term<T>, size: TermSize, shell
|
||||
}
|
||||
|
||||
let cursor = term.grid().cursor.point;
|
||||
let last = Column(term.columns() - 1);
|
||||
// Stops at the top of the screen: the shell's cursor-up stops there too,
|
||||
// so rows already in scrollback are not part of what it will move over.
|
||||
let mut start = cursor.line;
|
||||
while start > Line(0)
|
||||
&& term.grid()[Line(start.0 - 1)][last]
|
||||
.flags
|
||||
.contains(Flags::WRAPLINE)
|
||||
{
|
||||
start -= 1;
|
||||
}
|
||||
let start = wrapped_line_start(term, cursor.line);
|
||||
let rows_up = (cursor.line.0 - start.0) as usize;
|
||||
|
||||
term.grid_mut().cursor.point = Point::new(start, Column(0));
|
||||
@@ -75,6 +67,114 @@ pub(super) fn resize<T: EventListener>(term: &mut Term<T>, size: TermSize, shell
|
||||
term.grid_mut().cursor.point.column = cursor.column.min(last);
|
||||
}
|
||||
|
||||
/// The two prompt marks the shell brackets its prompt with.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PromptMark {
|
||||
/// OSC 133;A — a fresh prompt is about to be drawn.
|
||||
Opened,
|
||||
/// OSC 133;B — the prompt is drawn and input begins here.
|
||||
Ready,
|
||||
}
|
||||
|
||||
impl PromptMark {
|
||||
/// Reads an OSC payload (`133;A`, `133;B`, …).
|
||||
pub fn parse(payload: &[u8]) -> Option<Self> {
|
||||
let mark = payload.strip_prefix(b"133;")?;
|
||||
let (&kind, rest) = mark.split_first()?;
|
||||
if !(rest.is_empty() || rest.first() == Some(&b';')) {
|
||||
return None;
|
||||
}
|
||||
match kind {
|
||||
b'A' => Some(Self::Opened),
|
||||
b'B' => Some(Self::Ready),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the line break back between output that did not end in a newline and
|
||||
/// the prompt after it.
|
||||
///
|
||||
/// zsh's `PROMPT_SP` moves such a prompt onto a line of its own by writing
|
||||
/// the `%` end-of-line mark and then padding it with spaces until the cursor
|
||||
/// *wraps*. To the grid that is one wrapped line: `printf xyz` and the prompt
|
||||
/// under it read as a single line of text, so the reflow above took the
|
||||
/// output for the prompt's own first row and erased it on the next resize,
|
||||
/// and widening the pane joined the two onto one row.
|
||||
///
|
||||
/// The marks say where that happened. `A` comes from precmd, before
|
||||
/// `PROMPT_SP`: a cursor off column 0 there is output left dangling. By `B`
|
||||
/// the prompt is drawn, and if the row that output ended on is now padding
|
||||
/// all the way to its last column and wraps into the rows below, that wrap is
|
||||
/// the padding's, not the text's — so it becomes a hard break. A prompt that
|
||||
/// simply carried on from the output (bash has no `PROMPT_SP`) leaves its own
|
||||
/// text on that row rather than blanks, and is left alone.
|
||||
#[derive(Default)]
|
||||
pub struct PromptBreak {
|
||||
/// Where `A` found a dangling line: how many rows into its wrapped line
|
||||
/// the cursor was, the column, and the character it had just written —
|
||||
/// the last to recognize the row by again at `B`, which the prompt may
|
||||
/// have scrolled.
|
||||
dangling: Option<(usize, Column, char)>,
|
||||
}
|
||||
|
||||
impl PromptBreak {
|
||||
pub fn apply<T: EventListener>(&mut self, term: &mut Term<T>, mark: PromptMark) {
|
||||
match mark {
|
||||
PromptMark::Opened => {
|
||||
let cursor = term.grid().cursor.point;
|
||||
// A full row leaves the cursor parked on its last cell: the
|
||||
// text ends there, one column short of where it stands.
|
||||
let end = match term.grid().cursor.input_needs_wrap {
|
||||
true => cursor.column + 1,
|
||||
false => cursor.column,
|
||||
};
|
||||
self.dangling = (end.0 > 0).then(|| {
|
||||
let into = (cursor.line.0 - wrapped_line_start(term, cursor.line).0) as usize;
|
||||
let last = term.grid()[cursor.line][end - 1].c;
|
||||
(into, end, last)
|
||||
});
|
||||
}
|
||||
PromptMark::Ready => {
|
||||
let Some((into, end, last)) = self.dangling.take() else {
|
||||
return;
|
||||
};
|
||||
let cursor = term.grid().cursor.point.line;
|
||||
let row = wrapped_line_start(term, cursor) + into;
|
||||
if row >= cursor {
|
||||
return;
|
||||
}
|
||||
let cols = term.columns();
|
||||
let grid = term.grid();
|
||||
let padded = end.0 < cols
|
||||
&& grid[row][end - 1].c == last
|
||||
&& (end.0 + 1..cols).all(|col| grid[row][Column(col)].c == ' ')
|
||||
&& grid[row][Column(cols - 1)].flags.contains(Flags::WRAPLINE);
|
||||
if padded {
|
||||
term.grid_mut()[row][Column(cols - 1)]
|
||||
.flags
|
||||
.remove(Flags::WRAPLINE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first row of the wrapped line `line` is part of, stopping at the top of
|
||||
/// the screen as the shell's own cursor-up does.
|
||||
fn wrapped_line_start<T>(term: &Term<T>, line: Line) -> Line {
|
||||
let last = Column(term.columns() - 1);
|
||||
let mut start = line;
|
||||
while start > Line(0)
|
||||
&& term.grid()[Line(start.0 - 1)][last]
|
||||
.flags
|
||||
.contains(Flags::WRAPLINE)
|
||||
{
|
||||
start -= 1;
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -287,4 +387,86 @@ mod tests {
|
||||
resize(&mut term, TermSize::new(98, 12), true);
|
||||
assert_eq!(text(&term), ["status bar"]);
|
||||
}
|
||||
|
||||
/// What zsh 5.9 writes between precmd's `A` and the prompt when the last
|
||||
/// output did not end in a newline, as captured off a real pty: the
|
||||
/// standout `%` end-of-line mark, `cols - 1` spaces to force a wrap, then
|
||||
/// back to column 0 and clear below.
|
||||
fn prompt_sp(cols: usize) -> Vec<u8> {
|
||||
let mut out = b"\x1b[1m\x1b[7m%\x1b[27m\x1b[1m\x1b[0m".to_vec();
|
||||
out.extend(std::iter::repeat_n(b' ', cols - 1));
|
||||
out.extend_from_slice(b"\r \r\r\x1b[0m\x1b[27m\x1b[24m\x1b[J");
|
||||
out
|
||||
}
|
||||
|
||||
/// `printf xyz` at a zsh prompt, through a stream the reader has cut at
|
||||
/// the prompt marks, then drawn out at `cols`.
|
||||
fn dangling_output(cols: usize, guarded: bool) -> (Term<VoidListener>, Processor) {
|
||||
let (mut term, mut parser) = pane(cols);
|
||||
let mut marks = PromptBreak::default();
|
||||
parser.advance(&mut term, b"xyz");
|
||||
if guarded {
|
||||
marks.apply(&mut term, PromptMark::Opened);
|
||||
}
|
||||
parser.advance(&mut term, &prompt_sp(cols));
|
||||
parser.advance(&mut term, &zsh_redraw(cols, 0, ""));
|
||||
if guarded {
|
||||
marks.apply(&mut term, PromptMark::Ready);
|
||||
}
|
||||
(term, parser)
|
||||
}
|
||||
|
||||
/// `PROMPT_SP` wraps the dangling output's row into the prompt's. Taken
|
||||
/// for the prompt's own first row, that output was cleared by the next
|
||||
/// resize at the prompt — `printf xyz` simply vanished when a panel
|
||||
/// opened. Measured against the stream without the marks' fix, so the
|
||||
/// test also proves the scenario loses the output there.
|
||||
#[test]
|
||||
fn output_without_a_newline_survives_a_resize_at_the_prompt() {
|
||||
let narrow = |guarded: bool| {
|
||||
let (mut term, mut parser) = dangling_output(98, guarded);
|
||||
resize(&mut term, TermSize::new(60, 12), true);
|
||||
parser.advance(&mut term, &zsh_redraw(60, 0, ""));
|
||||
text(&term)
|
||||
};
|
||||
assert!(
|
||||
!narrow(false).iter().any(|row| row.starts_with("xyz")),
|
||||
"the scenario no longer loses the output without the fix"
|
||||
);
|
||||
assert_eq!(narrow(true), ["echo hi", "hi", "xyz%", &prompt_row(60)]);
|
||||
}
|
||||
|
||||
/// Widening joins wrapped rows back together, and the padding's wrap
|
||||
/// joined the output and the prompt onto one row.
|
||||
#[test]
|
||||
fn widening_keeps_dangling_output_off_the_prompt_row() {
|
||||
let (mut term, mut parser) = dangling_output(60, true);
|
||||
resize(&mut term, TermSize::new(98, 12), true);
|
||||
parser.advance(&mut term, &zsh_redraw(98, 0, ""));
|
||||
assert_eq!(text(&term), ["echo hi", "hi", "xyz%", &prompt_row(98)]);
|
||||
}
|
||||
|
||||
/// A prompt that carries straight on from the output — bash has no
|
||||
/// `PROMPT_SP` — wraps on its own text, not on padding, and that wrap is
|
||||
/// the shell's to count.
|
||||
#[test]
|
||||
fn a_prompt_continuing_the_output_row_keeps_its_wrap() {
|
||||
let (mut term, mut parser) = pane(20);
|
||||
let mut marks = PromptBreak::default();
|
||||
parser.advance(&mut term, b"xyz");
|
||||
marks.apply(&mut term, PromptMark::Opened);
|
||||
parser.advance(&mut term, b"user@host:~/some/long/path$ ");
|
||||
marks.apply(&mut term, PromptMark::Ready);
|
||||
let row: Line = term.grid().cursor.point.line - 1;
|
||||
assert!(term.grid()[row][Column(19)].flags.contains(Flags::WRAPLINE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_marks_parse() {
|
||||
assert_eq!(PromptMark::parse(b"133;A"), Some(PromptMark::Opened));
|
||||
assert_eq!(PromptMark::parse(b"133;B"), Some(PromptMark::Ready));
|
||||
assert_eq!(PromptMark::parse(b"133;A;k=s"), Some(PromptMark::Opened));
|
||||
assert_eq!(PromptMark::parse(b"133;C"), None);
|
||||
assert_eq!(PromptMark::parse(b"133;AB"), None);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -14,6 +14,7 @@ use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle};
|
||||
|
||||
use crate::terminal::command_cursor::{CommandCursorStyle, CommandMark};
|
||||
use crate::terminal::parked_cursor::{CursorCut, ParkedCursorRepair, ParkedCursorScanner};
|
||||
use crate::terminal::prompt_reflow::{PromptBreak, PromptMark};
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
@@ -1239,6 +1240,7 @@ impl RemoteTerminal {
|
||||
// against the bytes before its `D`, not the whole batch.
|
||||
let mut command_tok = OscTokenizer::new(&[b"133"]);
|
||||
let mut command_cursor = CommandCursorStyle::default();
|
||||
let mut prompt_break = PromptBreak::default();
|
||||
let mut pending: Vec<u8> = buffered;
|
||||
// Kitty-graphics decode runs on its own thread with newest-frame
|
||||
// coalescing (issue #213): inflating a full-window browser frame
|
||||
@@ -1335,6 +1337,9 @@ impl RemoteTerminal {
|
||||
ReaderCut::Command(mark) => {
|
||||
command_cursor.apply(term, mark)
|
||||
}
|
||||
ReaderCut::Prompt(mark) => {
|
||||
prompt_break.apply(term, mark)
|
||||
}
|
||||
},
|
||||
) else {
|
||||
return;
|
||||
@@ -1472,8 +1477,14 @@ impl RemoteTerminal {
|
||||
command_cuts(&mut command_tok, &bytes, &mut cuts);
|
||||
let fed =
|
||||
feed_grid(&term, &mut processor, &bytes, cuts, &quit, |term, cut| {
|
||||
if let ReaderCut::Command(mark) = cut {
|
||||
command_cursor.apply(term, mark);
|
||||
match cut {
|
||||
ReaderCut::Command(mark) => {
|
||||
command_cursor.apply(term, mark)
|
||||
}
|
||||
ReaderCut::Prompt(mark) => {
|
||||
prompt_break.apply(term, mark)
|
||||
}
|
||||
ReaderCut::Parked(_) => {}
|
||||
}
|
||||
});
|
||||
if fed.is_none() {
|
||||
@@ -3501,6 +3512,7 @@ mod chunking_tests {
|
||||
enum ReaderCut {
|
||||
Parked(CursorCut),
|
||||
Command(CommandMark),
|
||||
Prompt(PromptMark),
|
||||
}
|
||||
|
||||
/// Adds the batch's command marks to `cuts`, keeping them in stream order
|
||||
@@ -3510,6 +3522,8 @@ fn command_cuts(tok: &mut OscTokenizer, bytes: &[u8], cuts: &mut Vec<(usize, Rea
|
||||
tok.feed_at(bytes, |off, payload| {
|
||||
if let Some(mark) = CommandMark::parse(payload) {
|
||||
cuts.push((off, ReaderCut::Command(mark)));
|
||||
} else if let Some(mark) = PromptMark::parse(payload) {
|
||||
cuts.push((off, ReaderCut::Prompt(mark)));
|
||||
}
|
||||
});
|
||||
if before > 0 && cuts.len() > before {
|
||||
|
||||
@@ -754,7 +754,10 @@ impl Tty7App {
|
||||
} else {
|
||||
let roots = self.tab_code().map(|c| c.roots.as_slice()).unwrap_or(&[]);
|
||||
let local = f.host.id() == self.spawn_host(cx);
|
||||
path_segments(&f.path, if local { roots } else { &[] })
|
||||
match local {
|
||||
true => path_segments(&self.project_spelling(&f.path, cx), roots),
|
||||
false => path_segments(&f.path, &[]),
|
||||
}
|
||||
};
|
||||
let file = (f.untitled.is_none()).then(|| f.path.clone());
|
||||
// A rendered Markdown file keeps the path; its symbols are the source's.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, Context, ScrollHandle, Window, div, px};
|
||||
use gpui::{AnyElement, App, Context, ScrollHandle, Window, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
|
||||
use lsp_types::DiagnosticSeverity;
|
||||
@@ -108,12 +108,13 @@ impl Tty7App {
|
||||
|
||||
/// Where a file's name sits in the list: relative to the project the
|
||||
/// tab shows, when it is inside it.
|
||||
fn problems_label(&self, path: &Path) -> (String, String) {
|
||||
fn problems_label(&self, path: &Path, cx: &App) -> (String, String) {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
let dir = path.parent().map(|parent| {
|
||||
let spelled = self.project_spelling(path, cx);
|
||||
let dir = spelled.parent().map(|parent| {
|
||||
self.tab_code()
|
||||
.and_then(|c| {
|
||||
c.roots
|
||||
@@ -192,7 +193,7 @@ impl Tty7App {
|
||||
left += file.problems.len();
|
||||
continue;
|
||||
}
|
||||
let (name, dir) = self.problems_label(&file.path);
|
||||
let (name, dir) = self.problems_label(&file.path, cx);
|
||||
rows.push(
|
||||
h_flex()
|
||||
.id(("editor-problems-file", file_ix))
|
||||
|
||||
+38
-5
@@ -205,6 +205,11 @@ pub(crate) struct FileTreeState {
|
||||
unreadable: HashSet<DirKey>,
|
||||
stale: HashSet<DirKey>,
|
||||
repo_roots: ByHost<PathBuf, PathBuf>,
|
||||
/// A project root's real path, where it differs from the root as spelled
|
||||
/// (`/tmp/x` → `/private/tmp/x`, a symlinked checkout). The editor opens
|
||||
/// files by their real path, so this is how a file finds the root it is
|
||||
/// under — see [`Tty7App::project_spelling`].
|
||||
root_aliases: ByHost<PathBuf, PathBuf>,
|
||||
repo_root_loads: InFlight<DirKey>,
|
||||
search: SearchState,
|
||||
/// Every file under the project, for the search's Files tab
|
||||
@@ -252,6 +257,7 @@ impl FileTreeState {
|
||||
unreadable: HashSet::new(),
|
||||
stale: HashSet::new(),
|
||||
repo_roots: ByHost::default(),
|
||||
root_aliases: ByHost::default(),
|
||||
repo_root_loads: InFlight::default(),
|
||||
search: SearchState::default(),
|
||||
quick_open: Default::default(),
|
||||
@@ -683,6 +689,7 @@ impl FileTreeState {
|
||||
fn invalidate_repo_roots(&mut self) -> bool {
|
||||
let had = !self.repo_roots.is_empty() || !self.repo_root_loads.is_empty();
|
||||
self.repo_roots.clear();
|
||||
self.root_aliases.clear();
|
||||
self.repo_root_loads.invalidate_all();
|
||||
had
|
||||
}
|
||||
@@ -857,13 +864,18 @@ impl Tty7App {
|
||||
cx,
|
||||
{
|
||||
let cwd = cwd.clone();
|
||||
move |h| h.repo_root(&cwd).ok().flatten()
|
||||
move |h| {
|
||||
let root = h.repo_root(&cwd).ok().flatten().unwrap_or(cwd);
|
||||
let real = h.canonicalize(&root).ok().filter(|real| *real != root);
|
||||
(root, real)
|
||||
}
|
||||
},
|
||||
move |app, root, cx| {
|
||||
move |app, (root, real), cx| {
|
||||
if app.file_tree.repo_root_loads.finish(&key) {
|
||||
app.file_tree
|
||||
.repo_roots
|
||||
.insert(id, cwd.clone(), root.unwrap_or(cwd));
|
||||
if let Some(real) = real {
|
||||
app.file_tree.root_aliases.insert(id, root.clone(), real);
|
||||
}
|
||||
app.file_tree.repo_roots.insert(id, cwd, root);
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
@@ -1034,7 +1046,28 @@ impl Tty7App {
|
||||
/// no row to scroll to. An empty root list is not that: it means the panel
|
||||
/// has never drawn and does not know its roots yet, and the request
|
||||
/// outlives the render that fills them in.
|
||||
/// `path` as the tree spells it: under the project root as the tab names
|
||||
/// it, when `path` is that root's real path spelled out. The editor keeps
|
||||
/// files by their real path (`load_file` canonicalizes), the tree by the
|
||||
/// pane's — on macOS every project under `/tmp` differs that way, as does
|
||||
/// any checkout reached through a symlink. Anything else comes back as is.
|
||||
pub(crate) fn project_spelling(&self, path: &Path, cx: &App) -> PathBuf {
|
||||
let host = self.spawn_host(cx);
|
||||
let roots = self.tab_code().map(|c| c.roots.as_slice()).unwrap_or(&[]);
|
||||
if roots.iter().any(|root| path.starts_with(root)) {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
roots
|
||||
.iter()
|
||||
.find_map(|root| {
|
||||
let real = self.file_tree.root_aliases.get(host, root)?;
|
||||
Some(root.join(path.strip_prefix(real).ok()?))
|
||||
})
|
||||
.unwrap_or_else(|| path.to_path_buf())
|
||||
}
|
||||
|
||||
pub(crate) fn file_tree_reveal_path(&mut self, path: &Path, cx: &mut Context<Self>) -> bool {
|
||||
let path = &self.project_spelling(path, cx);
|
||||
let roots = self.tab_code().map(|c| c.roots.clone()).unwrap_or_default();
|
||||
if !roots.is_empty() && !roots.iter().any(|root| path.starts_with(root)) {
|
||||
return false;
|
||||
|
||||
+1
-1
@@ -275,7 +275,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::SettingsThemeIntroDesc => "Each theme sets its own light or dark look.",
|
||||
L10nKey::SettingsTypography => "Typography",
|
||||
L10nKey::SettingsFontSize => "Terminal font size",
|
||||
L10nKey::SettingsFontSizeDesc => "Terminal text size in pixels.",
|
||||
L10nKey::SettingsFontSizeDesc => "Terminal text size in points.",
|
||||
L10nKey::SettingsUiFontSize => "Interface font size",
|
||||
L10nKey::SettingsUiFontSizeDesc => "Text size for tabs, panels and settings.",
|
||||
L10nKey::SettingsUiFontFamily => "Interface font",
|
||||
|
||||
+1
-1
@@ -292,7 +292,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SettingsThemeIntroDesc => "テーマごとにライトかダークが決まります",
|
||||
L10nKey::SettingsTypography => "タイポグラフィ",
|
||||
L10nKey::SettingsFontSize => "ターミナルの文字サイズ",
|
||||
L10nKey::SettingsFontSizeDesc => "ターミナルテキストのサイズ(ピクセル)",
|
||||
L10nKey::SettingsFontSizeDesc => "ターミナルテキストのサイズ(ポイント)",
|
||||
L10nKey::SettingsUiFontSize => "画面の文字サイズ",
|
||||
L10nKey::SettingsUiFontSizeDesc => "タブ・パネル・設定などの文字サイズ",
|
||||
L10nKey::SettingsUiFontFamily => "画面のフォント",
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SettingsThemeIntroDesc => "每个主题自带浅色或深色外观。",
|
||||
L10nKey::SettingsTypography => "字体排版",
|
||||
L10nKey::SettingsFontSize => "终端字号",
|
||||
L10nKey::SettingsFontSizeDesc => "终端文字大小(像素)。",
|
||||
L10nKey::SettingsFontSizeDesc => "终端文字大小(磅)。",
|
||||
L10nKey::SettingsUiFontSize => "界面字号",
|
||||
L10nKey::SettingsUiFontSizeDesc => "标签页、面板、设置等非终端文字的大小。",
|
||||
L10nKey::SettingsUiFontFamily => "界面字体",
|
||||
|
||||
Reference in New Issue
Block a user