Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/herdr-gpui/config-gpui.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
# none. Uninstalled names are ignored, and at most 8 are accepted per section.
# With none installed and [terminal] fallback unset, a pane showing an icon
# brings up a dismissible card naming a Nerd Font to install.
# [sidebar], [tabs], and [ui] then fall back to the platform's Chinese,
# Japanese, and Korean faces (PingFang and Hiragino on macOS, Microsoft YaHei
# and Yu Gothic on Windows, Noto Sans CJK on Linux), the one matching the
# first CJK language in `language` or the system's languages first, so Han
# characters take that region's shapes. [terminal] keeps the icons alone.
# Saved changes to the local file reload automatically in every GUI window.

# Built-ins: Default, Nord, Dracula, Catppuccin Mocha, Catppuccin Latte, plus
Expand Down
53 changes: 15 additions & 38 deletions crates/herdr-gpui/src/browser/annotate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! bounded, stripped of control characters, and marked as quoted data in the
//! prompt, which is pasted into an agent's terminal.
use super::{Location, Tab};
use crate::notifications::safe_text;
use crate::truncate::one_line;
use serde::Deserialize;
use std::{path::PathBuf, sync::Arc};

Expand Down Expand Up @@ -162,29 +162,6 @@ pub(crate) enum Report {
},
}

/// One line of page text: no controls or direction overrides, no runs of
/// whitespace, and bounded.
fn line(text: &str, limit: usize) -> String {
// Breaks and controls become spaces first, so words never run together.
let spaced: String = text
.chars()
.take(limit * 4)
.map(|c| {
if c.is_whitespace() || c.is_control() {
' '
} else {
c
}
})
.collect();
let text = safe_text(&spaced, limit * 4);
let text: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
match text.char_indices().nth(limit) {
Some((end, _)) => format!("{}\u{2026}", &text[..end]),
None => text,
}
}

