fix: some tui work in layout

This commit is contained in:
2026-08-11 14:44:16 +02:00
parent 9e0e37b875
commit 64c90aa1d2
4 changed files with 90 additions and 12 deletions

View File

@@ -33,8 +33,10 @@ editor, or form. A paste containing control characters is rejected and never
submits a command or creates extra entry lines. Losing terminal ownership submits a command or creates extra entry lines. Losing terminal ownership
(including terminal suspension/focus loss) immediately revokes the lease, (including terminal suspension/focus loss) immediately revokes the lease,
cancels Git and clipboard work, removes plaintext presentation state, and cancels Git and clipboard work, removes plaintext presentation state, and
returns to the locked screen. `NO_COLOR` or `TERM=dumb` selects the monochrome returns to the locked screen. A native authentication prompt may temporarily
fallback while preserving focused selections through reverse-video attributes. own focus; its result stays hidden until terminal focus returns. `NO_COLOR` or
`TERM=dumb` selects the monochrome fallback while preserving focused selections
through reverse-video attributes.
Selected rows override nested field colors so labels, masked values, metadata, Selected rows override nested field colors so labels, masked values, metadata,
and OTP codes keep one high-contrast foreground across the complete selection. and OTP codes keep one high-contrast foreground across the complete selection.

View File

