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
164 changes: 161 additions & 3 deletions crates/hidegit-ui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ use iced::{Element as IcedElement, Subscription, Task, keyboard};
use hidegit_forge::{NewPullRequest, WebTarget};

use crate::message::{
CommitLoad, Message, OpenedRepository, OperationOutcome, Page, PrsLoad, Refreshed, RepoMessage,
StatusLoad, UiError,
CommitLoad, LayoutMessage, Message, OpenedRepository, OperationOutcome, Page, PrsLoad,
Refreshed, RepoMessage, StatusLoad, UiError,
};
use crate::state::{
ActionSheet, App, Confirmation, DetailPane, Draft, GraphView, OpenRepo, Operation, PAGE_SIZE,
Expand Down Expand Up @@ -494,6 +494,8 @@ impl Hidegit {
Task::none()
}

Message::Layout(message) => self.update_layout(message),

Message::RememberGeometryToggled => {
// Nothing in this crate acts on it; the shell reads it back out
// and decides what to write. Kept here because the panel that
Expand Down Expand Up @@ -1350,13 +1352,43 @@ impl Hidegit {
Task::batch(indices.into_iter().map(|index| self.poll_task(index)))
}

/// A drag on a pane divider, wherever it was raised from.
///
/// The arithmetic is [`crate::layout::Layout`]'s; what happens here is the
/// mapping from four messages onto four of its methods. Nothing is loaded,
/// nothing is written, and nothing is redrawn that iced would not redraw
/// anyway — a divider drag is the cheapest message in the application, and
/// during a drag it arrives on every frame.
fn update_layout(&mut self, message: LayoutMessage) -> Task<Message> {
match message {
LayoutMessage::Grabbed(split, extent) => self.app.layout.grab(split, extent),
LayoutMessage::Dragged(x, y) => self.app.layout.drag_to(x, y),
LayoutMessage::Released => self.app.layout.release(),
LayoutMessage::Reset(split) => self.app.layout.reset(split),
}

Task::none()
}

fn update_repo(&mut self, index: usize, message: RepoMessage) -> Task<Message> {
// Answered before the repository is even looked up: a divider inside
// the working directory addresses the window's layout, not this
// repository, and it only arrives here because the view it was dragged
// in speaks `RepoMessage`.
if let RepoMessage::Layout(message) = message {
return self.update_layout(message);
}

let Some(repo) = self.app.repos.get_mut(index) else {
return Task::none();
};
let cache = self.caches.entry(index).or_default();

match message {
// Answered above, before `repo` was borrowed. The arm is here
// because the match has to be exhaustive, not because it can run.
RepoMessage::Layout(_) => Task::none(),

RepoMessage::Selected(selection) => {
repo.selection = Some(selection.clone());
// A filter written against the last commit's files would hide
Expand Down Expand Up @@ -3569,7 +3601,36 @@ impl Hidegit {
_ => None,
});

Subscription::batch([keys, focus].into_iter().chain(watches).chain(polls))
// Where the pointer is, but only while a divider is being dragged.
//
// A press on a divider says that it happened, not where: `mouse_area`
// reports the press and the window reports the position, and this is
// what joins them. It is switched off the rest of the time on purpose —
// a message per pointer movement for the whole session, to serve a drag
// nobody is doing, is the most expensive subscription in the
// application and the easiest one to avoid.
let pointer = if self.app.layout.is_dragging() {
iced::event::listen_with(|event, _, _| match event {
iced::Event::Mouse(iced::mouse::Event::CursorMoved { position }) => Some(
Message::Layout(LayoutMessage::Dragged(position.x, position.y)),
),
// Released anywhere, including outside the window: a drag that
// only ended when the pointer came back would keep following it.
iced::Event::Mouse(iced::mouse::Event::ButtonReleased(
iced::mouse::Button::Left,
)) => Some(Message::Layout(LayoutMessage::Released)),
_ => None,
})
} else {
Subscription::none()
};

Subscription::batch(
[keys, focus, pointer]
.into_iter()
.chain(watches)
.chain(polls),
)
}

/// Which modal, if any, currently owns the keyboard.
Expand Down Expand Up @@ -4721,6 +4782,103 @@ mod tests {
.collect()
}

// ---- dividers ----

/// Presses a divider, moves the pointer twice — the first movement is the
/// anchor — and lets go, the way the window delivers a drag.
fn drag_divider(app: &mut Hidegit, split: crate::layout::Split, extent: f32, by: (f32, f32)) {
let _ = app.update(Message::Layout(LayoutMessage::Grabbed(split, extent)));
let _ = app.update(Message::Layout(LayoutMessage::Dragged(500.0, 500.0)));
let _ = app.update(Message::Layout(LayoutMessage::Dragged(
500.0 + by.0,
500.0 + by.1,
)));
let _ = app.update(Message::Layout(LayoutMessage::Released));
}

#[test]
fn dragging_the_sidebar_divider_resizes_the_sidebar() {
let mut app = app_with(3);

drag_divider(&mut app, crate::layout::Split::Sidebar, 1440.0, (60.0, 0.0));

assert_eq!(
app.app.layout.sidebar(),
crate::layout::Layout::SIDEBAR + 60.0
);
}

#[test]
fn a_divider_dragged_inside_the_working_directory_resizes_the_same_layout() {
// It arrives as a `RepoMessage` only because the staging view speaks
// one. If this ever stopped reaching the application's layout, the
// file list would resize and forget by the next render.
let mut app = app_with(3);

let _ = app.update(Message::Repo(
0,
RepoMessage::Layout(LayoutMessage::Grabbed(crate::layout::Split::Files, 1000.0)),
));
let _ = app.update(Message::Repo(
0,
RepoMessage::Layout(LayoutMessage::Dragged(400.0, 300.0)),
));
let _ = app.update(Message::Repo(
0,
RepoMessage::Layout(LayoutMessage::Dragged(440.0, 300.0)),
));

assert_eq!(app.app.layout.files(), crate::layout::Layout::FILES + 40.0);
}

#[test]
fn a_divider_addresses_the_window_rather_than_the_repository_it_was_dragged_in() {
// Two tabs, a drag in the second: the sidebar is the window's, so
// switching back must not switch the width back with it.
let mut app = app_with_tabs(2);

drag_divider(&mut app, crate::layout::Split::Sidebar, 1440.0, (40.0, 0.0));
let dragged = app.app.layout.sidebar();
let _ = app.update(Message::OpenRepository(PathBuf::from("/fake/repo-0")));

assert_eq!(app.app.layout.sidebar(), dragged);
}

#[test]
fn the_pointer_is_only_followed_while_a_divider_is_held() {
// What decides whether the subscription that reports every pointer
// movement exists at all.
let mut app = app_with(3);
assert!(!app.app.layout.is_dragging());

let _ = app.update(Message::Layout(LayoutMessage::Grabbed(
crate::layout::Split::Detail,
900.0,
)));
assert!(app.app.layout.is_dragging());

let _ = app.update(Message::Layout(LayoutMessage::Released));
assert!(!app.app.layout.is_dragging());
}

#[test]
fn double_clicking_a_divider_puts_it_back() {
let mut app = app_with(3);
drag_divider(
&mut app,
crate::layout::Split::Sidebar,
1440.0,
(120.0, 0.0),
);
assert_ne!(app.app.layout.sidebar(), crate::layout::Layout::SIDEBAR);

let _ = app.update(Message::Layout(LayoutMessage::Reset(
crate::layout::Split::Sidebar,
)));

assert_eq!(app.app.layout.sidebar(), crate::layout::Layout::SIDEBAR);
}

#[test]
fn dragging_a_row_across_the_list_moves_only_that_row() {
// Remove-and-insert, not a swap. Swapping the ends would send the first
Expand Down
65 changes: 65 additions & 0 deletions crates/hidegit-ui/src/app/tests/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,71 @@ fn app_showing_a_diff(name: &str) -> Hidegit {
app
}

#[test]
fn a_commit_message_of_any_length_leaves_the_diff_on_screen() {
// The message is the one part of this pane whose length nobody chose. It
// is scrolled and capped: before the cap, a body like this one was drawn
// at whatever height it wanted, and the files and the diff underneath it
// went off the bottom of the window.
let mut app = app_showing_a_diff("src/main.rs");
{
let repo = app.app.repos.get_mut(0).unwrap();
if let crate::state::DetailPane::Commit { detail, .. } = &mut repo.detail {
detail.commit.body = Some(
(1..=60)
.map(|n| format!("paragraph {n} of a release note nobody will read"))
.collect::<Vec<_>>()
.join("\n\n"),
);
}
}

// Visible, not merely present: a widget pushed off the bottom of the
// window is still in the tree, so `shows` would pass against exactly the
// bug this is about. The hunk header rather than a line of code — a
// highlighted line reaches the tree as rich text, which this harness
// cannot see inside of.
let mut ui = render(&app);
let diff = ui.find("@@ -1 +1 @@").expect("the diff is in the tree");

assert!(
diff.visible_bounds().is_some(),
"a long commit message pushed the diff out of the window"
);
}

#[test]
fn a_toast_with_a_long_stderr_keeps_its_summary_on_screen() {
// A toast is bottom-aligned, so its details grow upwards: before they were
// capped, a `git` stderr of thirty lines carried the summary — and the
// dismiss button — off the top of the window, on exactly the failures
// somebody needs to read.
let mut app = app_with(2);
app.app.toast(&crate::message::UiError {
summary: "The push was rejected".to_owned(),
details: (1..=40)
.map(|n| format!(" ! [rejected] branch-{n} -> branch-{n} (fetch first)"))
.collect::<Vec<_>>()
.join("\n"),
});

// Laid out in a window somebody might actually have, rather than the
// harness's roomy default: the bug is about a toast outgrowing its window,
// so the window has to be a size a toast can outgrow.
let view = app.view();
let mut ui: iced_test::Simulator<'_, Message> = iced_test::Simulator::with_size(
iced_test::core::Settings::default(),
iced::Size::new(1000.0, 500.0),
view,
);
let dismiss = ui.find("Copy details").expect("the toast is in the tree");

assert!(
dismiss.visible_bounds().is_some(),
"the details pushed the toast's own controls out of the window"
);
}

#[test]
fn an_open_in_flight_says_which_repository_and_which_step() {
// Two of them open at once from the command line, so the name is on the
Expand Down
Loading
Loading