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
58 changes: 48 additions & 10 deletions crates/moon-ui-gpui/src/core_expert/pages/special/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@

use gpui::*;
use moon_ui::{
MoonAlert, MoonButton, MoonButtonVariant, MoonCheckbox, MoonInput, MoonPalette, h_flex, v_flex,
MoonAlert, MoonButton, MoonButtonVariant, MoonCheckbox, MoonInput, MoonNotification,
MoonPalette, MoonWindowExt as _, h_flex, v_flex,
};
use rust_i18n::t;

Expand Down Expand Up @@ -232,20 +233,57 @@ pub(super) fn own_bot(view: &Entity<CoreExpertView>, rm: &RemoteCtx<'_>, cx: &Ap
.into_any_element()
}

/// The PIN to type into the channel, as the core generated it, or a dash before it did.
/// The PIN to type into the channel, with a copy action while the core has supplied one.
pub(super) fn pin(rm: &RemoteCtx<'_>, p: MoonPalette, cx: &App) -> AnyElement {
let pin = rm
.snapshot
.and_then(|s| s.channel_pin.clone())
.filter(|pin| !pin.is_empty());
let shown = pin.is_some();
div()
.font_family(design::mono())
.child(caption(
pin.unwrap_or_else(|| "—".to_string()),
shown && rm.fresh,
p,
cx,
))
h_flex()
.debug_selector(|| "exp-sp-pin-row".to_string())
.w_full()
.items_center()
.gap(design::ui_px(cx, 6.0))
.child(
div()
.debug_selector(|| "exp-sp-pin-value".to_string())
.flex_none()
.font_family(design::mono())
.child(caption(
pin.clone().unwrap_or_else(|| "—".to_string()),
shown && rm.fresh,
p,
cx,
)),
)
.children(pin.map(|pin| {
div()
.debug_selector(|| "exp-sp-copy-pin".to_string())
.flex_none()
.child(
MoonButton::new("exp-sp-copy-pin-button")
.xsmall()
.mono(false)
.label(t!("core_cmds.common.copy").to_string())
.variant(MoonButtonVariant::Neutral)
// Keep the full localized label beside the PIN at the window minimum.
.padding_x(4.0)
.on_click(move |_, window, app| {
app.write_to_clipboard(ClipboardItem::new_string(pin.clone()));
window.push_notification(
MoonNotification::success(
t!("core_expert.remote_pin_copied").to_string(),
),
app,
);
})
.render(),
)
.into_any_element()
}))
.into_any_element()
}

#[cfg(test)]
mod tests;
184 changes: 184 additions & 0 deletions crates/moon-ui-gpui/src/core_expert/pages/special/remote/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! PIN-row interaction and narrow-window layout in every shipped locale.

use gpui::{
AppContext, Bounds, Context, InteractiveElement, IntoElement, Modifiers, ParentElement, Pixels,
Render, Styled, VisualTestContext, Window, div, px,
};
use moon_core::{
config::{Language, UiThemeMode},
feed::CoreTelegramState,
};
use moon_ui::{MoonPalette, MoonTheme, Root, h_flex, v_flex};
use rust_i18n::t;

use crate::{
core_expert::{MIN_SIZE, remote::RemoteBlock},
design,
};

use super::{RemoteCtx, pin};

const PIN: &str = "739182";

/// Render the production PIN row in the narrowest column its real three-column page can give it.
struct Host {
pin: Option<&'static str>,
}

impl Render for Host {
/// Reserve the expert rail's maximum share of the minimum window, then lay out three equal
/// remote-page columns with the production PIN row in the middle one.
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let snapshot = CoreTelegramState {
settings_supported: true,
channel_pin: self.pin.map(str::to_string),
..Default::default()
};
let block = RemoteBlock::default();
let remote = RemoteCtx {
block: &block,
snapshot: Some(&snapshot),
fresh: true,
track: None,
core_name: None,
selected_cores: 1,
};
h_flex()
.size_full()
.child(div().flex_none().w(px(MIN_SIZE.0 * 0.45)))
.child(
v_flex()
.flex_1()
.min_w_0()
.px(design::ui_px(cx, design::HEADER_PAD_X))
.child(
h_flex()
.w_full()
.items_start()
.gap(design::ui_px(cx, 10.0))
.child(v_flex().flex_1().min_w_0())
.child(
v_flex()
.debug_selector(|| "exp-sp-pin-test-column".to_string())
.flex_1()
.min_w_0()
.child(pin(&remote, MoonPalette::active(cx), cx)),
)
.child(v_flex().flex_1().min_w_0()),
),
)
}
}

/// Open and paint the PIN host at the expert window's real minimum dimensions.
fn open_pin_window(cx: &mut gpui::TestAppContext, pin: Option<&'static str>) -> VisualTestContext {
let handle = cx.open_window(gpui::size(px(MIN_SIZE.0), px(MIN_SIZE.1)), |window, cx| {
let host = cx.new(|_| Host { pin });
Root::new(host, window, cx)
});
let mut visual = VisualTestContext::from_window(handle.into(), cx);
for _ in 0..3 {
visual.update(|window, app| {
let _ = window.draw(app);
});
}
visual
}