@@ -87,6 +87,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
let mut authentication = None; let mut authentication = None;
let mut git_control = None; let mut git_control = None;
let mut authentication_initialized = false; let mut authentication_initialized = false;
let mut terminal_focused = true;
let mut key_resolver = KeyResolver::default(); let mut key_resolver = KeyResolver::default();
let color_capability = ui::ColorCapability::detect(); let color_capability = ui::ColorCapability::detect();
let startup = app.begin_latest_request(); let startup = app.begin_latest_request();
@@ -120,7 +121,8 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
Err(error) => app.authentication_failed(error), Err(error) => app.authentication_failed(error),
} }
} }
if let Some(coordinator) = authentication.as_mut() if terminal_focused
&& let Some(coordinator) = authentication.as_mut()
&& let Some(event) = coordinator.completion() && let Some(event) = coordinator.completion()
{ {
apply_authentication_event( apply_authentication_event(
@@ -252,12 +254,18 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
} }
pasted.zeroize(); pasted.zeroize();
} }
Event::FocusGained => terminal_focused = true,
Event::FocusLost => { Event::FocusLost => {
handle_terminal_ownership_lost( terminal_focused = false;
let native_prompt_pending = authentication
.as_ref()
.is_some_and(AuthenticationCoordinator::native_prompt_pending);
handle_terminal_focus_lost(
&mut app, &mut app,
&mut authentication, &mut authentication,
&mut git_control, &mut git_control,
&mut clipboard_cancellations, &mut clipboard_cancellations,
native_prompt_pending,
); );
} }
_ => {} _ => {}
@@ -266,6 +274,19 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
Ok(()) Ok(())
} }
fn handle_terminal_focus_lost(
app: &mut App,
authentication: &mut Option<AuthenticationCoordinator>,
git_control: &mut Option<ironstorage::git::GitOperationControl>,
clipboard_cancellations: &mut ClipboardCancellations,
native_prompt_pending: bool,
) {
if native_prompt_pending {
return;
}
handle_terminal_ownership_lost(app, authentication, git_control, clipboard_cancellations);
}
fn current_unix_seconds() -> Result<u64, std::time::SystemTimeError> { fn current_unix_seconds() -> Result<u64, std::time::SystemTimeError> {
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -1620,6 +1641,24 @@ mod tests {
assert_eq!(presentation.0, 0); assert_eq!(presentation.0, 0);
} }
#[test]
fn native_authentication_focus_handoff_waits_without_relocking() {
let mut app = App::new();
let mut authentication = None;
let mut git_control = None;
let mut clipboard = ClipboardCancellations::default();
handle_terminal_focus_lost(
&mut app,
&mut authentication,
&mut git_control,
&mut clipboard,
true,
);
assert_eq!(app.mode(), crate::app::Mode::Browser);
}
#[test] #[test]
fn authentication_expiry_cancels_the_active_clipboard_presentation() { fn authentication_expiry_cancels_the_active_clipboard_presentation() {
let mut app = App::new(); let mut app = App::new();

View File

@@ -53,6 +53,7 @@ pub struct AuthenticationCoordinator {
sender: Sender<AuthenticationCompletion>, sender: Sender<AuthenticationCompletion>,
receiver: Receiver<AuthenticationCompletion>, receiver: Receiver<AuthenticationCompletion>,
generation: u64, generation: u64,
native_prompt_pending: bool,
} }
impl AuthenticationCoordinator { impl AuthenticationCoordinator {
@@ -71,6 +72,7 @@ impl AuthenticationCoordinator {
sender, sender,
receiver, receiver,
generation: 0, generation: 0,
native_prompt_pending: false,
}) })
} }
@@ -96,6 +98,7 @@ impl AuthenticationCoordinator {
fn request(&mut self, target: AuthenticationTarget) { fn request(&mut self, target: AuthenticationTarget) {
self.generation = self.generation.wrapping_add(1); self.generation = self.generation.wrapping_add(1);
self.native_prompt_pending = self.handle.is_none();
let generation = self.generation; let generation = self.generation;
let session = self.session.clone(); let session = self.session.clone();
let key = self.key.clone(); let key = self.key.clone();
@@ -118,6 +121,7 @@ impl AuthenticationCoordinator {
.try_iter() .try_iter()
.filter(|completion| completion.generation == self.generation) .filter(|completion| completion.generation == self.generation)
.last()?; .last()?;
self.native_prompt_pending = false;
match completion.result { match completion.result {
Ok(handle) => { Ok(handle) => {
self.handle = Some(handle); self.handle = Some(handle);
@@ -172,8 +176,13 @@ impl AuthenticationCoordinator {
self.handle.clone() self.handle.clone()
} }
pub const fn native_prompt_pending(&self) -> bool {
self.native_prompt_pending
}
pub fn lock(&mut self) -> Result<(), String> { pub fn lock(&mut self) -> Result<(), String> {
self.generation = self.generation.wrapping_add(1); self.generation = self.generation.wrapping_add(1);
self.native_prompt_pending = false;
self.handle = None; self.handle = None;
self.session self.session
.manual_lock() .manual_lock()

View File

@@ -419,14 +419,7 @@ fn render_large_otp(
let inner_width = usize::from(area.width.saturating_sub(2)); let inner_width = usize::from(area.width.saturating_sub(2));
let mut lines = glyphs let mut lines = glyphs
.iter() .iter()
.map(|row| { .map(|row| large_otp_line(row))
Line::styled(
row.as_str(),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
lines.push(Line::styled( lines.push(Line::styled(
countdown_bar(remaining, period, inner_width), countdown_bar(remaining, period, inner_width),
@@ -444,6 +437,26 @@ fn render_large_otp(
); );
} }
fn large_otp_line(row: &str) -> Line<'_> {
let digit_style = Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD);
Line::from(
row.split(' ')
.enumerate()
.flat_map(|(index, digits)| {
[
(index != 0).then(|| Span::raw(" ")),
(!digits.is_empty()).then(|| Span::styled(digits, digit_style)),
]
.into_iter()
.flatten()
})
.collect::<Vec<_>>(),
)
}
fn render_compact_otp( fn render_compact_otp(
frame: &mut Frame, frame: &mut Frame,
display: &crate::app::OtpDisplay, display: &crate::app::OtpDisplay,
@@ -1149,6 +1162,21 @@ mod tests {
assert_eq!(layout_class(Rect::new(0, 0, 140, 20)), LayoutClass::Wide); assert_eq!(layout_class(Rect::new(0, 0, 140, 20)), LayoutClass::Wide);
} }
#[test]
fn large_otp_inverts_only_the_digits_that_form_each_glyph() {
let line = large_otp_line("1 22");
assert_eq!(line.to_string(), "1 22");
for span in line.spans {
if span.content == " " {
assert_eq!(span.style, Style::default());
} else {
assert_eq!(span.style.fg, Some(Color::Black));
assert_eq!(span.style.bg, Some(Color::Green));
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
}
}
#[test] #[test]
fn color_capability_has_a_complete_monochrome_fallback() { fn color_capability_has_a_complete_monochrome_fallback() {
assert_eq!( assert_eq!(