fn tag(text: &str) -> String {
let tag: String = text
.chars()
Expand Down Expand Up @@ -219,10 +196,10 @@ impl Report {
rect,
} => Self::Picked {
anchor: Anchor::Element {
selector: line(&selector, 1000),
selector: one_line(&selector, 1000),
tag: tag(&name),
text: line(&text, 300),
html: line(&html, 2000),
text: one_line(&text, 300),
html: one_line(&html, 2000),
},
shot: rect,
},
Expand All @@ -233,9 +210,9 @@ impl Report {
rect,
} => Self::Picked {
anchor: Anchor::Selection {
selector: line(&selector, 1000),
selector: one_line(&selector, 1000),
tag: tag(&name),
quote: line(&quote, 1000),
quote: one_line(&quote, 1000),
},
shot: rect,
},
Expand All @@ -258,17 +235,17 @@ impl Report {
anchor: Anchor::Region {
rect: rect.offset(scroll.x, scroll.y),
viewport: (viewport.width.max(0.), viewport.height.max(0.)),
container: line(&container, 1000),
container: one_line(&container, 1000),
covers: covers
.into_iter()
.take(MAX_COVERED)
.map(|covered| Covered {
selector: line(&covered.selector, 1000),
selector: one_line(&covered.selector, 1000),
tag: tag(&covered.tag),
text: line(&covered.text, 120),
text: one_line(&covered.text, 120),
})
.collect(),
text: line(&text, 600),
text: one_line(&text, 600),
},
shot: Some(rect),
}
Expand All @@ -282,7 +259,7 @@ impl Report {
/// JSON without characters that could steer a terminal. Too much data
/// is refused rather than cut into JSON that does not parse.
fn sent(summary: &str, data: &serde_json::Value) -> Option<Self> {
let summary = line(summary, MAX_SUMMARY_CHARS);
let summary = one_line(summary, MAX_SUMMARY_CHARS);
let data = match data {
serde_json::Value::Null => String::new(),
data => serde_json::to_string(data).ok()?,
Expand Down Expand Up @@ -343,16 +320,16 @@ impl Anchor {
pub(crate) fn summary(&self) -> String {
match self {
Self::Element { tag, text, .. } if text.is_empty() => format!("<{tag}>"),
Self::Element { tag, text, .. } => format!("<{tag}> {}", line(text, 60)),
Self::Selection { quote, .. } => format!("\u{201c}{}\u{201d}", line(quote, 60)),
Self::Element { tag, text, .. } => format!("<{tag}> {}", one_line(text, 60)),
Self::Selection { quote, .. } => format!("\u{201c}{}\u{201d}", one_line(quote, 60)),
Self::Region { rect, .. } => format!(
"Region {}\u{00d7}{}",
rect.width.round(),
rect.height.round()
),
Self::Page => "Whole page".into(),
Self::Sent { summary, .. } if summary.is_empty() => "Page result".into(),
Self::Sent { summary, .. } => format!("Page result: {}", line(summary, 60)),
Self::Sent { summary, .. } => format!("Page result: {}", one_line(summary, 60)),
}
}

Expand Down Expand Up @@ -398,7 +375,7 @@ impl Note {
}

pub(crate) fn new(anchor: Anchor, comment: &str) -> Option<Self> {
let comment = line(comment, MAX_COMMENT_CHARS);
let comment = one_line(comment, MAX_COMMENT_CHARS);
(!comment.is_empty()).then_some(Self {
anchor,
comment,
Expand Down
7 changes: 4 additions & 3 deletions crates/herdr-gpui/src/browser/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,11 @@ pub(crate) fn scope(endpoint: &crate::endpoint::Endpoint) -> Scope {
}

/// Page titles run long; a tab shows the start of one, like a web browser.
/// The budget is in columns, so a CJK title is cut about as wide.
pub(super) fn tab_label(title: &str) -> SharedString {
const MAX_CHARS: usize = 28;
match title.char_indices().nth(MAX_CHARS) {
Some((end, _)) => format!("{}\u{2026}", title[..end].trim_end()).into(),
const MAX_COLUMNS: usize = 28;
match crate::truncate::cut_width(title, MAX_COLUMNS) {
Some(kept) => format!("{}{}", kept.trim_end(), crate::truncate::ELLIPSIS).into(),
None => title.to_owned().into(),
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/herdr-gpui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub(crate) mod backdrop;
mod bitmap_fonts;
mod boat;
pub(crate) mod chat;
mod cjk_fonts;
mod cloud;
mod coder;
mod container;
Expand Down
107 changes: 107 additions & 0 deletions crates/herdr-gpui/src/config/cjk_fonts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! The interface faces' cascade for Chinese, Japanese, and Korean text.
//!
//! Han characters share codepoints across regions, so a platform's own
//! cascade draws them in whichever region's shapes it finds first, often
//! Japanese for a Chinese reader. Naming the right regional family first, by
//! the languages the user prefers, draws them as that reader expects. The
//! terminal keeps its own cascade: its cells need monospace widths these
//! proportional faces do not have.

use herdr_i18n::{CjkScript, Language, LanguageIdentifier};
use std::collections::BTreeSet;

/// A platform's CJK families by the script they draw, in the order searched
/// when no preferred language names a script. A script may list several
/// families; the first installed one serves it.
pub(super) type CjkFonts = &'static [(CjkScript, &'static str)];

/// Every macOS release since 10.11 ships PingFang, Hiragino Sans, and Apple
/// SD Gothic Neo.
pub(super) const MACOS_CJK_FONTS: CjkFonts = &[
(CjkScript::Simplified, "PingFang SC"),
(CjkScript::Traditional, "PingFang TC"),
(CjkScript::HongKong, "PingFang HK"),
(CjkScript::Japanese, "Hiragino Sans"),
(CjkScript::Korean, "Apple SD Gothic Neo"),
];

/// The `UI` cuts are the interface variants Windows draws its own chrome
/// with. Microsoft JhengHei serves Hong Kong as well as Taiwan.
pub(super) const WINDOWS_CJK_FONTS: CjkFonts = &[
(CjkScript::Simplified, "Microsoft YaHei UI"),
(CjkScript::Traditional, "Microsoft JhengHei UI"),
(CjkScript::HongKong, "Microsoft JhengHei UI"),
(CjkScript::Japanese, "Yu Gothic UI"),
(CjkScript::Korean, "Malgun Gothic"),
];

/// Distributions package Noto Sans CJK, its Source Han Sans twin, or only
/// the older WenQuanYi and Droid faces, which draw Chinese shapes.
pub(super) const LINUX_CJK_FONTS: CjkFonts = &[
(CjkScript::Simplified, "Noto Sans CJK SC"),
(CjkScript::Traditional, "Noto Sans CJK TC"),
(CjkScript::HongKong, "Noto Sans CJK HK"),
(CjkScript::Japanese, "Noto Sans CJK JP"),
(CjkScript::Korean, "Noto Sans CJK KR"),
(CjkScript::Simplified, "Source Han Sans SC"),
(CjkScript::Traditional, "Source Han Sans TC"),
(CjkScript::HongKong, "Source Han Sans HC"),
(CjkScript::Japanese, "Source Han Sans"),
(CjkScript::Korean, "Source Han Sans K"),
(CjkScript::Simplified, "WenQuanYi Micro Hei"),
(CjkScript::Simplified, "WenQuanYi Zen Hei"),
(CjkScript::Simplified, "Droid Sans Fallback"),
];

pub(super) const PLATFORM_CJK_FONTS: CjkFonts = if cfg!(target_os = "linux") {
LINUX_CJK_FONTS
} else if cfg!(windows) {
WINDOWS_CJK_FONTS
} else {
MACOS_CJK_FONTS
};

/// The script whose glyphs the user reads Han characters in: from the
/// `language` setting when it names a CJK language, else the first such
/// language the system prefers. The setting counts even before messages in
/// that language exist, since the glyphs are wanted either way.
pub(super) fn preferred_script(
language: &Language,
system: &[LanguageIdentifier],
) -> Option<CjkScript> {
let configured = match language {
Language::Locale(locale) if locale.pseudo().is_none() => Some(locale.id()),
Language::Locale(_) | Language::System => None,
};
CjkScript::preferred(configured.into_iter().chain(system))
}

/// The installed families of `fonts` to search for CJK glyphs: the first
/// installed family of each script, the `preferred` script's first and the
/// rest in table order, each family once.
pub(super) fn cjk_fallbacks(
fonts: CjkFonts,
preferred: Option<CjkScript>,
installed: &BTreeSet<String>,
) -> Vec<String> {
let mut scripts: Vec<CjkScript> = preferred.into_iter().collect();
for (script, _) in fonts {
if !scripts.contains(script) {
scripts.push(*script);
}
}
let mut families: Vec<String> = Vec::with_capacity(scripts.len());
for script in scripts {
let family = fonts
.iter()
.filter(|(candidate, _)| *candidate == script)
.map(|(_, family)| *family)
.find(|family| installed.contains(*family));
if let Some(family) = family
&& !families.iter().any(|chosen| chosen == family)
{
families.push(family.to_owned());
}
}
families
}
18 changes: 14 additions & 4 deletions crates/herdr-gpui/src/config/fonts.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! Font faces, their configured overrides and sizes, and the icon-font cascade.
//! Font faces, their configured overrides and sizes, and the icon-font and
//! CJK cascades.
use super::Error;
use super::{
Config,
Config, cjk_fonts,
font_weight::{self, MissingWeight},
};
use crate::Result;
Expand Down Expand Up @@ -427,6 +428,8 @@ impl Config {
I: IntoIterator<Item = String>,
{
self.replace_undrawable_fonts(super::bitmap_fonts::is_undrawable);
let cjk_script =
cjk_fonts::preferred_script(&self.language, &herdr_i18n::system_languages());
let terminal_detects = self.terminal.fallbacks.is_none();
let configured = [&self.sidebar, &self.tabs, &self.terminal, &self.ui];
let requested: BTreeSet<&str> = configured
Expand Down Expand Up @@ -463,13 +466,20 @@ impl Config {
let [_, _, terminal, _] = &faces;
let terminal_draws_icons =
draws_icons(&terminal.family) && !self.missing_fonts.contains(&terminal.family);
let cjk = cjk_fonts::cjk_fallbacks(cjk_fonts::PLATFORM_CJK_FONTS, cjk_script, &installed);
let detected = symbol_fallbacks(installed);
// Only the terminal draws prompts; an explicit `fallback`, even `[]`,
// is a choice the user already made.
self.icon_font_missing = detected.is_empty() && terminal_detects && !terminal_draws_icons;
for face in faces {
let [sidebar, tabs, terminal, ui] = faces;
if terminal.fallbacks.is_none() {
terminal.fallbacks = Some(detected.clone());
}
// Icons first, so a CJK family that also maps the Private Use Area
// never draws a prompt icon.
for face in [sidebar, tabs, ui] {
if face.fallbacks.is_none() {
face.fallbacks = Some(detected.clone());
face.fallbacks = Some(detected.iter().chain(&cjk).cloned().collect());
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/herdr-gpui/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod backdrop;
mod bitmap_fonts;
mod bold_color;
mod change_counts;
mod cjk_fonts;
mod cloud_setup;
mod code;
mod code_navigation;
Expand Down
Loading
Loading