/// Whether one laid-out rectangle stays inside another, allowing only fractional-pixel rounding.
fn inside(child: Bounds<Pixels>, parent: Bounds<Pixels>) -> bool {
child.origin.x + px(0.5) >= parent.origin.x
&& child.right() <= parent.right() + px(0.5)
&& child.origin.y + px(0.5) >= parent.origin.y
&& child.bottom() <= parent.bottom() + px(0.5)
}

/// A wide translation or missing flex constraint would push the PIN or its copy action out of the
/// narrowest column at the window minimum.
#[gpui::test]
fn pin_and_copy_fit_every_locale_at_minimum_window_width(cx: &mut gpui::TestAppContext) {
cx.update(moon_ui::init);
cx.update(|app| {
MoonTheme::install_config(
crate::startup::moon_theme_config_for_presentation(UiThemeMode::Light, 1.0),
app,
)
});
for language in Language::ALL {
let _locale = crate::test_locale::force(language.code());
let mut visual = open_pin_window(cx, Some(PIN));
let at = language.code();
let column = visual
.debug_bounds("exp-sp-pin-test-column")
.unwrap_or_else(|| panic!("PIN column not laid out ({at})"));
let row = visual
.debug_bounds("exp-sp-pin-row")
.unwrap_or_else(|| panic!("PIN row not laid out ({at})"));
let value = visual
.debug_bounds("exp-sp-pin-value")
.unwrap_or_else(|| panic!("PIN value not laid out ({at})"));
let button = visual
.debug_bounds("exp-sp-copy-pin")
.unwrap_or_else(|| panic!("copy button not laid out ({at})"));
assert!(inside(row, column), "PIN row escaped its column ({at})");
assert!(inside(value, row), "PIN escaped its row ({at})");
assert!(
inside(button, row),
"copy button escaped its row ({at}): column {column:?}, row {row:?}, value {value:?}, button {button:?}"
);
assert!(
value.right() <= button.origin.x + px(0.5),
"PIN and copy button overlap ({at})"
);
let required_button_width = visual.update(|_window, app| {
design::ui_caption_text_width(app, &t!("core_cmds.common.copy"), 400.0) + 2.0 * 4.0
});
assert!(
f32::from(button.size.width) + 0.5 >= required_button_width,
"copy label is clipped ({at}): button {}, required {required_button_width}",
f32::from(button.size.width)
);
visual.update(|window, _| window.remove_window());
}
}

/// Missing and empty snapshots must offer no copy control whose payload could be blank.
#[gpui::test]
fn absent_pin_has_no_copy_button(cx: &mut gpui::TestAppContext) {
cx.update(moon_ui::init);
let _locale = crate::test_locale::force("en");
for supplied in [None, Some("")] {
let mut visual = open_pin_window(cx, supplied);
assert!(
visual.debug_bounds("exp-sp-pin-row").is_some(),
"dash placeholder row missing"
);
assert!(
visual.debug_bounds("exp-sp-copy-pin").is_none(),
"copy offered without a real PIN"
);
visual.update(|window, _| window.remove_window());
}
}

