Repository: insightsengineering/rlistings
Pull request #262: Add align col formats as cols
Author: login: Melkiades
Labels: name: sme
Created: 2025-04-28T20:03:46Z | Merged: 2025-04-30T14:16:19Z

Fix #261 

To allow complete ability to change values (i.e. differently from the column values), I reckon that a new parameter should be added to `formatters`'s `fmt_config()`. I do not think it is the current user need atm though, so I would wait for that.


@xarnim can I ask you if the column's align needs to be sometimes different from the column title alignment? Because that would entail a bit more work ^^

Linked issue #261: [Feature Request]: Alignment of column titles in `as_listing()`
**Summary**

In the `as_listing` function, the user is able to update the column formatting alignment to left, centre, or right with the `col_formatting` parameter. However, this formatting only seems to apply to the column content within the listing, but not the actual column title itself. 

It would be great if the column titles could also align with the column content to improve readability and general aesthetics.  

**Example**

```
# Load necessary libraries
# install.packages("rlistings") # If not already installed
# install.packages("formatters") # If not already installed
library(rlistings)
library(formatters)
library(dplyr) # For data manipulation

# Use the example DM dataset from the formatters package
data(DM, package = "formatters")

# Select a few rows and columns for clarity
dm_subset <- DM %>%
  filter(ARM == "A: Drug X") %>%
  select(ID, ARM, AGE, SEX) %>%
  head(5)

# Create the listing with left alignment for ID and ARM content
listing_obj <- as_listing(
  df = dm_subset,
  key_cols = c("ID"),
  default_formatting = list(all = fmt_config(na_str = "")),
  col_formatting = list(
    ID = fmt_config(align = "left"),
    ARM     = fmt_config(align = "left")
    # AGE and SEX will use default (usually center) alignment for content
  )
)
```

**Workarounds**
It seems there is an existing workaround, however this only seems to be valid in the programming environment and not with the final exported output. 

```
# Print the listing object directly to the console
a <- matrix_form(listing_obj)
a$aligns
#>      [,1]     [,2]     [,3]     [,4]    
#> [1,] "center" "center" "center" "center"
#> [2,] "left"   "left"   "center" "center"
#> [3,] "left"   "left"   "center" "center"
#> [4,] "left"   "left"   "center" "center"
#> [5,] "left"   "left"   "center" "center"
#> [6,] "left"   "left"   "center" "center"
cat(toString(a))
#> subject id      ARM      Age   SEX
#> ——————————————————————————————————
#> S10          A: Drug X   27     F 
#> S14          A: Drug X   31     F 
#> S15          A: Drug X   33     F 
#> S16          A: Drug X   31     F 
#> S6           A: Drug X   29     M
a$aligns[,2] <- "left"
cat(toString(a))
#> subject id   ARM         Age   SEX
#> ——————————————————————————————————
#> S10          A: Drug X   27     F 
#> S14          A: Drug X   31     F 
#> S15          A: Drug X   33     F 
#> S16          A: Drug X   31     F 
#> S6           A: Drug X   29     M
```


Diff:
diff --git a/NAMESPACE b/NAMESPACE
index 47b84d0e..7ef8c8a9 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -1,9 +1,11 @@
 # Generated by roxygen2: do not edit by hand
 
 S3method(print,listing_df)
+export("align_colnames<-")
 export("listing_dispcols<-")
 export(add_listing_col)
 export(add_listing_dispcol)
+export(align_colnames)
 export(as_keycol)
 export(as_listing)
 export(export_as_txt)
diff --git a/NEWS.md b/NEWS.md
index 6c2be6e9..9265fb00 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,4 +1,5 @@
 ## rlistings 0.2.11.9000
+ * Added parameter `align_colnames` to `as_listings()`, along with post-processing functions `align_colnames()` and `align_colnames()<-`. This flag allows to align colnames as the column content is aligned.
 
 ## rlistings 0.2.11
  * Added parameter `sort_cols` to `as_listing` to specify columns to sort the listing by. Previously listings were always sorted by key columns.
diff --git a/R/rlistings.R b/R/rlistings.R
index 3f27d5c9..30daf4c5 100644
--- a/R/rlistings.R
+++ b/R/rlistings.R
@@ -30,6 +30,8 @@ setOldClass(c("MatrixPrintForm", "list"))
 #'   columns when rendering the listing. Each name-value pair consists of a name corresponding to a column name and a
 #'   value of type `fmt_config` with the formatting configuration that should be implemented for that column. Objects
 #'   of type `fmt_config` can take 3 arguments: `format`, `na_str`, and `align`. Defaults to `NULL`.
