How to implement tab stops?

Many word processors support this feature and I tried to implement it in typst. It works in principle but suffers from 2 issues:

  1. To create a certain horizontal space a box with absolute width is created, this however only works if the box has visible content
  2. More than 4 tab stops in one line cause the failure of document convergence

My current implementation:

#let tab = context {
  let tab_left = 2.5cm // leftmost tab stop (i.e. page margin)
  let tab_width = 2.0cm // width of a single tab stop

  let pos = here().position() // caller position
  let column = 1 + calc.trunc( (pos.x - tab_left) / tab_width )   // column where this tab is located
  let advance_to = tab_left + column * tab_width   // position needed for the next tab stop

  // create box with the required width
  // the box requires visible content to actually occupy the correct width
  // for debug purposes we choose the computed column as content
  box(width: advance_to - pos.x)[#text(size: 8pt, fill: purple)[#column]]
}

I applied this code to the following 3 lines for testing purposes:

One very long text #tab which covers several tab columns #tab ?\
Hello #tab#tab World #tab#tab#tab?\
A #tab B #tab C #tab D #tab E #tab F #tab G

which yields the result

Line 1 is correct but the 2 other lines have too many tabs and show weird behavior after column 4.
It produces the warning

warning: layout did not converge within 5 attempts
 = hint: check if any states or queries are updating themselves

How can I solve the issues 1. and 2. ?

2 Likes

It seems to me that there might be a bit of an XY problem happening here. Generally, I don’t think that tab stops are a natural way of solving problems in Typst, so what are you trying to achieve?

I would expect that usually, a table or grid, potentially using cells with colspan and/or rowspan will be the way to go.

(Still, I also find the behavior a bit puzzling and would like to understand what is going on.)

2 Likes

I do not understand your first question, you could also use box(width: advance_to - pos.x). I would however use h(advance_to - pos.x) instead.

Regarding the second question, the problem is that each #tab moves the rest of the line, hence a fifth #tab would have already been recomputed and moved 4 times. Because of cases like this, context queries have been set to converge within 5 attempts.

I think a good implementation would be (currently) impossible to implement in typst. The following code provides only a broken solution:

#let t = metadata("tab")

#let tabed(c) = {
  let cs = c.children.split(metadata("tab"))
  context grid(
    columns: (2cm,) * 8,
    row-gutter: par.leading,
    ..cs.fold((0, ()), ((col, arr), c) => {
      let s = c.sum()
      let w = int(calc.div-euclid(measure(s).width.pt(), 2cm.pt())) + 1
      if col + w > 8 {
        let (b, t) = s.children.map(t => if t.has("text") {t.text.split(" ")} else {t}).flatten().fold(([],()), ((tmp, res), word) => {
          if res == () {
            let new = if tmp == [] {
              if word == [ ] { tmp } else { word }
            } else if word == [ ] {
              tmp + sym.space
            } else {
              tmp + sym.space + word
            }
            if measure(new).width > (8 - col) * 2cm {
              (word, tmp)
            } else {
              (new, ())
            }
          } else {(tmp + sym.space + word, res)}
        })
        (calc.rem(col + w, 8), arr + (grid.cell(colspan: 8-col, t), grid.cell(colspan:  w - 8 + col, b)))
      } else {
        (calc.rem(col + w, 8), arr + (grid.cell(colspan: w, s),))
      }
    }).at(1)
  )
}

This code does the tabbing via a grid. One can then call

#tabed[
  #lorem(8)#t #lorem(7)#t A#t B#t C#t D #t E #t F #t G
]

One apperent problem with this code are, e.g. linebreaks not working properly.

3 Likes

Thanks for your answer. I used your nice idea with metadata and implemented a tabed environment myself. So far its working quite well.

#let t = metadata("tab")

#let tabed(c, tab_width: 1.55cm) = context {
  let rows = c.children.split(linebreak())
  for (j, row) in rows.enumerate() {
    let advance = 0.0pt
    let num_tabs = 0
    let cont = []
    for child in row + (t,) { // add extra tab stop for loop logic
      if child == t {
        if (num_tabs == 0)  {
          advance += measure(cont).width
          cont // emit content
          cont = []
        }
        num_tabs += 1
      } else {
        if (num_tabs > 0) {
          let column = calc.trunc( advance / tab_width )   // column where the first tab is located
          let advance_by = tab_width * (column + num_tabs) - advance
          h(advance_by) // emit space
          advance += advance_by
        }
        cont += child
        num_tabs = 0
      }
    }
    if (j < rows.len() - 1) {linebreak()} // emit line break
  }
}