/// Clicking the production MoonButton must put the exact snapshot text, not a placeholder or a
/// reformatted value, on the clipboard and confirm the action through the existing notification layer.
#[gpui::test]
fn copy_button_writes_the_exact_pin(cx: &mut gpui::TestAppContext) {
cx.update(moon_ui::init);
let _locale = crate::test_locale::force("en");
let mut visual = open_pin_window(cx, Some(PIN));
let button = visual
.debug_bounds("exp-sp-copy-pin")
.expect("copy button with a real PIN");
visual.simulate_click(button.center(), Modifiers::none());
let copied = visual
.read_from_clipboard()
.and_then(|item| item.text())
.expect("PIN copied to clipboard");
assert_eq!(copied, PIN);
let notifications =
visual.update(|window, app| Root::read(window, app).notification_count(app));
assert_eq!(notifications, 1, "copy gave no success notification");
visual.update(|window, _| window.remove_window());
}
1 change: 1 addition & 0 deletions locales/en/core_expert.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Your bot's token"
core_expert.remote_own_bot_needs_token: "Your own bot needs a token: the core does not hold one yet."
core_expert.remote_bot_saved: "The core saved the bot settings."
core_expert.remote_pin_ready: "The core issued a PIN — send it to your channel."
core_expert.remote_pin_copied: "PIN copied to clipboard"
core_expert.remote_channel_reset: "The core unbound the channel."
core_expert.remote_waiting: "Waiting for the core's Telegram state. If it never arrives, the core does not send one, and the channel and own bot are not available here."
core_expert.sp_udp_server: "UDP server: %{v}"
Expand Down
1 change: 1 addition & 0 deletions locales/es/core_expert.es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Token de su bot"
core_expert.remote_own_bot_needs_token: "Su propio bot necesita un token: el núcleo aún no guarda ninguno."
core_expert.remote_bot_saved: "El núcleo guardó los ajustes del bot."
core_expert.remote_pin_ready: "El núcleo emitió un PIN: envíelo a su canal."
core_expert.remote_pin_copied: "PIN copiado al portapapeles"
core_expert.remote_channel_reset: "El núcleo desvinculó el canal."
core_expert.remote_waiting: "Esperando el estado de Telegram del núcleo. Si nunca llega, el núcleo no lo envía y el canal y el bot propio no están disponibles aquí."
core_expert.sp_udp_server: "Servidor UDP: %{v}"
Expand Down
1 change: 1 addition & 0 deletions locales/pt/core_expert.pt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Token do seu bot"
core_expert.remote_own_bot_needs_token: "O seu próprio bot precisa de um token: o núcleo ainda não guarda nenhum."
core_expert.remote_bot_saved: "O núcleo guardou as definições do bot."
core_expert.remote_pin_ready: "O núcleo emitiu um PIN — envie-o para o seu canal."
core_expert.remote_pin_copied: "PIN copiado para a área de transferência"
core_expert.remote_channel_reset: "O núcleo desvinculou o canal."
core_expert.remote_waiting: "A aguardar o estado do Telegram do núcleo. Se nunca chegar, o núcleo não o envia, e o canal e o bot próprio não estão disponíveis aqui."
core_expert.sp_udp_server: "Servidor UDP: %{v}"
Expand Down
1 change: 1 addition & 0 deletions locales/ru/core_expert.ru.yml
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Токен своего бота"
core_expert.remote_own_bot_needs_token: "Для своего бота нужен токен: ядро его ещё не хранит."
core_expert.remote_bot_saved: "Ядро сохранило настройки бота."
core_expert.remote_pin_ready: "Ядро выдало PIN — отправьте его в свой канал."
core_expert.remote_pin_copied: "PIN-код скопирован в буфер обмена"
core_expert.remote_channel_reset: "Ядро отвязало канал."
core_expert.remote_waiting: "Ждём состояние Telegram от ядра. Если оно так и не придёт, ядро его не присылает — канал и свой бот отсюда недоступны."
core_expert.sp_udp_server: "UDP сервер: %{v}"
Expand Down
1 change: 1 addition & 0 deletions locales/tr/core_expert.tr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Botunuzun tokeni"
core_expert.remote_own_bot_needs_token: "Kendi botunuz için token gerekir: çekirdekte henüz yok."
core_expert.remote_bot_saved: "Çekirdek bot ayarlarını kaydetti."
core_expert.remote_pin_ready: "Çekirdek bir PIN verdi — kanalınıza gönderin."
core_expert.remote_pin_copied: "PIN panoya kopyalandı"
core_expert.remote_channel_reset: "Çekirdek kanal bağını kaldırdı."
core_expert.remote_waiting: "Çekirdeğin Telegram durumu bekleniyor. Hiç gelmezse çekirdek bunu göndermiyor demektir; kanal ve kendi bot buradan kullanılamaz."
core_expert.sp_udp_server: "UDP sunucusu: %{v}"
Expand Down
1 change: 1 addition & 0 deletions locales/uk/core_expert.uk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Токен свого бота"
core_expert.remote_own_bot_needs_token: "Для свого бота потрібен токен: ядро його ще не зберігає."
core_expert.remote_bot_saved: "Ядро зберегло налаштування бота."
core_expert.remote_pin_ready: "Ядро видало PIN — надішліть його у свій канал."
core_expert.remote_pin_copied: "PIN-код скопійовано в буфер обміну"
core_expert.remote_channel_reset: "Ядро відв'язало канал."
core_expert.remote_waiting: "Чекаємо стан Telegram від ядра. Якщо він так і не прийде, ядро його не надсилає — канал і свій бот звідси недоступні."
core_expert.sp_udp_server: "UDP-сервер: %{v}"
Expand Down
1 change: 1 addition & 0 deletions locales/vi/core_expert.vi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ core_expert.remote_token_none: "Token bot của bạn"
core_expert.remote_own_bot_needs_token: "Bot riêng cần token: lõi chưa lưu token nào."
core_expert.remote_bot_saved: "Lõi đã lưu cài đặt bot."
core_expert.remote_pin_ready: "Lõi đã cấp PIN — hãy gửi nó vào kênh của bạn."
core_expert.remote_pin_copied: "Đã sao chép PIN vào clipboard"
core_expert.remote_channel_reset: "Lõi đã hủy liên kết kênh."
core_expert.remote_waiting: "Đang chờ trạng thái Telegram từ lõi. Nếu không bao giờ đến, lõi không gửi nó, và kênh cùng bot riêng không khả dụng ở đây."
core_expert.sp_udp_server: "Máy chủ UDP: %{v}"
Expand Down
Loading