How to get per-section progress indication in Touying slides?

Background

I’m trying to implement a per-section progress tracker (in the form of <slides #> / <# of slides in current section>) in the header of each regular “slide” in my own custom Touying theme.

I’ve whittled down my custom theme code (see below) to be around 120 lines for now (apologies if it is still too long), most of it is copied from the Touying documentation itself under the Build Your Own Theme section.

I’ve thought of using counters, but that requires displaying them with context, and if I store the counters themselves in self using an array in self.store, then I can’t update the array later on as variables from outside the context are read-only (self is the variable from outside the context).

Code

Custom theme file

// Touying_test.typ
#import "@preview/touying:0.6.1": *

#let slide(title: auto, ..args) = touying-slide-wrapper(self => {
  if title != auto {
    self.store.title = title
  }

  // set page
  let header(self) = {
    set align(top)
    block(inset: (left: 1em, right: -1em))[
      #set text(size: .65em)
      #set align(horizon)

      #self.store.current_slide_in_section.step()

      #grid(columns: 2)[
        #box(width: 70%, height: 100%)[
          #utils.display-current-heading(level: 1)
          #linebreak()
          #if self.store.title != none {
            utils.call-or-display(self, self.store.title)
          } else {
            utils.display-current-heading(level: 2)
          }
        ]
      ][
        (#context self.store.current_slide_section_num.display())
        #context self.store.current_slide_in_section.display()
        \/ `<# slides in section>`
      ]
    ]
  }
  let footer(self) = {
    set align(bottom)
    show: pad.with(.4em)
    set text(fill: self.colors.neutral-dark, size: .8em)
    utils.call-or-display(self, self.store.footer)
    h(1fr)
    context utils.slide-counter.display() + " / " + utils.last-slide-number  // Essentially something like this.
  }
  self = utils.merge-dicts(
    self,
    config-page(
      header: header,
      footer: footer
    ),
  )
  touying-slide(self: self, ..args)
})

#let title-slide(..args) = touying-slide-wrapper(self => {
  let self = utils.merge-dicts(
    self,
    config-page(margin: 2em)
  )
  let body = {
    set align(top)
    block[*This is the title slide!*]
  }
  touying-slide(self: self, body)
})

#let new-section-slide(self: none, body) = touying-slide-wrapper(self => {
  // I'm guessing some kind of update to "move on to the next section" needs to happen here...
  context {
    let curr_sec_num = self.store.current_slide_section_num.get().first()
    if self.store.section_totals.len() <= curr_sec_num {
      self.store.section_totals = self.store.section_totals + (counter(str(curr_sec_num)), )  // Problematic line: can't update `self.store.section_totals` as `self` is read-only
    }
  }
  // context self.store.section_totals.at(self.store.current_slide_section_num.get().first())
  self.store.current_slide_section_num.step()
  self.store.current_slide_in_section.update(0)
  
  let main-body = {
    self = utils.merge-dicts(self, config-page(margin: 2em))
    set align(center + horizon)
    set text(size: 2em, fill: self.colors.primary, weight: "bold", style: "italic")
    utils.display-current-heading(level: 1)
  }
  touying-slide(self: self, main-body)
})

#let custom-theme(
  aspect-ratio: "16-9",
  txt-size: 16pt,
  footer: none,
  ..args,
  body
) = {
  set text(size: txt-size)
  set heading(numbering: "1.1.A")

  show: touying-slides.with(
    config-page(
      paper: "presentation-" + aspect-ratio,
      margin: (top: 4em, bottom: 1.5em, x: 1.75em)
    ),
    config-common(
      slide-fn: slide,
      new-section-slide-fn: new-section-slide,
    ),
    config-store(
      title: none,
      footer: footer,
      txt-size: txt-size,
      current_slide_in_section: counter("current_slide_in_section"),
      current_slide_section_num: counter("current_slide_section_num"),
      section_totals: (counter("0"),)  // zero-th idx = sectionless slides, in the beginning
    ),
    ..args,
  )

  body
}

Custom example driver file

#import "@preview/touying:0.6.1": *
#import "Touying_test.typ": *

#show: custom-theme.with(
  aspect-ratio: "16-9",
  footer: [Typst forum MWE],
  config-info(
    author: [HappyAlt],
    date: datetime.today(),
  )
)

#title-slide()

== Just a random title, because why not?

Slide without a section!!! Woo hoo!

= First Section

== First slide

A slide with a title and an *important* information.

== Second slide

This is an _alert._ Nope, *this* is! And so is #alert[this]!.

== Third slide

This is an _alert._ Nope, *this* is! And so is #alert[this]!. (_Déjà vu_)

== Fourth slide

This is an _alert._ Nope, *this* is! And so is #alert[this]!. (_Déjà vu_) (_Déjà vu_)

= Next section!

== Example slide!

#lorem(250)

== Next one!

Just to give you another slide to count.

Example render of the driver file (without the problematic line from the theme file)

1 Like

My solution

I decided to solve it using the following cobbled together method and getting an LLM to read through the Touying source code to pick out counters and metadata.

// In Touying_test.typ

/*
...
*/

// Returns (current_slide_index_in_current_section, total_slides_in_current_section)
// or (none, none) if currently not in a section
#let per_section_progress(level: 1) = {
  let sections = query(heading.where(level: level))

  // all slides are marked by metadata of kind "touying-new-slide"
  let slides = query(<touying-metadata>).filter(it => utils.is-kind(it, "touying-new-slide"))

  // if there are no sections, just show nothing
  if sections.len() == 0 {
    return (none, none)
  } else {
    let current_page = here().page()

    // find which section we’re in (by page)
    let sec_index = sections.filter(hd => hd.location().page() <= current_page).len() - 1
    if sec_index < 0 { // before the first section, show nothing
      return (none, none)
    } else {
      let start_page = sections.at(sec_index).location().page()
      let end_page = if sec_index + 1 < sections.len() {
        sections.at(sec_index + 1).location().page()
      } else {
        calc.inf
      }

      let sec_slides = slides.filter(sl => {
          let p = sl.location().page()
          p >= start_page and p < end_page
        }
      )

      let curr = sec_slides.filter(sl => sl.location().page() <= current_page).len()

      if (sec_slides.len() >= 2) { // Ignore the section title slide and the very first section content slide
        return (curr - 1, sec_slides.len() - 1)
      }
    }
  }
}

/*
...
*/

#let slide(title: auto, ..args) = touying-slide-wrapper(self => {
  if title != auto {
    self.store.title = title
  }

  // set page
  let header(self) = {
    set align(top)
    block(inset: (left: 1em, right: -1em))[
      #set text(size: .65em)
      #set align(horizon)

      #self.store.current_slide_in_section.step()

      #grid(columns: 2)[
        #box(width: 70%, height: 100%)[
          #utils.display-current-heading(level: 1)
          #linebreak()
          #if self.store.title != none {
            utils.call-or-display(self, self.store.title)
          } else {
            utils.display-current-heading(level: 2)
          }
        ]
      ][
        #context {
          let (curr, sec_total) = per_section_progress()
          if curr != none and sec_total != none [
            (#curr / #sec_total)  // Tada!!!
          ]
        }
      ]
    ]
  }
  
  /*
  ...
  */
  
  touying-slide(self: self, ..args)
})

/*
...
*/

The rest of the stuff stayed the same, I just no longer needed the extra counters defined in self.store in the initialisation function for the theme.