+#' @param align_colnames (`flag`)\cr whether the column titles should have the same alignment as their columns. All
+#'   titles default to `"center"` alignment if `FALSE` (default). This can be changed with `align_colnames()`.
 #' @param add_trailing_sep (`character` or `numeric` or `NULL`)\cr If it is assigned to one or more column names,
 #'   a trailing separator will be added between groups with identical values for that column. Numeric option allows
 #'   the user to specify in which rows it can be added. Defaults to `NULL`.
@@ -143,6 +145,7 @@ as_listing <- function(df,
                        unique_rows = FALSE,
                        default_formatting = list(all = fmt_config()),
                        col_formatting = NULL,
+                       align_colnames = FALSE,
                        add_trailing_sep = NULL,
                        trailing_sep = " ",
                        main_title = NULL,
@@ -254,6 +257,10 @@ as_listing <- function(df,
     df[[col]]
   })
 
+  # Check and set align_colnames
+  checkmate::assert_flag(align_colnames)
+  align_colnames(df) <- align_colnames
+
   if (unique_rows) df <- df[!duplicated(df[, cols]), ]
 
   class(df) <- c("listing_df", class(df))
@@ -418,9 +425,15 @@ setMethod(
       bodymat
     )
 
+    col_alignment_values <- sapply(listing, obj_align)
+    colnames_align <- if (isFALSE(align_colnames(obj))) {
+      rep("center", length(cols))
+    } else {
+      col_alignment_values
+    }
     colaligns <- rbind(
-      rep("center", length(cols)),
-      matrix(sapply(listing, obj_align),
+      unname(colnames_align),
+      matrix(col_alignment_values,
         ncol = length(cols),
         nrow = nrow(fullmat) - 1,
         byrow = TRUE
@@ -510,6 +523,21 @@ add_listing_dispcol <- function(df, new) {
   attr(df, "listing_dispcols") <- unique(value)
   df
 }
+
+#' @export
+#' @rdname listings
+align_colnames <- function(df) attr(df, "align_colnames") %||% FALSE
+
+#' @param value (`string`)\cr new value.
+#'
+#' @export
+#' @rdname listings
+`align_colnames<-` <- function(df, value) {
+  checkmate::assert_flag(value)
+  attr(df, "align_colnames") <- value
+  df
+}
+
 #' @keywords internal
 listing_trailing_sep <- function(df) attr(df, "listing_trailing_sep") %||% NULL
 
diff --git a/R/rlistings_methods.R b/R/rlistings_methods.R
index 147bc5c9..87fe1f27 100644
--- a/R/rlistings_methods.R
+++ b/R/rlistings_methods.R
@@ -21,26 +21,29 @@ print.listing_df <- function(x,
                              col_gap = 3L,
                              round_type = c("iec", "sas"),
                              ...) {
-  tryCatch({
-    cat(
-      toString(
-        matrix_form(x, fontspec = fontspec, col_gap = col_gap),
-        widths = widths,
-        tf_wrap = tf_wrap,
-        max_width = max_width,
-        fontspec = fontspec,
-        col_gap = col_gap,
-        round_type = round_type,
-        ...
+  tryCatch(
+    {
+      cat(
+        toString(
+          matrix_form(x, fontspec = fontspec, col_gap = col_gap),
+          widths = widths,
+          tf_wrap = tf_wrap,
+          max_width = max_width,
+          fontspec = fontspec,
+          col_gap = col_gap,
+          round_type = round_type,
+          ...
+        )
       )
-    )
-  }, error = function(e) {
-    if (nrow(x) == 0) {
-      print("No observation in the listing object.")
-    } else {
-      stop(e)
+    },
+    error = function(e) {
+      if (nrow(x) == 0) {
+        print("No observation in the listing object.")
+      } else {
+        stop(e)
+      }
     }
-  })
+  )
   invisible(x)
 }
 
@@ -121,7 +124,8 @@ setMethod("vec_nlines", "ANY", function(vec, max_width = NULL, fontspec = dflt_c
   }
   # in formatters for characters
   unlist(lapply(format_colvector(colvec = vec, round_type = round_type), nlines,
-                max_width = max_width, fontspec = fontspec))
+    max_width = max_width, fontspec = fontspec
+  ))
 })
 
 ## setMethod("vec_nlines", "character", function(vec, max_width = NULL) {
diff --git a/man/listings.Rd b/man/listings.Rd
index 7510ed83..5cfa3423 100644
--- a/man/listings.Rd
+++ b/man/listings.Rd
@@ -8,6 +8,8 @@
 \alias{listing_dispcols}
 \alias{add_listing_dispcol}
 \alias{listing_dispcols<-}
+\alias{align_colnames}
+\alias{align_colnames<-}
 \alias{add_listing_col}
 \title{Create a listing from a \code{data.frame} or \code{tibble}}
 \usage{
@@ -20,6 +22,7 @@ as_listing(
   unique_rows = FALSE,
   default_formatting = list(all = fmt_config()),
   col_formatting = NULL,
+  align_colnames = FALSE,
   add_trailing_sep = NULL,
   trailing_sep = " ",
   main_title = NULL,
@@ -41,6 +44,10 @@ add_listing_dispcol(df, new)
 
 listing_dispcols(df) <- value
 
+align_colnames(df)
+
+align_colnames(df) <- value
+
 add_listing_col(
   df,
   name,
@@ -81,6 +88,9 @@ columns when rendering the listing. Each name-value pair consists of a name corr
 value of type \code{fmt_config} with the formatting configuration that should be implemented for that column. Objects
 of type \code{fmt_config} can take 3 arguments: \code{format}, \code{na_str}, and \code{align}. Defaults to \code{NULL}.}
 
+\item{align_colnames}{(\code{flag})\cr whether the column titles should have the same alignment as their columns. All
+titles default to \code{"center"} alignment if \code{FALSE} (default). This can be changed with \code{align_colnames()}.}
+
 \item{add_trailing_sep}{(\code{character} or \code{numeric} or \code{NULL})\cr If it is assigned to one or more column names,
 a trailing separator will be added between groups with identical values for that column. Numeric option allows
 the user to specify in which rows it can be added. Defaults to \code{NULL}.}
diff --git a/tests/testthat/_snaps/matrix_form.md b/tests/testthat/_snaps/matrix_form.md
new file mode 100644
index 00000000..181cc824
--- /dev/null
+++ b/tests/testthat/_snaps/matrix_form.md
@@ -0,0 +1,18 @@
+# align_colnames can change alignment for column titles
+
+    Code
+      cat(toString(out[[2]]))
+    Output
+      Unique Subject Identifier      A   
+      ———————————————————————————————————
+        AB12345-BRA-1-id-134      AB12345
+
+---
+
+    Code
+      cat(toString(out[[2]]))
+    Output
+      Unique Subject Identifier   A      
+      ———————————————————————————————————
+        AB12345-BRA-1-id-134      AB12345
+
diff --git a/tests/testthat/test-matrix_form.R b/tests/testthat/test-matrix_form.R
index cd43445f..77a63d0a 100644
--- a/tests/testthat/test-matrix_form.R
+++ b/tests/testthat/test-matrix_form.R
@@ -69,3 +69,34 @@ test_that("matrix_form detects { or } in labels and sends meaningful error messa
   expect_true(grepl(toString(lsting), pattern = "\\(1\\) adasdasd"))
   expect_true(grepl(toString(lsting), pattern = "A: Drug X\\(1\\)"))
 })
+
+test_that("align_colnames can change alignment for column titles", {
+  dat <- ex_adae[1:3, ]
+  attr(dat$STUDYID, "label") <- "A" # For clearer alignment
+
+  lsting <- as_listing(dat,
+    key_cols = "USUBJID",
+    disp_cols = "STUDYID",
+    col_formatting = c("STUDYID" = fmt_config(align = "left")),
+    align_colnames = TRUE
+  )
+
+  # same alignment
+  mat <- matrix_form(lsting)
+  expect_equal(mat$aligns[, 2], rep("left", 4))
+
+  # post-processing changes
+  align_colnames(lsting) <- FALSE
+  mat <- matrix_form(lsting)
+  expect_equal(mat$aligns[1, 2], "center")
+
+  # pagination
+  align_colnames(lsting) <- FALSE
+  out <- paginate_listing(lsting, lpp = 4, print_pages = FALSE)
+  expect_snapshot(cat(toString(out[[2]])))
+
+  # pagination
+  align_colnames(lsting) <- TRUE
+  out <- paginate_listing(lsting, lpp = 4, print_pages = FALSE)
+  expect_snapshot(cat(toString(out[[2]])))
+})


Reviews:
user: login: edelarua
body: Hi @Melkiades,

This looks great!! Just a suggestion from me for an alternative parameter name since it's quite long. Plus the unit test fixes of course! :)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T21:32:31Z
user: login: shajoezhu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T05:22:34Z
user: login: shajoezhu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T05:23:26Z
user: login: Melkiades
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:40:04Z
user: login: Melkiades
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:41:05Z
user: login: Melkiades
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:42:57Z
user: login: shajoezhu
body: this is fantastic! great work @Melkiades thanks for the fast turn around!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-30T03:05:42Z

Review comments:
diff_hunk: @@ -143,6 +145,7 @@ as_listing <- function(df,
                        unique_rows = FALSE,
                        default_formatting = list(all = fmt_config()),
                        col_formatting = NULL,
+                       align_coltitles_as_cols = FALSE,
path: R/rlistings.R
user: login: edelarua
body: Argument name is a bit wordy - maybe just `align_col_titles` or `align_colnames` or `align_labels`? I think the param description is explanatory enough. What do you think?
created_at: 2025-04-28T21:23:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,6 +30,8 @@ setOldClass(c("MatrixPrintForm", "list"))
 #'   columns when rendering the listing. Each name-value pair consists of a name corresponding to a column name and a
 #'   value of type `fmt_config` with the formatting configuration that should be implemented for that column. Objects
 #'   of type `fmt_config` can take 3 arguments: `format`, `na_str`, and `align`. Defaults to `NULL`.
+#' @param align_coltitles_as_cols (`flag`)\cr whether the column titles have the same alignment as columns. All values
+#'   defaults to `"center"` if `FALSE` (default).
path: R/rlistings.R
user: login: edelarua
body: ```suggestion
#' @param align_coltitles_as_cols (`flag`)\cr whether the column titles should have the same alignment as their columns. All titles
#'   default to `"center"` alignment if `FALSE` (default).
```
created_at: 2025-04-28T21:27:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -418,9 +425,15 @@ setMethod(
       bodymat
     )
 
+    col_alignment_values <- sapply(listing, obj_align)
+    colnames_align <- if (isFALSE(align_coltitles_as_cols(obj))) {
+      rep("center", length(cols))
+    } else {
+      col_alignment_values
+    }
     colaligns <- rbind(
-      rep("center", length(cols)),
-      matrix(sapply(listing, obj_align),
+      colnames_align,
+      matrix(col_alignment_values,
path: R/rlistings.R
user: login: shajoezhu
body: so this will allow us to post process a listing, and reset the alignment right?
created_at: 2025-04-29T05:22:34Z
author_association: COLLABORATOR
diff_hunk: @@ -69,3 +69,14 @@ test_that("matrix_form detects { or } in labels and sends meaningful error messa
   expect_true(grepl(toString(lsting), pattern = "\\(1\\) adasdasd"))
   expect_true(grepl(toString(lsting), pattern = "A: Drug X\\(1\\)"))
 })
+
+test_that("align_coltitles_as_cols can change alignment for column titles", {
+  lsting <- as_listing(ex_adae[1:3, ],
+    key_cols = c("USUBJID", "AESOC"),
+    disp_cols = "STUDYID",
+    col_formatting = c("STUDYID" = fmt_config(align = "left")),
+    align_coltitles_as_cols = TRUE
+  )
+  mat <- matrix_form(lsting)
+  expect_equal(unname(mat$aligns[, 3]), rep("left", 4))
path: tests/testthat/test-matrix_form.R
user: login: shajoezhu
body: can we please have snapshot test please. 

and one for post-processing as well?
created_at: 2025-04-29T05:23:26Z
author_association: COLLABORATOR
diff_hunk: @@ -69,3 +69,14 @@ test_that("matrix_form detects { or } in labels and sends meaningful error messa
   expect_true(grepl(toString(lsting), pattern = "\\(1\\) adasdasd"))
   expect_true(grepl(toString(lsting), pattern = "A: Drug X\\(1\\)"))
 })
+
+test_that("align_coltitles_as_cols can change alignment for column titles", {
+  lsting <- as_listing(ex_adae[1:3, ],
+    key_cols = c("USUBJID", "AESOC"),
+    disp_cols = "STUDYID",
+    col_formatting = c("STUDYID" = fmt_config(align = "left")),
+    align_coltitles_as_cols = TRUE
+  )
+  mat <- matrix_form(lsting)
+  expect_equal(unname(mat$aligns[, 3]), rep("left", 4))
path: tests/testthat/test-matrix_form.R
user: login: Melkiades
body: I will add a snapshot testing for pagination, so we know if that process works!
created_at: 2025-04-29T08:40:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -143,6 +145,7 @@ as_listing <- function(df,
                        unique_rows = FALSE,
                        default_formatting = list(all = fmt_config()),
                        col_formatting = NULL,
+                       align_coltitles_as_cols = FALSE,
path: R/rlistings.R
user: login: Melkiades
body: it makes sense! Lets go with `align_colnames`. Thanks Emily ;)
created_at: 2025-04-29T08:41:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -418,9 +425,15 @@ setMethod(
       bodymat
     )
 
+    col_alignment_values <- sapply(listing, obj_align)
+    colnames_align <- if (isFALSE(align_coltitles_as_cols(obj))) {
+      rep("center", length(cols))
+    } else {
+      col_alignment_values
+    }
     colaligns <- rbind(
-      rep("center", length(cols)),
-      matrix(sapply(listing, obj_align),
+      colnames_align,
+      matrix(col_alignment_values,
path: R/rlistings.R
user: login: Melkiades
body: Yes it does!
created_at: 2025-04-29T08:42:56Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-28T20:08:10Z
body: ## Unit Tests Summary
  1 files    5 suites   11s ⏱️
 47 tests  37 ✅ 10 💤 0 ❌
146 runs  127 ✅ 19 💤 0 ❌

Results for commit a4e240d2.

[test-results]:data:application/gzip;base64,H4sIANakEGgC/02Nyw6DIBBFf8Ww7oLH6GB/pkHQhNRHw2Nl+u8FIpblOTdz5iSLXWdPnh17dMRHGwr0CUx0Kthjz1se0xLyBljh5aPWyYjGvO0nH9DbLMquyfzF7NzhLuPiXn7DcEFNMo63uZJjFU2xcBvUx7bZkIAomDlQw5ELBDVwyuSEBqCnRkwStB4lRWQL+f4Azw8D/wQBAAA=

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-28T20:08:13Z
body: 
## Unit Test Performance Difference

<details>
  <summary><b>Additional test case details</b></summary>

| Test Suite | $Status$ | Time on `main` | $±Time$ | Test Case |
|:-----|:----:|:----:|:----:|:-----|
| matrix_form | 👶 |  | $+0.10$ | align_colnames_can_change_alignment_for_column_titles |
</details>

Results for commit a4ebc639915ceee7781dcda283897b72235c9558

♻️ This comment has been updated with latest results.

<!-- Sticky Pull Request Commentmarkdown-table -->
author_association: CONTRIBUTOR
user: login: xarnim
created_at: 2025-04-28T20:23:56Z
body: Hi @Melkiades, 

It's fine to have the column titles align with the content. Personally I would find it easier to read the listing if everything were aligned, and I cannot think of any cases where the titles *need* to have a separate alignment from the content. Hopefully this helps! Thanks a lot for your work. :) 
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-29T09:53:17Z
body: ![badge](https://raw.githubusercontent.com/insightsengineering/rlistings/_xml_coverage_reports/data/261_add_col_formats%40main/./badge.svg)


## Code Coverage Summary

```
Filename                 Stmts    Miss  Cover    Missing
---------------------  -------  ------  -------  ------------------------------------------------
R/paginate_listing.R        29       0  100.00%
R/rlistings_methods.R      133      17  87.22%   40-41, 43, 73, 89, 93, 187-190, 193, 277-283
R/rlistings.R              298      23  92.28%   204-211, 511-514, 518-521, 550-551, 592-595, 655
TOTAL                      460      40  91.30%
```

### Diff against main

```
Filename                 Stmts    Miss  Cover
---------------------  -------  ------  -------
R/rlistings_methods.R       +4       0  +0.40%
R/rlistings.R              +12       0  +0.32%
TOTAL                      +16       0  +0.31%
```


Results for commit: a4e240d272374a62018b7d4450d3b84cc980771f


_Minimum allowed coverage is `80%`_


:recycle: This comment has been updated with latest results


<!-- Sticky Pull Request Commentrlistings//coverage.xml -->
author_association: CONTRIBUTOR