How to correctly use state for an ordered outline?

Hello everyone,

For a personal project where i’m trying to create a sorted outline by alphabetical order i found myself blocked due to my understanding of state that i consider has a global variable.

The current implementation of this ordered outline is :

#let alphabet-displayed = state("alphabet-displayed", ())
#let ordered-outline() = context {
  let current-page = here().page()
  let headings = query(selector(heading.where(level : 2)))
  let sorted-headings = headings.sorted(
    key: heading => {if heading.body.has("text") {heading.body.text} else {""}}
  )
  sorted-headings.map(entry => {
      let first-letter = entry.body.text.first() 
      // reimplement default outline.entry
      if entry.numbering != none {
        numbering(entry.numbering, ..counter(heading).at(entry.location()))
      }
      [ ]
      /*    Focus Here    */
      if (first-letter not in alphabet-displayed.get()){
        text(first-letter) + linebreak()
        alphabet-displayed.update(current => current + (first-letter,))
      }
      h(4pt) + entry.body
      box(width: 1fr, repeat[.])
      [#entry.location().page()]
    }).join([ \ ])
}


== Aaaa
some song
== Abaaa
some song
== B
some song
== C
some song
== D
some song

#pagebreak()
= Index
#ordered-outline()

With result :

Result Image

I want the letter of each “sub section” of the outline to be shown only one. How can i achieve this result ? In the example give, “A” should only be noted once even thought multiple song begin with A

Thanks in advance for your help.

Edits

EDIT 1 : Reformat code & image for following @Andrew comment.

Hi there,

Are you creating an index?

This may be of some help in-dexter – Typst Universe or at least some code they are using. Or perhaps glossarium – Typst Universe.

1 Like

Hello,

I’m actually trying to make an index from scratch.

While those libraries are great, they don’t quite fit what I’m trying to achieve, especially after looking through the source code.

I’d like to stick with the state approach, as I think it’ll help me deepen my understanding of how it all works.

Hello. I can’t compile a document that uses your function. See https://sscce.org.

1 Like

The answer may be wrapping another context :

/*    Focus Here    */
#context if (first-letter not in alphabet-displayed.get()){
  text(first-letter) + linebreak()
  alphabet-displayed.update(current => current + (first-letter,))
}
Result

result

Whole code
#set page(
  width:5cm,
  height:auto,
  margin:10pt,
)
#let alphabet-displayed = state("alphabet-displayed", ())
#let writing = state("writing", true)
#let ordered-outline() = context {
  let current-page = here().page()
  let headings = query(selector(heading.where(level : 2)))
  let sorted-headings = headings.sorted(
    key: heading => {if heading.body.has("text") {heading.body.text} else {""}}
  )
  sorted-headings.map(entry => {
      let first-letter = entry.body.text.first() 
      // reimplement default outline.entry
      if entry.numbering != none {
        numbering(entry.numbering, ..counter(heading).at(entry.location()))
      }
      [ ]
      /*    Focus Here    */
      context if (first-letter not in alphabet-displayed.get()){
        text(first-letter) + linebreak()
        alphabet-displayed.update(current => current + (first-letter,))
      }
      h(4pt) + entry.body
      box(width: 1fr, repeat[.])
      [#entry.location().page()]
    }).join([ \ ])
}

== Aaaa
some song
== Abaaa
some song
== B
some song
== C
some song
== D
== Diary life
== #sym.suit.heart
some song

#pagebreak()
= Index
#ordered-outline()

In a context environment, if you want to use an updated state.value , you have to insert another context by nested or parallel context.

This is explained in the context reference, which provides a very good overview. However, in this case, the fix actually uses a parallel context rather than a nested one, so the official explanation might not be entirely sufficient on its own. That’s why I wanted to add my own example to illustrate both patterns.

Another example
#let mycounter = counter("mycounter")

#context[
  #mycounter.step()
  Got #mycounter.display() after calling step().
  // step() inside a context does not take effect immediately.

  #context[
    // Previous step() took effect here due to nesting.
    Got #mycounter.display() by nesting a context.

    // Now insert a counter (i.e., state) update.
    #mycounter.step()

    Got #mycounter.display(), which has not changed.
    // Note: step() does not update the display number in the same context.

    #context[
      // 1. Nesting context
      // Second step() took effect here due to nesting.
      Got #mycounter.display() by nesting again.
    ]
  ]

  #context[
    // 2. Parallelizing context
     // Second step() took effect here due to parallelizing.
    Got #mycounter.display() by using a parallel context.
  ]
]

zyvb

1 Like

If you want to do an outline properly, that mimics the default one, then you need to

  • use blocks,
  • the outline heading should not be included in the PDF Document Outline, nor have numbering,
  • the heading level is 2 for no reason, unless there is a context missing,
  • and the is link missing to each heading.

Though, the indent thingy kind of goes away, since you make a custom one-time indent (I’m not sure if grid is used in the new outline). I feel the most natural way is to group headings by the first letter and then work on the dictionary and then on individual headings. You also have some stuff that is never used.

#import "@preview/t4t:0.4.3": get

#let ordered-outline(title: "Index") = context {
  let alphabet-displayed = state("alphabet-displayed", ())
  let headings = query(heading.where(level: 1))
  let index = headings.position(it => get.text(it) == get.text(title))
  if index != none { _ = headings.remove(index) }
  let sorted-headings = headings.sorted(key: get.text)

  let buckets = (:)
  for heading in sorted-headings {
    let letter = get.text(heading).clusters().first()
    buckets.insert(letter, buckets.at(letter, default: ()) + (heading,))
  }

  heading(outlined: false, numbering: none, title)
  buckets
    .pairs()
    .map(((letter, headings)) => {
      let fill = box(width: 1fr, repeat[.])
      let headings = for entry in headings {
        let numbering = if entry.numbering != none {
          numbering(entry.numbering, ..counter(heading).at(entry.location()))
          h(0.3em)
        }
        let page = entry.location().page()
        let body = pad(left: 0.7em, block[#numbering#entry.body #fill #page])
        link(entry.location(), body)
      }
      block(letter, spacing: 0.65em)
      block(spacing: 0pt, layout(size => {
        let extend = 0.4em
        place(dx: 0.3em, dy: -extend, line(
          length: measure(headings).height + extend,
          angle: 90deg,
          stroke: purple,
        ))
      }))
      headings
    })
    .join()
}

#set heading(numbering: "1.")

#ordered-outline()

= #text(red)[Aaaa]
= Aaaa
some song
= Cheading
some song
= Bheading
#pagebreak()
some song
= Dheading
some song
= Aaaaa
some song

image

You can of course remove the fancy part:

      block(spacing: 0pt, layout(size => {
        let extend = 0.4em
        place(dx: 0.3em, dy: -extend, line(
          length: measure(headings).height + extend,
          angle: 90deg,
          stroke: purple,
        ))
      }))
1 Like