From 35c9e5c5cbd9613f6a0d0011e6d5e0f61d4da0cc Mon Sep 17 00:00:00 2001 From: OrangeX4 <318483724@qq.com> Date: Mon, 2 Sep 2024 17:07:09 +0800 Subject: [PATCH] theme(stargazer): complete stargazer theme --- README.md | 3 +- examples/stargazer.typ | 66 ++++++ src/components.typ | 87 +++++++- src/utils.typ | 20 ++ themes/dewdrop.typ | 8 +- themes/stargazer.typ | 471 +++++++++++++++++++++++++++++++++++++++++ themes/themes.typ | 3 +- 7 files changed, 647 insertions(+), 11 deletions(-) create mode 100644 examples/stargazer.typ create mode 100644 themes/stargazer.typ diff --git a/README.md b/README.md index d23c81074..f2d1b9680 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ If you like it, consider [giving a star on GitHub](https://github.com/touying-ty ![GitHub](https://img.shields.io/github/license/touying-typ/touying) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/touying-typ/touying) ![GitHub Repo stars](https://img.shields.io/github/stars/touying-typ/touying) -![Themes badge](https://img.shields.io/badge/themes-5-aqua) +![Themes badge](https://img.shields.io/badge/themes-6-aqua) ## Document @@ -374,6 +374,7 @@ Thanks to... - [@Enivex](https://github.com/Enivex) for the `metropolis` theme - [@drupol](https://github.com/drupol) for the `university` theme - [@pride7](https://github.com/pride7) for the `aqua` theme +- [@Coekjan](https://github.com/Coekjan) and [@QuadnucYard](https://github.com/QuadnucYard) for the `stargazer` theme - [@ntjess](https://github.com/ntjess) for contributing to `fit-to-height`, `fit-to-width` and `cover-with-rect` ## Star History diff --git a/examples/stargazer.typ b/examples/stargazer.typ new file mode 100644 index 000000000..42e4396ef --- /dev/null +++ b/examples/stargazer.typ @@ -0,0 +1,66 @@ +#import "../lib.typ": * +#import themes.stargazer: * + +#import "@preview/numbly:0.1.0": numbly + +#show: stargazer-theme.with( + aspect-ratio: "16-9", + config-info( + title: [Stargazer in Touying: Customize Your Slide Title Here], + subtitle: [Customize Your Slide Subtitle Here], + author: [Authors], + date: datetime.today(), + institution: [Institution], + logo: emoji.school, + ), +) + +#set heading(numbering: numbly("{1}.", default: "1.1")) + +#title-slide() + +#outline-slide() + += Section A + +== Subsection A.1 + +#tblock(title: [Theorem])[ + A simple theorem. + + $ x_(n+1) = (x_n + a / x_n) / 2 $ +] + +== Subsection A.2 + +A slide without a title but with *important* information. + += Section B + +== Subsection B.1 + +#lorem(80) + +#focus-slide[ + Wake up! +] + +== Subsection B.2 + +We can use `#pause` to #pause display something later. + +#pause + +Just like this. + +#meanwhile + +Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously. + +#show: appendix + += Appendix + +== Appendix + +Please pay attention to the current slide number. \ No newline at end of file diff --git a/src/components.typ b/src/components.typ index 1e0f2bc7d..df479eb20 100644 --- a/src/components.typ +++ b/src/components.typ @@ -265,13 +265,18 @@ transform: (cover: false, alpha: alpha, ..args, it) => { let array-at(arr, idx) = arr.at(idx, default: arr.last()) let set-text(level, body) = { - set text(fill: ( + set text(fill: { + let text-color = if type(text-fill) == array and text-fill.len() > 0 { + array-at(text-fill, level - 1) + } else { + text.fill + } if cover { - utils.update-alpha(array-at(text-fill, level - 1), alpha) + utils.update-alpha(text-color, alpha) } else { - array-at(text-fill, level - 1) + text-color } - )) if type(text-fill) == array and text-fill.len() > 0 + }) set text( size: array-at(text-size, level - 1), ) if type(text-size) == array and text-size.len() > 0 @@ -458,4 +463,78 @@ set text(size: .7em) grid(columns: cols.map(_ => auto).intersperse(1fr), ..cols.intersperse([])) } +) + + + +#let simple-navigation( + self: none, + short-heading: true, + primary: white, + secondary: gray, + background: black, + logo: none, +) = ( + context { + let body() = { + let sections = query(heading.where(level: 1)) + if sections.len() == 0 { + return + } + let current-page = here().page() + set text(size: 0.5em) + for (section, next-section) in sections.zip(sections.slice(1) + (none,)) { + set text(fill: if section.location().page() <= current-page and ( + next-section == none or current-page < next-section.location().page() + ) { + primary + } else { + secondary + }) + box(inset: 0.5em)[#link( + section.location(), + if short-heading { + utils.short-heading(self: self, section) + } else { + section.body + }, + )] + } + } + block( + fill: background, + inset: 0pt, + outset: 0pt, + grid( + align: center + horizon, + columns: (1fr, auto), + rows: 1.8em, + gutter: 0em, + cell( + fill: background, + body(), + ), + block(fill: background, inset: 4pt, height: 100%, text(fill: primary, logo)), + ), + ) + } +) + + +/// LaTeX-like knob marker for list +/// +/// Example: `#set list(marker: components.knob-marker(primary: rgb("005bac")))` +#let knob-marker(primary: rgb("#005bac")) = box( + width: 0.5em, + place( + dy: 0.1em, + circle( + fill: gradient.radial( + primary.lighten(100%), + primary.darken(40%), + focal-center: (30%, 30%), + ), + radius: 0.25em, + ), + ), ) \ No newline at end of file diff --git a/src/utils.typ b/src/utils.typ index bea4ecc4d..cff4c5666 100644 --- a/src/utils.typ +++ b/src/utils.typ @@ -1055,4 +1055,24 @@ if show-notes-on-second-screen != none { slide-note-state.update(setting(note)) } +} + + +/// i18n Outline Title +#let i18n-outline-title = context { + let mapping = ( + ar: "المحتويات", + ca: "Índex", + cs: "Obsah", + da: "Indhold", + en: "Outline", + es: "Índice", + et: "Sisukord", + fi: "Sisällys", + ja: "目次", + ru: "Содержание", + zh-TW: "目錄", + zh: "目录", + ) + mapping.at(text.lang, default: mapping.en) } \ No newline at end of file diff --git a/themes/dewdrop.typ b/themes/dewdrop.typ index a78c11dd8..49a5bf756 100644 --- a/themes/dewdrop.typ +++ b/themes/dewdrop.typ @@ -167,7 +167,7 @@ /// Outline slide for the presentation. -#let outline-slide(..args) = touying-slide-wrapper(self => { +#let outline-slide(title: utils.i18n-outline-title, ..args) = touying-slide-wrapper(self => { self = utils.merge-dicts( self, config-page( @@ -182,7 +182,7 @@ 1.2em, fill: self.colors.primary, weight: "bold", - utils.call-or-display(self, self.store.outline-title), + utils.call-or-display(self, title), ), text( fill: self.colors.neutral-darkest, @@ -272,7 +272,7 @@ /// /// - `alpha` is the alpha of transparency. Default is `60%`. /// -/// - `outline-title` is the title of the outline. Default is `[Outline]`. +/// - `outline-title` is the title of the outline. Default is `utils.i18n-outline-title`. /// /// - `subslide-preamble` is the preamble of the subslide. Default is `self => block(text(1.2em, weight: "bold", fill: self.colors.primary, utils.display-current-heading(depth: self.slide-level)))`. /// @@ -298,7 +298,6 @@ footer-right: context utils.slide-counter.display() + " / " + utils.last-slide-number, primary: rgb("#0c4842"), alpha: 60%, - outline-title: [Outline], subslide-preamble: self => block( text(1.2em, weight: "bold", fill: self.colors.primary, utils.display-current-heading(depth: self.slide-level)), ), @@ -349,7 +348,6 @@ footer: footer, footer-right: footer-right, alpha: alpha, - outline-title: outline-title, subslide-preamble: subslide-preamble, ), ..args, diff --git a/themes/stargazer.typ b/themes/stargazer.typ new file mode 100644 index 000000000..6ceab7eff --- /dev/null +++ b/themes/stargazer.typ @@ -0,0 +1,471 @@ +// Stargazer theme. +// Authors: Coekjan, QuadnucYard, OrangeX4 +// Inspired by https://github.com/Coekjan/touying-buaa and https://github.com/QuadnucYard/touying-theme-seu + +#import "../src/exports.typ": * + +#let _typst-builtin-align = align + +#let _tblock(self: none, title: none, it) = { + grid( + columns: 1, + row-gutter: 0pt, + block( + fill: self.colors.primary-dark, + width: 100%, + radius: (top: 6pt), + inset: (top: 0.4em, bottom: 0.3em, left: 0.5em, right: 0.5em), + text(fill: self.colors.neutral-lightest, weight: "bold", title), + ), + + rect( + fill: gradient.linear(self.colors.primary-dark, self.colors.primary.lighten(90%), angle: 90deg), + width: 100%, + height: 4pt, + ), + + block( + fill: self.colors.primary.lighten(90%), + width: 100%, + radius: (bottom: 6pt), + inset: (top: 0.4em, bottom: 0.5em, left: 0.5em, right: 0.5em), + it, + ), + ) +} + + +/// Theorem block for the presentation. +/// +/// - `title` is the title of the theorem. Default is `none`. +/// +/// - `it` is the content of the theorem. +#let tblock(title: none, it) = touying-fn-wrapper(_tblock.with(title: title, it)) + + +/// Default slide function for the presentation. +/// +/// - `title` is the title of the slide. Default is `auto`. +/// +/// - `config` is the configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them. +/// +/// - `repeat` is the number of subslides. Default is `auto`,which means touying will automatically calculate the number of subslides. +/// +/// The `repeat` argument is necessary when you use `#slide(repeat: 3, self => [ .. ])` style code to create a slide. The callback-style `uncover` and `only` cannot be detected by touying automatically. +/// +/// - `setting` is the setting of the slide. You can use it to add some set/show rules for the slide. +/// +/// - `composer` is the composer of the slide. You can use it to set the layout of the slide. +/// +/// For example, `#slide(composer: (1fr, 2fr, 1fr))[A][B][C]` to split the slide into three parts. The first and the last parts will take 1/4 of the slide, and the second part will take 1/2 of the slide. +/// +/// If you pass a non-function value like `(1fr, 2fr, 1fr)`, it will be assumed to be the first argument of the `components.side-by-side` function. +/// +/// The `components.side-by-side` function is a simple wrapper of the `grid` function. It means you can use the `grid.cell(colspan: 2, ..)` to make the cell take 2 columns. +/// +/// For example, `#slide(composer: 2)[A][B][#grid.cell(colspan: 2)[Footer]] will make the `Footer` cell take 2 columns. +/// +/// If you want to customize the composer, you can pass a function to the `composer` argument. The function should receive the contents of the slide and return the content of the slide, like `#slide(composer: grid.with(columns: 2))[A][B]`. +/// +/// - `..bodies` is the contents of the slide. You can call the `slide` function with syntax like `#slide[A][B][C]` to create a slide. +#let slide( + title: auto, + header: auto, + footer: auto, + align: auto, + config: (:), + repeat: auto, + setting: body => body, + composer: auto, + ..bodies, +) = touying-slide-wrapper(self => { + if align != auto { + self.store.align = align + } + // restore typst builtin align function + let align = _typst-builtin-align + if title != auto { + self.store.title = title + } + if header != auto { + self.store.header = header + } + if footer != auto { + self.store.footer = footer + } + let self = utils.merge-dicts( + self, + config-page(fill: self.colors.neutral-lightest), + ) + let new-setting = body => { + show: align.with(self.store.align) + set text(fill: self.colors.neutral-darkest) + show: setting + body + } + touying-slide(self: self, config: config, repeat: repeat, setting: new-setting, composer: composer, ..bodies) +}) + + +/// Title slide for the presentation. You should update the information in the `config-info` function. You can also pass the information directly to the `title-slide` function. +/// +/// Example: +/// +/// ```typst +/// #show: stargazer-theme.with( +/// config-info( +/// title: [Title], +/// logo: emoji.city, +/// ), +/// ) +/// +/// #title-slide(subtitle: [Subtitle]) +/// ``` +#let title-slide(..args) = touying-slide-wrapper(self => { + self.store.title = none + let info = self.info + args.named() + info.authors = { + let authors = if "authors" in info { + info.authors + } else { + info.author + } + if type(authors) == array { + authors + } else { + (authors,) + } + } + let body = { + show: align.with(center + horizon) + block( + fill: self.colors.primary, + inset: 1.5em, + radius: 0.5em, + breakable: false, + { + text(size: 1.2em, fill: self.colors.neutral-lightest, weight: "bold", info.title) + if info.subtitle != none { + parbreak() + text(size: 1.0em, fill: self.colors.neutral-lightest, weight: "bold", info.subtitle) + } + }, + ) + // authors + grid( + columns: (1fr,) * calc.min(info.authors.len(), 3), + column-gutter: 1em, + row-gutter: 1em, + ..info.authors.map(author => text(fill: black, author)), + ) + v(0.5em) + // institution + if info.institution != none { + parbreak() + text(size: 0.7em, info.institution) + } + // date + if info.date != none { + parbreak() + text(size: 1.0em, utils.display-info-date(self)) + } + } + self = utils.merge-dicts( + self, + config-page(fill: self.colors.neutral-lightest), + ) + touying-slide(self: self, body) +}) + + + +/// Outline slide for the presentation. +/// +/// - `title` is the title of the outline. Default is `utils.i18n-outline-title`. +/// +/// - `level` is the level of the outline. Default is `none`. +/// +/// - `numbered` is whether the outline is numbered. Default is `true`. +#let outline-slide( + title: utils.i18n-outline-title, + numbered: true, + level: none, + ..args, +) = touying-slide-wrapper(self => { + self.store.title = title + self = utils.merge-dicts( + self, + config-page(fill: self.colors.neutral-lightest), + ) + touying-slide( + self: self, + align( + self.store.align, + components.adaptive-columns( + text( + fill: self.colors.primary, + weight: "bold", + components.custom-progressive-outline( + level: level, + alpha: self.store.alpha, + indent: (0em, 1em), + vspace: (.4em,), + numbered: (numbered,), + depth: 1, + ..args, + ), + ), + ), + ), + ) +}) + + +/// New section slide for the presentation. You can update it by updating the `new-section-slide-fn` argument for `config-common` function. +/// +/// Example: `config-common(new-section-slide-fn: new-section-slide.with(numbered: false))` +/// +/// - `title` is the title of the section. The default is `utils.i18n-outline-title`. +/// +/// - `level` is the level of the heading. The default is `1`. +/// +/// - `numbered` is whether the heading is numbered. The default is `true`. +/// +/// - `body` is the title of the section. It will be pass by touying automatically. +#let new-section-slide( + title: utils.i18n-outline-title, + level: 1, + numbered: true, + ..args, + body, +) = outline-slide(title: title, level: level, numbered: numbered, ..args) + + + +/// Focus on some content. +/// +/// Example: `#focus-slide[Wake up!]` +/// +/// - `align` is the alignment of the content. Default is `horizon + center`. +#let focus-slide(align: horizon + center, body) = touying-slide-wrapper(self => { + self = utils.merge-dicts( + self, + config-common(freeze-slide-counter: true), + config-page( + fill: self.colors.primary, + margin: 2em, + header: none, + footer: none, + ), + ) + set text(fill: self.colors.neutral-lightest, weight: "bold", size: 1.5em) + touying-slide(self: self, _typst-builtin-align(align, body)) +}) + + +/// End slide for the presentation. +/// +/// - `title` is the title of the slide. Default is `none`. +/// +/// - `body` is the content of the slide. +#let ending-slide(title: none, body) = touying-slide-wrapper(self => { + let content = { + set align(center + horizon) + if title != none { + block( + fill: self.colors.tertiary, + inset: (top: 0.7em, bottom: 0.7em, left: 3em, right: 3em), + radius: 0.5em, + text(size: 1.5em, fill: self.colors.neutral-lightest, title), + ) + } + body + } + touying-slide(self: self, content) +}) + + +/// Touying stargazer theme. +/// +/// Example: +/// +/// ```typst +/// #show: stargazer-theme.with(aspect-ratio: "16-9", config-colors(primary: blue))` +/// ``` +/// +/// Consider using: +/// +/// ```typst +/// #set text(font: "Fira Sans", weight: "light", size: 20pt)` +/// #show math.equation: set text(font: "Fira Math") +/// #set strong(delta: 100) +/// #set par(justify: true) +/// ``` +/// +/// - `aspect-ratio` is the aspect ratio of the slides. Default is `16-9`. +/// +/// - `align` is the alignment of the content. Default is `horizon`. +/// +/// - `title` is the title in header of the slide. Default is `utils.display-current-heading()`. +/// +/// - `header-right` is the right part of the header. Default is `self => self.info.logo`. +/// +/// - `footer` is the footer of the slide. Default is `none`. +/// +/// - `footer-right` is the right part of the footer. Default is `context utils.slide-counter.display() + " / " + utils.last-slide-number`. +/// +/// - `progress-bar` is whether to show the progress bar in the footer. Default is `true`. +/// +/// - `footer-columns` is the columns of the footer. Default is `(25%, 25%, 1fr, 5em)`. +/// +/// - `footer-a` is the left part of the footer. Default is `self => self.info.author`. +/// +/// - `footer-b` is the second left part of the footer. Default is `self => utils.display-info-date(self)`. +/// +/// - `footer-c` is the second right part of the footer. Default is `self => if self.info.short-title == auto { self.info.title } else { self.info.short-title }`. +/// +/// - `footer-d` is the right part of the footer. Default is `context utils.slide-counter.display() + " / " + utils.last-slide-number`. +/// +/// ---------------------------------------- +/// +/// The default colors: +/// +/// ```typ +/// config-colors( +/// primary: rgb("#005bac"), +/// primary-dark: rgb("#004078"), +/// secondary: rgb("#ffffff"), +/// tertiary: rgb("#005bac"), +/// neutral-lightest: rgb("#ffffff"), +/// neutral-darkest: rgb("#000000"), +/// ) +/// ``` +#let stargazer-theme( + aspect-ratio: "16-9", + align: horizon, + alpha: 20%, + title: utils.display-current-heading(), + header-right: self => self.info.logo, + progress-bar: true, + footer-columns: (25%, 25%, 1fr, 5em), + footer-a: self => self.info.author, + footer-b: self => utils.display-info-date(self), + footer-c: self => if self.info.short-title == auto { + self.info.title + } else { + self.info.short-title + }, + footer-d: context utils.slide-counter.display() + " / " + utils.last-slide-number, + ..args, + body, +) = { + let header(self) = { + set _typst-builtin-align(top) + grid( + rows: (auto, auto), + utils.call-or-display(self, self.store.navigation), + utils.call-or-display(self, self.store.header), + ) + } + let footer(self) = { + set text(size: .5em) + set _typst-builtin-align(center + bottom) + grid( + rows: (auto, auto), + utils.call-or-display(self, self.store.footer), + if self.store.progress-bar { + utils.call-or-display( + self, + components.progress-bar(height: 2pt, self.colors.primary, self.colors.neutral-lightest), + ) + }, + ) + } + + show: touying-slides.with( + config-page( + paper: "presentation-" + aspect-ratio, + header: header, + footer: footer, + header-ascent: 0em, + footer-descent: 0em, + margin: (top: 3.5em, bottom: 2.5em, x: 2.5em), + ), + config-common( + slide-fn: slide, + new-section-slide-fn: new-section-slide, + ), + config-methods( + init: (self: none, body) => { + set text(size: 20pt) + set list(marker: components.knob-marker(primary: self.colors.primary)) + show strong: self.methods.alert.with(self: self) + show figure.caption: set text(size: 0.6em) + show footnote.entry: set text(size: 0.6em) + show link: it => if type(it.dest) == str { + set text(fill: self.colors.primary) + it + } else { + it + } + show figure.where(kind: table): set figure.caption(position: top) + + body + }, + alert: utils.alert-with-primary-color, + tblock: _tblock, + ), + config-colors( + primary: rgb("#005bac"), + primary-dark: rgb("#004078"), + secondary: rgb("#ffffff"), + tertiary: rgb("#005bac"), + neutral-lightest: rgb("#ffffff"), + neutral-darkest: rgb("#000000"), + ), + // save the variables for later use + config-store( + align: align, + alpha: alpha, + title: title, + header-right: header-right, + progress-bar: progress-bar, + footer-columns: footer-columns, + footer-a: footer-a, + footer-b: footer-b, + footer-c: footer-c, + footer-d: footer-d, + navigation: self => components.simple-navigation(self: self, primary: white, secondary: gray, background: self.colors.neutral-darkest, logo: utils.call-or-display(self, self.store.header-right)), + header: self => if self.store.title != none { + block( + width: 100%, + height: 1.8em, + fill: gradient.linear(self.colors.primary, self.colors.neutral-darkest), + place(left + horizon, text(fill: self.colors.neutral-lightest, weight: "bold", size: 1.3em, self.store.title), dx: 1.5em), + ) + }, + footer: self => { + let cell(fill: none, it) = rect( + width: 100%, + height: 100%, + inset: 1mm, + outset: 0mm, + fill: fill, + stroke: none, + _typst-builtin-align(horizon, text(fill: self.colors.neutral-lightest, it)), + ) + grid( + columns: self.store.footer-columns, + rows: (1.5em, auto), + cell(fill: self.colors.neutral-darkest, utils.call-or-display(self, self.store.footer-a)), + cell(fill: self.colors.neutral-darkest, utils.call-or-display(self, self.store.footer-b)), + cell(fill: self.colors.primary, utils.call-or-display(self, self.store.footer-c)), + cell(fill: self.colors.primary, utils.call-or-display(self, self.store.footer-d)), + ) + } + ), + ..args, + ) + + body +} diff --git a/themes/themes.typ b/themes/themes.typ index f89f85410..520e49fcc 100644 --- a/themes/themes.typ +++ b/themes/themes.typ @@ -3,4 +3,5 @@ #import "metropolis.typ" #import "dewdrop.typ" #import "university.typ" -#import "aqua.typ" \ No newline at end of file +#import "aqua.typ" +#import "stargazer.typ" \ No newline at end of file