Skip to content

Commit 1b4d90a

Browse files
billdenneyclaude
andauthored
Phase 4: sheet view and navigation (#123)
* Add sheet tab state with cross-sheet validation xl_sheet() gains active, selected, visible and first_tab, reaching worksheet_activate, worksheet_select, worksheet_hide and worksheet_set_first_sheet. These are the first worksheet settings whose rules span the whole workbook, so unlike everything else in the sheet plan they cannot be checked one sheet at a time. Excel requires that a hidden sheet is neither active nor selected, that at most one sheet is active, that the first sheet is not hidden unless another is made active, and that at least one sheet stays visible. libxlsxwriter enforces none of them -- hide, activate and select all return void and simply set a flag -- so a bad combination would produce a workbook Excel cannot open, with no diagnostic. .resolve_sheet_visibility() checks all four rules in write_xlsx(), where every sheet is in scope, and names the sheet at fault. Plain data frames carry no tab settings and are treated as unset throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Add hide_zero, right_to_left, selection and top_left xl_sheet() gains four more view settings, reaching worksheet_hide_zero, worksheet_right_to_left, worksheet_set_selection and worksheet_set_top_left_cell. selection and top_left go through the shared range resolver, so they take an Excel reference or a list(rows =, cols =) spec like every other range argument. Excel encodes which cell of a selection is active by the order its corners are given, so worksheet_set_selection(6, 6, 3, 3) means "G7 to D4". writexl does not expose that: the shared resolver rejects an inverted range, which is right for every other caller, and one strict range parser is worth more than a niche capability. The active cell is therefore always the range's top-left corner, which is documented and covered by a test that pins the error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Add split panes, converting from a cell reference worksheet_split_panes() does not take a row and a column. Both arguments are distances, in the units Excel uses for row height and column width, and the two units differ from each other -- 15 means one default row, 8.43 one default column. Passing 1 for "one row" would put the split a fifteenth of the way down the first row, silently. xl_sheet(split =) therefore takes a cell reference, as freeze does, and converts: the distance above the split is the summed height of the rows before it, the distance to its left the summed width of the columns before it. The sums use the sheet's real geometry -- header row height, xl_row_spec() heights, xl_col_spec() widths and auto_colwidth results -- so a split lands where it was asked for on a sheet whose rows or columns have been resized, rather than only on a default one. Tests pin that sensitivity in both directions. list(vertical =, horizontal =) still accepts the raw units. split and freeze together is an error, since Excel supports one or the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Document sheet view and navigation in the vignette and NEWS Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 63ffa4b commit 1b4d90a

9 files changed

Lines changed: 675 additions & 4 deletions

File tree

NEWS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@
2929
and footers, print area, repeating heading rows/columns, manual page breaks,
3030
and the print options.
3131

32+
* **Sheet tabs and the opening view** via `xl_sheet()`: which tab is active,
33+
selected, hidden or leftmost; the selected cell and scroll position; hiding
34+
zero values; right-to-left column order; and split panes alongside the
35+
existing frozen panes. The workbook-wide visibility rules (only one active
36+
sheet, no hidden-and-active sheet, at least one visible sheet) are checked
37+
before writing, naming the sheet at fault.
38+
3239
* `xl_properties(hyperlink_format = NULL)` writes hyperlinks with no styling at
3340
all, which was previously impossible.
3441

R/sheet_visibility.R

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# =============================================================================
2+
# Sheet visibility: which tab opens, which are selected, which are hidden
3+
# =============================================================================
4+
#
5+
# These are the only worksheet settings whose rules span the whole workbook, so
6+
# unlike everything else in the sheet plan they cannot be validated one sheet at
7+
# a time. Excel's rules, as documented for worksheet_hide():
8+
#
9+
# * a hidden sheet cannot be activated or selected -- the calls are mutually
10+
# exclusive;
11+
# * the first sheet is active by default, so it cannot be hidden unless some
12+
# other sheet is made active;
13+
# * at most one sheet may be active;
14+
# * at least one sheet must stay visible, or Excel refuses to open the file.
15+
#
16+
# libxlsxwriter enforces none of them: worksheet_hide(), worksheet_activate()
17+
# and worksheet_select() all return void and simply set a flag. A bad
18+
# combination therefore produces a broken workbook with no diagnostic, so the
19+
# checks live here and name the sheet at fault.
20+
# -----------------------------------------------------------------------------
21+
22+
# One sheet's visibility settings, with absent ones as NA.
23+
.sheet_view_flags <- function(el) {
24+
if (!inherits(el, "xl_sheet"))
25+
return(list(active = NA, selected = NA, visible = NA, first_tab = NA))
26+
list(active = el$active,
27+
selected = el$selected,
28+
visible = el$visible,
29+
first_tab = el$first_tab)
30+
}
31+
32+
# A readable name for a sheet in an error message.
33+
.sheet_label <- function(nms, i) {
34+
if (!is.null(nms) && length(nms) >= i && !is.na(nms[i]) && nzchar(nms[i]))
35+
sprintf('"%s"', nms[i])
36+
else
37+
sprintf("%d", i)
38+
}
39+
40+
# Validate the workbook-wide visibility rules. Returns nothing; it exists to
41+
# fail loudly before anything is written.
42+
.resolve_sheet_visibility <- function(elems, nms = names(elems)) {
43+
n <- length(elems)
44+
if (!n) return(invisible(NULL))
45+
f <- lapply(elems, .sheet_view_flags)
46+
is_set <- function(k, want) vapply(f, function(x) identical(x[[k]], want),
47+
logical(1))
48+
49+
hidden <- is_set("visible", FALSE)
50+
active <- is_set("active", TRUE)
51+
selected <- is_set("selected", TRUE)
52+
53+
# every sheet hidden -> Excel will not open the file
54+
if (all(hidden))
55+
stop("every sheet is hidden; a workbook needs at least one visible sheet",
56+
call. = FALSE)
57+
58+
# a hidden sheet cannot also be the active or a selected one
59+
bad <- which(hidden & (active | selected))
60+
if (length(bad))
61+
stop(sprintf(paste0("sheet %s is hidden but also marked active/selected; ",
62+
"Excel cannot show a hidden sheet"),
63+
.sheet_label(nms, bad[1L])), call. = FALSE)
64+
65+
# only one active sheet
66+
if (sum(active) > 1L)
67+
stop(sprintf("sheets %s are both marked active; only one sheet may be active",
68+
paste(vapply(which(active), function(i) .sheet_label(nms, i),
69+
character(1)), collapse = " and ")),
70+
call. = FALSE)
71+
72+
# the first sheet is active by default, so hiding it needs another active one
73+
if (hidden[1L] && !any(active))
74+
stop(sprintf(paste0("sheet %s is the first sheet and cannot be hidden ",
75+
"unless another sheet is given active = TRUE, since ",
76+
"Excel opens on the first sheet by default"),
77+
.sheet_label(nms, 1L)), call. = FALSE)
78+
79+
invisible(NULL)
80+
}

R/split_panes.R

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# =============================================================================
2+
# Split panes: converting a cell reference into libxlsxwriter's units
3+
# =============================================================================
4+
#
5+
# worksheet_split_panes() does not take a row and a column. Its two arguments
6+
# are distances, measured in the units Excel uses for row height and column
7+
# width, and the two units differ from each other:
8+
#
9+
# worksheet_split_panes(worksheet1, 15, 0); // First row.
10+
# worksheet_split_panes(worksheet2, 0, 8.43); // First column.
11+
#
12+
# So `vertical` is a number of row-heights and positions the split *between
13+
# rows*, while `horizontal` is a number of column-widths and positions it
14+
# between columns. Passing 1 for "one row" would put the split a fifteenth of
15+
# the way down the first row, silently.
16+
#
17+
# writexl therefore takes a cell reference, as `freeze` does, and converts:
18+
# the distance above the split is the summed height of the rows before it, and
19+
# the distance to its left is the summed width of the columns before it. Those
20+
# use the sheet's real geometry -- header row height, xl_row_spec() heights,
21+
# xl_col_spec() widths and auto_colwidth results -- rather than assuming the
22+
# defaults, so a split lands where it was asked for on a sheet whose rows or
23+
# columns have been resized.
24+
#
25+
# A raw list(vertical =, horizontal =) is still accepted for callers who want
26+
# to specify the units directly.
27+
# -----------------------------------------------------------------------------
28+
29+
# Excel's defaults, and the values libxlsxwriter's own examples use.
30+
.DEFAULT_ROW_HEIGHT <- 15
31+
.DEFAULT_COL_WIDTH <- 8.43
32+
33+
# The height of one 0-based sheet row: an explicit xl_row_spec() height, else
34+
# the sheet default, else Excel's.
35+
.row_height_at <- function(i, header_offset, props, default_row_height,
36+
row_row, row_height) {
37+
k <- which(row_row == i)
38+
if (length(k) && !is.na(row_height[k[1L]])) return(row_height[k[1L]])
39+
if (header_offset > 0L && i == 0L && !is.null(props$header_row_height))
40+
return(as.numeric(props$header_row_height))
41+
if (!is.na(default_row_height)) return(as.numeric(default_row_height))
42+
.DEFAULT_ROW_HEIGHT
43+
}
44+
45+
# The width of one 1-based column.
46+
.col_width_at <- function(j, col_width) {
47+
if (j <= length(col_width) && !is.na(col_width[j])) col_width[j]
48+
else .DEFAULT_COL_WIDTH
49+
}
50+
51+
.resolve_split <- function(spec, df, header_offset, props, default_row_height,
52+
row_row, row_height, col_width) {
53+
# raw units, for callers who want to place the split exactly
54+
if (is.list(spec) &&
55+
(!is.null(spec[["vertical"]]) || !is.null(spec[["horizontal"]]))) {
56+
bad <- setdiff(names(spec), c("vertical", "horizontal"))
57+
if (length(bad))
58+
stop("unknown `split` element(s): ", paste(bad, collapse = ", "),
59+
call. = FALSE)
60+
v <- if (is.null(spec$vertical)) 0 else as.numeric(spec$vertical)
61+
h <- if (is.null(spec$horizontal)) 0 else as.numeric(spec$horizontal)
62+
if (anyNA(c(v, h)) || any(c(v, h) < 0))
63+
stop("`split` units must be non-negative numbers", call. = FALSE)
64+
return(c(vertical = v, horizontal = h))
65+
}
66+
67+
q <- .xl_resolve_range(spec, arg = "split", df = df,
68+
header_offset = header_offset, allow_cell = TRUE)
69+
n_rows <- q[1L] # 0-based row index == how many rows sit above the split
70+
n_cols <- q[2L]
71+
72+
vertical <- 0
73+
if (n_rows > 0L)
74+
vertical <- sum(vapply(seq_len(n_rows) - 1L, .row_height_at, numeric(1),
75+
header_offset = header_offset, props = props,
76+
default_row_height = default_row_height,
77+
row_row = row_row, row_height = row_height))
78+
horizontal <- 0
79+
if (n_cols > 0L)
80+
horizontal <- sum(vapply(seq_len(n_cols), .col_width_at, numeric(1),
81+
col_width = col_width))
82+
83+
c(vertical = vertical, horizontal = horizontal)
84+
}

R/write_xlsx.R

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ write_xlsx <- function(x, path = tempfile(fileext = ".xlsx"), col_names = TRUE,
4747
dfs <- lapply(elems, function(el) if(inherits(el, "xl_sheet")) el$data else el)
4848
dfs <- lapply(dfs, normalize_df)
4949
names(dfs) <- .resolve_sheet_names(names(elems), length(dfs))
50+
# tab visibility is the one worksheet setting whose rules span the whole
51+
# workbook, so it is checked here rather than per sheet
52+
.resolve_sheet_visibility(elems, names(dfs))
5053
stopifnot(is.character(path) && length(path))
5154
path <- normalizePath(path, mustWork = FALSE)
5255
# Excel has no concept of a time zone, so decide once, for the whole workbook,

R/xl_sheet.R

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,42 @@ xl_row_spec <- function(rows, height = NA, hidden = NA, level = NA,
141141
#' per-comment `author` overrides it).
142142
#' @param show_comments If `TRUE`, all comments on the sheet are initially
143143
#' shown (individual comments can still be forced via `xl_comment(visible=)`).
144+
#' @param active Logical; make this the tab Excel opens on. At most one sheet
145+
#' in a workbook may be active.
146+
#' @param selected Logical; include this tab in the selected group. The active
147+
#' sheet is always selected.
148+
#' @param visible Logical; `FALSE` hides the sheet's tab. A hidden sheet cannot
149+
#' be active or selected, the first sheet cannot be hidden unless another is
150+
#' made active, and at least one sheet must stay visible or Excel will not
151+
#' open the file.
152+
#' @param first_tab Logical; make this the leftmost visible tab in the tab
153+
#' strip. This is independent of which sheet is active.
154+
#' @param hide_zero Logical; display zero values as blank cells.
155+
#' @param right_to_left Logical; order the columns right to left, for a sheet in
156+
#' a right-to-left language.
157+
#' @param selection The cell or range selected when the sheet opens, as an Excel
158+
#' reference (`"B2"`, `"B2:D10"`) or a `list(rows = , cols = )` spec.
159+
#'
160+
#' Excel also uses the order of a selection's corners to mark which cell in it
161+
#' is active; writexl does not expose that, because ranges are normalised by
162+
#' the shared range parser, which rejects an inverted range.
163+
#' @param top_left The cell scrolled to the top-left of the window when the
164+
#' sheet opens, as an Excel reference such as `"A5"`.
165+
#' @param split Split the sheet into scrollable panes with a visible, movable
166+
#' divider, given as the cell reference the split sits above and to the left
167+
#' of --- `"B3"` splits above row 3 and left of column B. Mutually exclusive
168+
#' with `freeze`, which does the same thing without the divider.
169+
#'
170+
#' libxlsxwriter positions a split by distance, in row-height and
171+
#' column-width units, not by row and column number. writexl converts the
172+
#' cell reference using the sheet's actual row heights and column widths, so
173+
#' the split lands where you asked even after resizing. Pass
174+
#' `list(vertical = , horizontal = )` to give those units directly.
175+
#'
176+
#' Note that libxlsxwriter derives the pane's scroll anchor back from that
177+
#' distance assuming default row heights, so on a sheet with resized rows or
178+
#' columns the divider is placed correctly but the anchor cell may be a row or
179+
#' two out.
144180
#' @param page An [xl_page_setup()] describing how the sheet prints
145181
#' (orientation, paper size, margins, scaling, header and footer). Affects
146182
#' printing only, never the cell data.
@@ -162,7 +198,9 @@ xl_sheet <- function(data, cols = NULL, rows = NULL, freeze = NULL,
162198
default_row_height = NA, auto_colwidth = FALSE,
163199
autofilter = FALSE, protect = FALSE,
164200
comment_author = NA, show_comments = FALSE,
165-
page = NULL) {
201+
page = NULL, active = NA, selected = NA, visible = NA,
202+
first_tab = NA, hide_zero = NA, right_to_left = NA,
203+
selection = NULL, top_left = NULL, split = NULL) {
166204
if (!is.data.frame(data))
167205
stop("`data` must be a data frame", call. = FALSE)
168206
if (!is.logical(auto_colwidth) || length(auto_colwidth) != 1L || is.na(auto_colwidth))
@@ -189,7 +227,16 @@ xl_sheet <- function(data, cols = NULL, rows = NULL, freeze = NULL,
189227
protect = protect,
190228
comment_author = comment_author,
191229
show_comments = show_comments,
192-
page = page
230+
page = page,
231+
active = .val_flag(active, "active"),
232+
selected = .val_flag(selected, "selected"),
233+
visible = .val_flag(visible, "visible"),
234+
first_tab = .val_flag(first_tab, "first_tab"),
235+
hide_zero = .val_flag(hide_zero, "hide_zero"),
236+
right_to_left = .val_flag(right_to_left, "right_to_left"),
237+
selection = selection,
238+
top_left = top_left,
239+
split = split
193240
),
194241
class = "xl_sheet"
195242
)
@@ -374,6 +421,7 @@ print.xl_sheet <- function(x, ...) {
374421
comment_author <- NA_character_
375422
show_comments <- FALSE
376423
page_payload <- NULL
424+
view <- list()
377425

378426
if (inherits(el, "xl_sheet")) {
379427
# column specs
@@ -417,6 +465,17 @@ print.xl_sheet <- function(x, ...) {
417465
overlay <- c(overlay, .as_overlay_list(el$overlay))
418466
protect <- .resolve_protect(el$protect)
419467
page_payload <- .page_setup_payload(el$page, df, header_offset)
468+
for (k in c("active", "selected", "visible", "first_tab", "hide_zero",
469+
"right_to_left"))
470+
if (!is.null(el[[k]])) view[[k]] <- as.integer(isTRUE(el[[k]]))
471+
if (!is.null(el$selection))
472+
view$selection <- .xl_resolve_range(el$selection, arg = "selection",
473+
df = df, header_offset = header_offset,
474+
allow_cell = TRUE)
475+
if (!is.null(el$top_left))
476+
view$top_left <- .xl_resolve_range(el$top_left, arg = "top_left",
477+
df = df, header_offset = header_offset,
478+
allow_cell = TRUE)[1:2]
420479
comment_author <- el$comment_author
421480
show_comments <- isTRUE(el$show_comments)
422481
# auto column widths (for columns the user did not size explicitly)
@@ -429,6 +488,18 @@ print.xl_sheet <- function(x, ...) {
429488
}
430489
}
431490

491+
# The split is resolved last, because converting a cell reference into
492+
# libxlsxwriter's units needs the sheet's final row heights and column widths
493+
# (including any set by auto_colwidth just above).
494+
if (inherits(el, "xl_sheet") && !is.null(el$split)) {
495+
if (!is.null(el$freeze) && !(length(el$freeze) == 1L && is.na(el$freeze)))
496+
stop("`split` and `freeze` cannot both be set: Excel supports frozen ",
497+
"panes or a split, not both", call. = FALSE)
498+
view$split <- .resolve_split(el$split, df, header_offset, props,
499+
default_row_height, row_row, row_height,
500+
col_width)
501+
}
502+
432503
col_format_id <- vapply(col_fmt, function(f) .register_format(reg, f), integer(1))
433504

434505
list(
@@ -443,6 +514,7 @@ print.xl_sheet <- function(x, ...) {
443514
protect_password = protect$password, protect_options = protect$options,
444515
comment_author = as.character(comment_author),
445516
show_comments = as.integer(show_comments),
446-
page = page_payload
517+
page = page_payload,
518+
view = if (length(view)) view else NULL
447519
)
448520
}

man/xl_sheet.Rd

Lines changed: 55 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)