1use std::{
14 future::Future,
15 io,
16 pin::Pin,
17 sync::Arc,
18 task::{Context as TaskContext, Poll},
19};
20
21use gpui_kit::{Context, SharedString, Task};
22use rust_i18n::t;
23
24use super::{
25 ColumnOf, LiveGridDelegate, LiveGridState, columns::column_of, pending::PendingValues,
26 selection::ScopedSelection,
27};
28use crate::{
29 engine::{DisplayRow, Projection},
30 export::{
31 CellExport, ExportColumn, ExportControl, ExportError, ExportFormat, ExportLine, ExportRow,
32 ExportScope, ExportTable,
33 },
34 model::{ColumnId, ColumnIx, ColumnSet, GridColumns as _, GridRow, NumberFormat},
35 state::{ColumnAlign, ColumnZone},
36 store::{PinnedEdge, StoreSnapshot},
37};
38
39pub struct ExportRequest<R: GridRow> {
54 scope: ExportScope,
55 header: ExportHeader,
56 empty: EmptyExport,
57 columns: Option<Vec<R::Column>>,
58 cells: Option<CellExport<R>>,
59}
60
61impl<R: GridRow> ExportRequest<R> {
62 pub fn new(scope: ExportScope) -> Self {
66 Self {
67 scope,
68 header: ExportHeader::default(),
69 empty: EmptyExport::default(),
70 columns: None,
71 cells: None,
72 }
73 }
74
75 pub fn with_header(self, header: ExportHeader) -> Self {
78 Self { header, ..self }
79 }
80
81 pub fn with_empty(self, empty: EmptyExport) -> Self {
84 Self { empty, ..self }
85 }
86
87 pub fn with_columns(self, columns: impl IntoIterator<Item = R::Column>) -> Self {
92 Self {
93 columns: Some(columns.into_iter().collect()),
94 ..self
95 }
96 }
97
98 pub fn with_cell_export(self, cells: CellExport<R>) -> Self {
100 Self {
101 cells: Some(cells),
102 ..self
103 }
104 }
105
106 pub fn scope(&self) -> ExportScope {
108 self.scope
109 }
110
111 pub fn header(&self) -> ExportHeader {
113 self.header
114 }
115
116 pub fn empty(&self) -> EmptyExport {
118 self.empty
119 }
120}
121
122impl<R: GridRow> Default for ExportRequest<R> {
123 fn default() -> Self {
125 Self::new(ExportScope::Visible)
126 }
127}
128
129impl<R: GridRow> Clone for ExportRequest<R> {
130 fn clone(&self) -> Self {
131 Self {
132 scope: self.scope,
133 header: self.header,
134 empty: self.empty,
135 columns: self.columns.clone(),
136 cells: self.cells.clone(),
137 }
138 }
139}
140
141#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
144#[non_exhaustive]
145pub enum ExportHeader {
146 None,
148 #[default]
150 Titles,
151}
152
153#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
155#[non_exhaustive]
156pub enum EmptyExport {
157 #[default]
160 Nothing,
161 HeaderOnly,
163}
164
165#[must_use = "dropping an export job cancels the export"]
175pub struct ExportJob<T> {
176 control: ExportControl,
177 task: Option<Task<()>>,
180 done: Box<async_channel::Receiver<Result<ExportOutcome<T>, ExportError>>>,
183 result: Pin<Box<dyn Future<Output = Result<ExportOutcome<T>, ExportError>> + Send>>,
185 detached: bool,
186}
187
188impl<T: Send + 'static> ExportJob<T> {
189 fn new(
190 control: ExportControl,
191 task: Task<()>,
192 done: async_channel::Receiver<Result<ExportOutcome<T>, ExportError>>,
193 ) -> Self {
194 let result = {
195 let done = done.clone();
196 Box::pin(async move {
197 done.recv().await.unwrap_or(Err(ExportError::Cancelled))
199 })
200 };
201 Self {
202 control,
203 task: Some(task),
204 done: Box::new(done),
205 result,
206 detached: false,
207 }
208 }
209}
210
211impl<T> ExportJob<T> {
212 pub fn progress(&self) -> ExportProgress {
215 ExportProgress {
216 rows_written: self.control.rows_written(),
217 rows_total: self.control.rows_total(),
218 }
219 }
220
221 pub fn cancel(&self) {
224 self.control.cancel();
225 }
226
227 pub fn is_finished(&self) -> bool {
229 self.done.is_closed()
230 }
231
232 pub fn detach(mut self) {
234 if let Some(task) = self.task.take() {
235 task.detach();
236 }
237 self.detached = true;
238 }
239}
240
241impl<T> Future for ExportJob<T> {
242 type Output = Result<ExportOutcome<T>, ExportError>;
243
244 fn poll(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Self::Output> {
245 let this = self.get_mut();
246 let result = std::task::ready!(this.result.as_mut().poll(cx));
247 this.task = None;
248 Poll::Ready(result)
249 }
250}
251
252impl<T> Drop for ExportJob<T> {
253 fn drop(&mut self) {
254 if !self.detached {
255 self.control.cancel();
256 }
257 }
258}
259
260#[derive(Clone, Copy, Debug, PartialEq, Eq)]
262pub struct ExportProgress {
263 rows_written: usize,
264 rows_total: usize,
265}
266
267impl ExportProgress {
268 pub fn rows_written(&self) -> usize {
270 self.rows_written
271 }
272
273 pub fn rows_total(&self) -> usize {
276 self.rows_total
277 }
278
279 pub fn fraction(&self) -> f32 {
281 if self.rows_total == 0 {
282 return 1.0;
283 }
284 (self.rows_written.min(self.rows_total) as f64 / self.rows_total as f64) as f32
285 }
286}
287
288#[derive(Clone, Debug)]
290pub struct ExportOutcome<T> {
291 output: T,
292 rows: usize,
293 data_rows: usize,
294 bytes: Option<u64>,
295}
296
297impl<T> ExportOutcome<T> {
298 pub fn output(&self) -> &T {
301 &self.output
302 }
303
304 pub fn into_output(self) -> T {
306 self.output
307 }
308
309 pub fn rows(&self) -> usize {
312 self.rows
313 }
314
315 pub fn data_rows(&self) -> usize {
317 self.data_rows
318 }
319
320 pub fn bytes(&self) -> Option<u64> {
322 self.bytes
323 }
324}
325
326enum CaptureRows<K> {
328 Lines(Arc<Projection>),
332 Expanded(Arc<Projection>),
336 Scoped(ScopedSelection<K>),
338}
339
340impl<K> CaptureRows<K> {
341 fn len(&self) -> usize {
344 match self {
345 Self::Lines(projection) => projection.len(),
346 Self::Expanded(projection) => projection
347 .rows()
348 .len()
349 .saturating_add(projection.groups().len().saturating_mul(2)),
350 Self::Scoped(scoped) => scoped.len(),
351 }
352 }
353}
354
355struct CapturedColumn<C> {
357 column: C,
358 title: SharedString,
359 width: f32,
360 align: ColumnAlign,
361 zone: ColumnZone,
362 bands: Box<[SharedString]>,
364}
365
366fn pinned_count<R: GridRow>(snapshot: &StoreSnapshot<R>) -> usize {
368 snapshot.pinned_rows(PinnedEdge::Top).len() + snapshot.pinned_rows(PinnedEdge::Bottom).len()
369}
370
371pub(super) struct ExportCapture<R: GridRow> {
374 snapshot: Arc<StoreSnapshot<R>>,
375 rows: CaptureRows<R::Key>,
376 columns: Vec<CapturedColumn<R::Column>>,
378 header: bool,
379 value_columns: Vec<(ColumnId, R::Column)>,
384 totals: Option<(PinnedEdge, SharedString)>,
387 pending: Arc<PendingValues<R::Key, R::Column>>,
390 formats: Arc<[NumberFormat]>,
393 cells: CellExport<R>,
394 is_empty: bool,
396}
397
398impl<R: GridRow> ExportCapture<R> {
399 pub(super) fn is_empty(&self) -> bool {
402 self.is_empty
403 }
404
405 pub(super) fn row_estimate(&self) -> usize {
408 let bands = match self.rows {
409 CaptureRows::Lines(_) | CaptureRows::Expanded(_) => {
410 pinned_count(&self.snapshot) + usize::from(self.totals.is_some())
411 }
412 CaptureRows::Scoped(_) => 0,
413 };
414 self.rows.len().saturating_add(bands)
415 }
416
417 pub(super) fn cell_count(&self) -> usize {
419 self.row_estimate().saturating_mul(self.columns.len())
420 }
421
422 pub(super) fn write<T>(
426 &self,
427 write: impl FnOnce(&ExportTable<'_, R>, &CellExport<R>) -> T,
428 ) -> T {
429 let band_titles: Vec<&str> = self
430 .columns
431 .iter()
432 .flat_map(|column| column.bands.iter().map(SharedString::as_ref))
433 .collect();
434 let mut band_start = 0;
435 let columns: Vec<ExportColumn<'_, R::Column>> = self
436 .columns
437 .iter()
438 .map(|column| {
439 let bands = &band_titles[band_start..band_start + column.bands.len()];
440 band_start += column.bands.len();
441 ExportColumn::new(column.column, &column.title)
442 .with_width(column.width)
443 .with_align(column.align)
444 .with_zone(column.zone)
445 .with_bands(bands)
446 })
447 .collect();
448 let mut rows = self.rows();
449 for row in &mut rows {
450 *row = row.with_number_formats(&self.formats);
451 }
452 write(
453 &ExportTable::new(&columns, &rows).with_header(self.header),
454 &self.cells,
455 )
456 }
457
458 fn rows(&self) -> Vec<ExportRow<'_, R>> {
460 let exported = self
461 .columns
462 .iter()
463 .fold(ColumnSet::EMPTY, |mut set, column| {
464 set.insert(column.column.ix());
465 set
466 });
467 match &self.rows {
468 CaptureRows::Lines(projection) => self.view_rows(
469 projection,
470 (0..projection.len()).map_while(|ix| projection.line(ix)),
471 false,
472 ),
473 CaptureRows::Expanded(projection) => {
474 self.view_rows(projection, projection.expanded_lines(), true)
475 }
476 CaptureRows::Scoped(scoped) => scoped
480 .keys()
481 .iter()
482 .enumerate()
483 .filter_map(|(ix, key)| {
484 let selected = scoped.columns(ix);
485 if !selected.intersects(exported) {
486 return None;
487 }
488 let row = self.snapshot.get(key)?;
489 Some(self.data_row(row).with_columns(selected))
490 })
491 .collect(),
492 }
493 }
494
495 fn data_row<'a>(&'a self, row: &'a R) -> ExportRow<'a, R> {
498 let export = ExportRow::new(row);
499 if self.pending.is_empty() {
500 return export;
501 }
502 match self.pending.get(row.key()) {
503 Some(values) => export.with_pending(values),
504 None => export,
505 }
506 }
507
508 fn view_rows<'a>(
517 &'a self,
518 projection: &'a Projection,
519 lines: impl Iterator<Item = DisplayRow> + 'a,
520 mark_hidden: bool,
521 ) -> Vec<ExportRow<'a, R>> {
522 let snapshot = &*self.snapshot;
523 let groups = projection.groups();
524 let is_tree = projection.is_tree();
525 let first_column = self.columns[0].column;
526 let totals = |edge| {
527 self.totals
528 .as_ref()
529 .filter(|(at, _)| *at == edge)
530 .map(|(_, label)| {
531 ExportRow::totals(projection.totals(), Some((first_column, label.as_ref())))
532 .with_band(edge)
533 })
534 };
535 let pinned = |edge| {
537 snapshot
538 .pinned_rows(edge)
539 .iter()
540 .map(move |row| ExportRow::new(&**row).with_band(edge))
541 };
542
543 let mut hidden = HiddenLines::default();
544 let mut rows = Vec::with_capacity(self.rows.len() + pinned_count(snapshot) + 1);
545 rows.extend(totals(PinnedEdge::Top));
546 rows.extend(pinned(PinnedEdge::Top));
547 rows.extend(lines.filter_map(|line| {
548 let (row, is_hidden) = match line {
549 DisplayRow::Data { row: id, depth } => {
550 let branch = if is_tree { projection.branch(id) } else { None };
551 let collapsed = branch.is_some_and(|branch| !branch.is_expanded());
552 let is_hidden = mark_hidden && hidden.next(depth, collapsed, false);
553 let row = self.data_row(snapshot.row(id)?).with_depth(depth);
554 (
555 match branch {
556 Some(branch) => row.with_branch(branch),
557 None => row,
558 },
559 is_hidden,
560 )
561 }
562 DisplayRow::GroupCaption { group, depth } => {
563 let group = groups.get(group)?;
564 let is_hidden = mark_hidden && hidden.next(depth, !group.is_expanded(), false);
565 let level = group.level_id();
566 let value_column = self
567 .value_columns
568 .iter()
569 .find(|(id, _)| id == level)
570 .map_or(first_column, |(_, column)| *column);
571 (ExportRow::caption(value_column, group), is_hidden)
572 }
573 DisplayRow::GroupFooter { group, depth } => {
574 let group = groups.get(group)?;
575 let is_hidden = mark_hidden && hidden.next(depth, false, !group.is_expanded());
576 (ExportRow::footer(group), is_hidden)
577 }
578 };
579 Some(row.with_hidden(is_hidden))
580 }));
581 rows.extend(pinned(PinnedEdge::Bottom));
582 rows.extend(totals(PinnedEdge::Bottom));
583 rows
584 }
585
586 fn run<T>(
590 &self,
591 empty: EmptyExport,
592 control: &ExportControl,
593 write: impl FnOnce(
594 &ExportTable<'_, R>,
595 &CellExport<R>,
596 &ExportControl,
597 ) -> Result<(T, Option<u64>), ExportError>,
598 ) -> Result<ExportOutcome<T>, ExportError> {
599 control.advance(0)?;
600 self.write(|table, cells| {
601 let rows = table.rows().len();
602 control.set_rows_total(rows);
603 if rows == 0 && empty == EmptyExport::Nothing {
604 return Err(ExportError::Nothing);
605 }
606 let (output, bytes) = write(table, cells, control)?;
607 Ok(ExportOutcome {
608 output,
609 rows,
610 data_rows: table
611 .rows()
612 .iter()
613 .filter(|row| row.line() == ExportLine::Data)
614 .count(),
615 bytes,
616 })
617 })
618 }
619}
620
621#[derive(Default)]
625struct HiddenLines {
626 below: Option<u16>,
628}
629
630impl HiddenLines {
631 fn next(&mut self, depth: u16, collapsed: bool, collapsed_footer: bool) -> bool {
635 let under_collapsed = match self.below {
636 Some(below) if depth > below => true,
637 _ => {
638 self.below = None;
639 false
640 }
641 };
642 if !under_collapsed && collapsed {
643 self.below = Some(depth);
644 }
645 under_collapsed || collapsed_footer
646 }
647}
648
649struct CountingWriter<W> {
651 inner: W,
652 bytes: u64,
653}
654
655impl<W: io::Write> io::Write for CountingWriter<W> {
656 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
657 let written = self.inner.write(buf)?;
658 self.bytes += written as u64;
659 Ok(written)
660 }
661
662 fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
663 self.inner.write_all(buf)?;
664 self.bytes += buf.len() as u64;
665 Ok(())
666 }
667
668 fn flush(&mut self) -> io::Result<()> {
669 self.inner.flush()
670 }
671}
672
673#[cfg(not(target_family = "wasm"))]
677fn write_through_part_file(
678 path: &std::path::Path,
679 control: &ExportControl,
680 write: impl FnOnce(&mut dyn io::Write) -> Result<(), ExportError>,
681) -> Result<u64, ExportError> {
682 let Some(name) = path.file_name() else {
683 return Err(
684 io::Error::new(io::ErrorKind::InvalidInput, "the export path names no file").into(),
685 );
686 };
687 let mut part_name = name.to_os_string();
688 part_name.push(".part");
689 let part = path.with_file_name(part_name);
690 let written = (|| {
691 let file = std::fs::File::create(&part)?;
692 let mut out = CountingWriter {
693 inner: io::BufWriter::new(file),
694 bytes: 0,
695 };
696 write(&mut out)?;
697 let bytes = out.bytes;
698 let file = out
699 .inner
700 .into_inner()
701 .map_err(io::IntoInnerError::into_error)?;
702 file.sync_all()?;
703 drop(file);
704 control.advance(0)?;
706 std::fs::rename(&part, path)?;
707 Ok(bytes)
708 })();
709 if written.is_err() {
710 let _ = std::fs::remove_file(&part);
711 }
712 written
713}
714
715impl<D: LiveGridDelegate> LiveGridState<D> {
716 pub fn export<W: io::Write + Send + 'static>(
748 &mut self,
749 request: ExportRequest<D::Row>,
750 format: impl ExportFormat<D::Row>,
751 sink: W,
752 cx: &mut Context<Self>,
753 ) -> Result<ExportJob<W>, ExportError> {
754 self.start_export(
755 request,
756 move |table, cells, control| {
757 let mut out = CountingWriter {
758 inner: sink,
759 bytes: 0,
760 };
761 format.export(table, cells, &mut out, control)?;
762 Ok((out.inner, Some(out.bytes)))
763 },
764 cx,
765 )
766 }
767
768 #[cfg(not(target_family = "wasm"))]
778 pub fn export_to_path(
779 &mut self,
780 request: ExportRequest<D::Row>,
781 format: impl ExportFormat<D::Row>,
782 path: impl Into<std::path::PathBuf>,
783 cx: &mut Context<Self>,
784 ) -> Result<ExportJob<std::path::PathBuf>, ExportError> {
785 let path = path.into();
786 self.start_export(
787 request,
788 move |table, cells, control| {
789 let bytes = write_through_part_file(&path, control, |out| {
790 format.export(table, cells, out, control)
791 })?;
792 Ok((path, Some(bytes)))
793 },
794 cx,
795 )
796 }
797
798 pub fn export_with<T: Send + 'static>(
804 &mut self,
805 request: ExportRequest<D::Row>,
806 write: impl FnOnce(
807 &ExportTable<'_, D::Row>,
808 &CellExport<D::Row>,
809 &ExportControl,
810 ) -> Result<T, ExportError>
811 + Send
812 + 'static,
813 cx: &mut Context<Self>,
814 ) -> Result<ExportJob<T>, ExportError> {
815 self.start_export(
816 request,
817 move |table, cells, control| Ok((write(table, cells, control)?, None)),
818 cx,
819 )
820 }
821
822 pub fn is_export_empty(&self, scope: ExportScope) -> bool {
827 self.capture(scope, None, true, CellExport::default())
828 .is_empty()
829 }
830
831 fn start_export<T: Send + 'static>(
833 &mut self,
834 request: ExportRequest<D::Row>,
835 write: impl FnOnce(
836 &ExportTable<'_, D::Row>,
837 &CellExport<D::Row>,
838 &ExportControl,
839 ) -> Result<(T, Option<u64>), ExportError>
840 + Send
841 + 'static,
842 cx: &mut Context<Self>,
843 ) -> Result<ExportJob<T>, ExportError> {
844 let ExportRequest {
845 scope,
846 header,
847 empty,
848 columns,
849 cells,
850 } = request;
851 let cells = cells.unwrap_or_else(|| self.copy_cells().clone());
852 let capture = self.capture(
853 scope,
854 columns.as_deref(),
855 header == ExportHeader::Titles,
856 cells,
857 );
858 if capture.is_empty() && (empty == EmptyExport::Nothing || capture.columns.is_empty()) {
859 return Err(ExportError::Nothing);
860 }
861 let control = ExportControl::new(capture.row_estimate());
862 let (done, result) = async_channel::bounded(1);
863 let job_control = control.clone();
864 let task = self.runner.run(
865 Box::new(move || {
866 let _ = done.try_send(capture.run(empty, &job_control, write));
867 }),
868 cx,
869 );
870 Ok(ExportJob::new(control, task, result))
871 }
872
873 pub(super) fn capture(
880 &self,
881 scope: ExportScope,
882 columns: Option<&[ColumnOf<D>]>,
883 header: bool,
884 cells: CellExport<D::Row>,
885 ) -> ExportCapture<D::Row> {
886 let (rows, selected) = match scope {
887 ExportScope::Selection => {
888 let scoped = self.command_scope(self.focus_target().as_ref());
889 let selected = (0..scoped.len()).fold(ColumnSet::EMPTY, |mut set, ix| {
890 set.union_with(scoped.columns(ix));
891 set
892 });
893 (CaptureRows::Scoped(scoped), selected)
894 }
895 ExportScope::Visible => (
896 CaptureRows::Lines(self.projection.clone()),
897 self.columns.visible_set(),
898 ),
899 ExportScope::Expanded => (
900 CaptureRows::Expanded(self.projection.clone()),
901 self.columns.visible_set(),
902 ),
903 };
904 let columns: Vec<CapturedColumn<ColumnOf<D>>> = match columns {
905 Some(columns) => columns
906 .iter()
907 .filter_map(|column| self.captured_column(column.ix()))
908 .collect(),
909 None => {
910 let selected = if selected.is_empty() {
912 self.columns.visible_set()
913 } else {
914 selected
915 };
916 self.columns
917 .visible()
918 .filter(|ix| selected.contains(*ix))
919 .filter_map(|ix| self.captured_column(ix))
920 .collect()
921 }
922 };
923 let (value_columns, totals) = match rows {
924 CaptureRows::Scoped(_) => (Vec::new(), None),
925 CaptureRows::Lines(_) | CaptureRows::Expanded(_) => {
926 let value_columns = self
927 .view
928 .group_by()
929 .iter()
930 .filter_map(|level| {
931 let column = level.as_column()?;
932 let exported = columns.iter().any(|each| each.column == column);
933 exported.then(|| (level.id(), column))
934 })
935 .collect();
936 let totals = self
939 .totals()
940 .filter(|_| !self.projection.totals().is_empty())
941 .map(|edge| {
942 let label = SharedString::from(t!("live_grid.totals.label").into_owned());
943 (edge, label)
944 });
945 (value_columns, totals)
946 }
947 };
948 let no_rows = match &rows {
949 CaptureRows::Scoped(scoped) => scoped.is_empty(),
950 CaptureRows::Lines(projection) | CaptureRows::Expanded(projection) => {
951 projection.is_empty() && pinned_count(&self.snapshot) == 0 && totals.is_none()
952 }
953 };
954 ExportCapture {
955 snapshot: self.snapshot.clone(),
956 rows,
957 is_empty: no_rows || columns.is_empty(),
958 columns,
959 header,
960 value_columns,
961 totals,
962 pending: self.pending_values(),
963 formats: self.columns.number_formats(),
964 cells,
965 }
966 }
967
968 fn captured_column(&self, ix: ColumnIx) -> Option<CapturedColumn<ColumnOf<D>>> {
970 let column = self.columns.get(ix)?;
971 Some(CapturedColumn {
972 column: column_of(ix),
973 title: column.title().clone(),
974 width: f32::from(self.columns.width(ix)),
975 align: column.align(),
976 zone: self.columns.zone_of(ix),
977 bands: self
978 .columns
979 .bands_of(ix)
980 .iter()
981 .map(|band| {
982 self.columns
983 .band_title(band)
984 .cloned()
985 .unwrap_or_else(|| SharedString::new_static(band))
986 })
987 .collect(),
988 })
989 }
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995
996 #[test]
997 fn test_hidden_lines() {
998 let mut hidden = HiddenLines::default();
1001 let walk = [
1002 (0, true, false, false),
1004 (1, false, false, true),
1005 (2, false, false, true),
1006 (1, true, false, true),
1007 (2, false, false, true),
1008 (1, false, true, true),
1009 (0, false, true, true),
1010 (0, false, false, false),
1011 (1, true, false, false),
1013 (2, false, false, true),
1014 (1, false, true, true),
1015 (1, false, false, false),
1016 (2, false, false, false),
1017 (1, false, false, false),
1018 (0, false, false, false),
1019 ];
1020 for (ix, (depth, collapsed, collapsed_footer, expected)) in walk.into_iter().enumerate() {
1021 assert_eq!(
1022 hidden.next(depth, collapsed, collapsed_footer),
1023 expected,
1024 "line {ix}"
1025 );
1026 }
1027 }
1028
1029 #[test]
1030 fn test_progress_fraction() {
1031 let progress = |rows_written, rows_total| ExportProgress {
1032 rows_written,
1033 rows_total,
1034 };
1035 assert_eq!(progress(0, 300).fraction(), 0.0);
1036 assert_eq!(progress(150, 300).fraction(), 0.5);
1037 assert_eq!(progress(300, 300).fraction(), 1.0);
1038 assert_eq!(progress(400, 300).fraction(), 1.0);
1040 assert_eq!(progress(0, 0).fraction(), 1.0);
1042 }
1043}