--------------------------------------------------------------------------------
-- typewriter.lua - per-character typewriter for Defold GUI text nodes
--------------------------------------------------------------------------------
--
-- Usage (in a .gui_script):
--
-- local typewriter = require "typewriter"
--
-- function init(self)
-- self.box = typewriter.new("text", { speed = 40, fadein_y = 6 }, function(box, text)
-- print("finished:", text) -- listener: every finished text
-- end)
-- self.box:type("One word is red.", nil, function(box, text)
-- print("this text is done") -- callback: only this text
-- end)
-- end
--
-- self.box:finish() -- skip the waiting, fade everything in now
--
-- -- Tags that change how the characters appear (see below):
-- self.box:type("A whole chunk, one letter, "
-- .. "a long text that wraps, line by line, "
-- .. "a long text that wraps, all lines together.")
--
-- -- A dialogue box: pauses the typing, continue() is the "accept" button.
-- self.box:type("Let me think. I got it! Shall we go?")
-- -- on the accept button:
-- if self.box:continue() then -- true: the text is fading out
-- self.box:type("Next text.") -- (or hide the textbox)
-- end
--
-- The node passed to new() is a TEXT node. It is used as
-- * the container of the nodes created for each text,
-- * the template (font, color, outline, tracking, leading ...) the character
-- nodes are cloned from, and
-- * the definition of the text area (its size and pivot), unless options.width
-- is given.
-- Its own text is cleared. Keep its alpha at 1, because child nodes inherit it.
--
-- Node structure: every call of type() creates one empty "run" node as a child of
-- the container, and all character nodes of that text are children of the run
-- node (or of a word node below it, see Textbox:animate()). Fading out an old text
-- therefore needs only two gui.animate calls (alpha and position of the run node),
-- no matter how many characters it has, and the run node is deleted together with
-- all its characters afterwards.
-- The delays between characters are driven by one timer per text, so a character
-- only starts its animations when its time has come. This keeps the number of
-- simultaneously running animations low (Defold allows 1024 at once).
--
-- Tags: anything that looks like , , or
-- is a tag. Tags are never drawn and take no time.
-- * Only these tags are passed on to the text renderer. They are wrapped around
-- the characters they enclose, e.g. "x"
-- is the text of the node for "x". Applying them is left to your text renderer.
-- color, size, gradient, ul, strike, outline, shadow, shake, wave, sprite, link
-- * , , , , , and are handled by
-- this module only (see below). They are never added to the text of a node.
-- * Every other tag is removed and has no effect.
-- Tag names are not case sensitive.
--
-- Self-closing tags: a tag from the list above that ends with "/>" is not an
-- opening tag that waits for a closing tag, it is ONE character in itself:
-- "A logo."
-- * Its node is drawn with exactly the text of the tag, and the tag text is also
-- what is measured to get its width, like the text of any other character.
-- Tags that are open around it (e.g. ) are wrapped around it like
-- around every character; without any, the node text is just the tag.
-- * shows an image: the
-- text renderer only reserves the space (its syntax, the size units em, px and
-- % etc. are handled by Defold, see the manual "Rich text markup"). The
-- module then asks gui.get_layout_objects() for the position and size of the
-- sprite and puts a box node there, a child of the node of the character, so
-- it fades, moves and scales with it. `src` is the name of a texture / atlas
-- of the GUI scene (gui.set_texture) and `animation` the flipbook animation
-- to play (gui.play_flipbook). Without `animation` only the texture is set,
-- without `src` there is no box node at all, the space stays empty.
-- If `src` ends with .png, .jpg or .jpeg (any capitalization) it is an image
-- file instead of an atlas, and `animation` is ignored. A path that starts
-- with "/" is a custom resource of the bundle (sys.load_resource), any other
-- path is a file of the file system (io.open). The image is loaded once per
-- textbox and src, as a GUI texture; Textbox:release_images() deletes them.
-- An image that cannot be loaded prints a warning and leaves the space empty.
-- A sprite works inside , , , and in
-- right-to-left text, too.
-- * It counts as one character for the speed.
-- * It is a word of its own: a line may break before and after it, even when
-- there is no space next to it (the usual no-start/no-end rules still apply to
-- the neighbours, e.g. a line never starts with "." or ends with "(").
-- Inside a there is no break, as for every character. Inside a
-- or it becomes part of the chunk node. In right-to-left mode it is a word node of
-- its own (unless it is inside a ).
-- * Self-closing tags that are not in the list above (and , ,
-- , , ) are removed like any other unknown tag.
--
-- Underlined / struck-through spaces: a space inside or is underlined
-- or struck through too, as long as it is not at the end of its line (a space at
-- an automatic or manual line break, or at the end of the text, is not). Since a
-- text renderer does not draw the line for a node that only contains a space, the
-- node of such a space is "" (same for ).
-- In justified lines the node is stretched to the width of the gap, so the
-- line has no hole. Not done in right-to-left mode, where spaces have no
-- node (inside a the renderer handles the spaces itself).
--
-- ...
-- Counts its content as ONE word: a line is never broken inside it, not even at
-- a space (or hyphen, dash, CJK break) inside the tag. The characters are still
-- typed one by one. Spaces inside a word are not stretched by "justified".
-- If the word is wider than the text area it overflows, like any long word.
-- Line breaks are still possible before and after the tag where they would be
-- possible without it, e.g. "a b c d" can break after "a " and
-- before " d".
--
-- ...
-- Counts its content as ONE character when it appears: everything inside is
-- drawn as a single text node that fades in at once. The character after the
-- chunk still waits as long as if the tag were not there, e.g. a chunk with five
-- letters makes the next character appear after five times the time between
-- two normal characters. A line is never broken inside a chunk (it is one node),
-- so a chunk is also unbreakable like a . The character before and after
-- the chunk decide about line breaks like they would do without the tag.
-- Tags inside a chunk stay inside its node. A line break inside a chunk (a
-- newline character) ends the chunk, the rest becomes a chunk of its own.
-- Nested chunks: the outermost one counts.
--
-- ...
-- Like (one node that appears at once, never broken, tags around it are
-- applied once to the whole node), but its content counts as ONE character for
-- the timing: the character after it starts after the time between two normal
-- characters, however many characters are inside. So abc makes
-- everything after it appear sooner than abc would (or no tag).
-- A line break inside (a newline character) ends it, the rest is a letter of its
-- own. and inside each other: the outermost one counts.
--
-- ...
-- A that may be broken into lines. The text is laid out and wrapped as if
-- the tag were not there, then every line of the block becomes one node: the
-- tags around it are applied once to the whole line, and the line fades in at
-- once at the moment its first character is due. The lines therefore appear one
-- after the other. What follows the block waits for every character in it, as
-- with a , so a block, a chunk, a , or no tag at all end at about
-- the same time. Without a line break inside, a block behaves exactly like a
-- .
-- "Short intro. A long sentence that needs three lines. Bye."
-- Details:
-- * Where lines may break does not change. Use inside to forbid breaks.
-- * Spaces at the start and end of a line (e.g. at an automatic line break) are
-- nodes of their own, outside of the line's node. "justified" does not
-- stretch the gaps
-- inside a line of a block (one node), only the ones around it.
-- * A plain inside a line cannot interrupt it: it is held in front of the
-- item that follows the line. A wait that is directly in front of the first
-- character of a line pauses before that line, as usual. Timed waits work
-- as everywhere.
-- * A or inside a block is a node of its own and cuts the
-- lines around it in two. For nested blocks (, and the
-- automatic ones, see "disable_auto_blocking") the outermost one counts, a
-- block inside a chunk does nothing.
-- * Right-to-left mode: words are the nodes there and they cannot be put
-- together, so the words of one line of a block fade in at the same moment
-- instead (the time of the first of them).
-- * Some tags make a block of their content automatically, see the option
-- "disable_auto_blocking".
--
-- ...
-- Like (every line is one node, text is wrapped as usual), but all lines
-- appear at the SAME time: at the moment its first character is due. The
-- character after it still starts after the time that all characters of the
-- tag would need, like after a or . Newline characters inside a
-- sync do not end it, the lines after them appear together with the rest. Without
-- a line break inside, sync, block and chunk behave the same.
-- A plain anywhere inside a sync is held in front of the item after it
-- (the group cannot be interrupted), a timed one is added in front of that item.
-- A or inside also appears at that moment. In right-to-left
-- mode all words of the tag fade in at the same moment.
--
-- Summary of what the tags do (all are typed at the speed of their characters):
--
-- tag line break inside nodes next character starts after
-- (none) possible one per char all characters
-- no one per char all characters
-- no one all characters
-- no one ONE character
-- yes one per line all characters (lines one by one)
-- yes one per line all characters (lines together)
--
-- and
-- Pauses the typing at the place of the tag. It takes no closing tag, and it has no
-- effect when no character follows it (e.g. at the end of the text).
-- "Let me think. I got it!"
-- With a value, the character after the tag (here the space) appears that many
-- seconds later than it would without the tag, and everything after it moves
-- along. A value of 0 (or less) does nothing; a value that is not a number
-- makes the tag useless, it is ignored.
-- "Do you accept? Great!" (also written )
-- Without a value, the typing stops there until Textbox:continue() is called.
-- Everything before the tag is shown by then, the rest is not drawn yet.
-- A timed wait right before or after a plain one (inside the same gap between
-- two characters) has no effect, the plain one counts.
-- Waits do not need a speed: with speed <= 0 the text appears at once up to the
-- next plain . Textbox:finish() ignores all waits and shows the whole text,
-- Textbox:continue() only goes as far as the next plain .
-- Inside a or (or an Arabic word) a wait cannot interrupt the
-- node, which appears as a whole: the wait is held in front of the character that
-- follows the chunk / word. The same holds for a line of a and for all of
-- a , see there. The same holds for the lines of a tag that makes an automatic
-- block (see disable_auto_blocking).
--
-- Size tags: , ... are applied by the text renderer like any
-- other tag, but they are taken into account for the layout:
-- * Horizontal: the advance of every character (and every word in right-to-left
-- mode) is measured with its tags, so the width follows the size.
-- * Vertical: all characters of a line sit on a common baseline, like in a text
-- editor. The height of a line is that of its largest character (its ascent
-- above the baseline plus the descent below it, both scaled by the size), and
-- the next line starts below it, so lines never overlap. The leading option of
-- the node is applied to this line height, so with text without size tags the
-- result is the same as before.
-- * In right-to-left mode a word is one node, so it is measured as a whole and
-- counts with the largest size of its characters. Different sizes inside one
-- word are left to the renderer.
-- resource.get_text_metrics() scales width and height with a size tag, but not
-- max_ascent and max_descent. The size factor is therefore taken from the height
-- (height with tags / height without), which means that whatever the renderer
-- makes of the tag (percent, plain number, nested tags) is respected without
-- parsing the value here. The characters themselves are still sized only by the
-- tag, they are just positioned differently.
--
-- Scale: the option "scale" (see below) scales every character node with
-- gui.set_scale(), independent of the size tags. A inside a text with
-- scale = 1.5 draws the characters 3 times as large: the node is scaled by 1.5 and
-- the renderer applies the size tag to its text. The layout uses the full size:
-- * Horizontal: the advance of every character (and word) is what
-- resource.get_text_metrics() returns for its tags, times scale x.
-- * Vertical: the size factor of the tags is multiplied by scale y, which is then
-- used for the baselines, ascents, descents and line heights exactly like a
-- size tag.
-- get_text_metrics() knows nothing about the scale of the node, only about the
-- tags, so the scale is always multiplied on top of its results.
--
-- Zero width space (U+200B):
--
-- A place where a line may break, without a space. It has no width, is not drawn
-- and takes no time. For texts without spaces between the words, such as Thai.
-- In Lua source, write it as the UTF-8 bytes "\226\128\139" (the escape "\u{200B}"
-- does not exist in every Lua version Defold runs on). Texts from files or
-- localization tables can contain the character itself.
-- It only adds break places: where text breaks anyway (Thai between syllables, CJK
-- between characters) it does not forbid the others. Inside a or
-- there is no break.
--
-- Fonts:
--
-- typewriter.new("text", {
-- fonts = {
-- latin = "noto_sans",
-- arabic = "noto_arabic",
-- chinese = "noto_chinese_simplified",
-- serif = "noto_serif", -- any other name: only for tags
-- },
-- })
-- typewriter.new("text", { font = "noto_serif" }) -- same as fonts = { latin = "noto_serif" }
--
-- self.box:type("This word is using serifs.")
-- self.box:type("私は iPhone 15 が好きです。") -- Japanese font, if there is one
--
-- The fonts have to be part of the GUI scene already; the values are font names as
-- gui.set_font() takes them. This module only switches the font of the nodes it
-- creates, a font is never written into the text of a node. As a node can only have
-- one font, a text is cut into nodes wherever its font changes. That happens
-- automatically, also where the text could not be cut otherwise (see below).
-- Fonts given to type() are added to the ones of the textbox, and replace the
-- entries with the same name.
--
-- Which font a character gets:
-- 1. ..., the manual way. `name` is a key of the option "fonts"
-- (any key, not only the language names below). The tag is never drawn and takes
-- no time. Tags inside each other: the innermost one counts. A tag without a
-- name, or with a name that is not in "fonts" (a warning is printed once), is
-- skipped. The characters inside the tag are not looked at in 2.
-- 2. Otherwise the font of the language of the text. The language names are:
-- latin Latin, Cyrillic, Greek, Vietnamese (and every script
-- that has no name here)
-- arabic Arabic, Persian, Urdu
-- japanese Hiragana, Katakana
-- chinese Han characters, simplified
-- chinese_traditional Han characters, traditional
-- korean Hangul
-- thai
-- devanagari Hindi, Marathi, Nepali
-- A language without a font uses the font of "latin", and "latin" without a
-- font uses the font of the text node that was given to new(). The four CJK
-- languages are the exception: they stand in for each other (chinese and
-- chinese_traditional first, then japanese and korean), since a CJK font is the
-- only one that has their glyphs.
--
-- How the language is found. Every line of the text (up to a line break) is
-- looked at on its own:
-- * Han characters are Japanese if the line has kana, Korean if it has Hangul,
-- else Chinese. Chinese is simplified, unless a character that exists only in
-- traditional (e.g. 國) appears anywhere in the text: then ALL Chinese text of it
-- is traditional. If characters of both kinds appear, every one of them keeps
-- its own kind and the ones that are the same in both follow the one before.
-- (The characters that tell the two apart are the lists SIMPLIFIED_ONLY and
-- TRADITIONAL_ONLY below. They hold the common ones, not every one that
-- exists. Use where it matters.)
-- * Latin letters (A-Z, with the accents of Latin-1 and Latin Extended-A) and the
-- digits 0-9 have no language of their own. They use the font of the language
-- that dominates the line, so an English word in a Japanese text and the digits
-- in an Arabic text use the Japanese / Arabic font, and fonts are mixed only
-- where they have to be. Dominating means: most characters of one non-Latin
-- script (Han counts as its language) against the number of Latin words. A tie
-- goes to latin. So in an English sentence with one Chinese word the sentence
-- stays latin, and only the Chinese word gets the Chinese font.
-- * Other scripts (Arabic in a Japanese text, Chinese in an Arabic text ...) always
-- get their own font.
-- * Spaces, punctuation and symbols use the font of the character before them
-- (at the start of a line: the font of the dominating language). CJK punctuation
-- and the fullwidth forms use the font of the nearest CJK character instead.
--
-- Where a node has to be cut because of a font:
-- * / : the pieces are separate nodes that appear at the same moment.
-- * / : a line of the block is cut into one node per font. The nodes
-- of one line still appear at the same moment.
-- * Right-to-left mode: a word is cut where its font changes (the letters of the
-- two pieces cannot join). The pieces appear one after the other.
--
-- Options (defaults in DEFAULTS below):
-- speed characters per second (<= 0 means everything at once)
-- In right-to-left mode whole words fade in instead of single
-- characters. The next word starts after the time that the
-- characters of the former word (and the spaces behind it)
-- would have needed, so the same text takes about the same
-- time in both modes. The same rule is used for
-- (and for / in this mode, see there). A
-- counts as one character.
-- fadein_duration seconds a character takes from alpha 0 to 1
-- fadein_x/_y pixels a character travels while fading in
-- (it starts at target - fadein_x/y and ends at the target)
-- fadeout_duration seconds the old characters take from alpha 1 to 0
-- fadeout_x/_y pixels an old character travels while fading out
-- fade_cross 0..1, how much fading out the old text and typing the new
-- one overlap. 1 = both start at the same time, 0 = the new
-- text starts when the old one is completely faded out,
-- values in between start the new text when that fraction
-- of the fade out is still to go (0.5 = when it is half
-- done). Only the fade out duration of the old text counts,
-- not the time it needed to be typed. Use 0 if you run
-- into "Out of animation resources" with long texts.
-- width width of the text area; nil = width of the GUI node
-- tracking number, optional
-- Extra space between the characters, like gui.set_tracking().
-- nil = the tracking of the GUI node. It is used for measuring
-- (resource.get_text_metrics) and set on every character node.
-- leading number, optional
-- Line height factor, like gui.set_leading(). nil = the leading
-- of the GUI node. Used for the distance between the lines and
-- set on every character node.
-- scale number | vector (default 1)
-- Scales every character node (every word node in right-to-left
-- mode) with gui.set_scale(). A number scales x and y by the
-- same factor, a vector (vmath.vector3 / vector4, or a table
-- with x and y fields) gives the factors for x and y; z is
-- ignored. Must be greater than 0.
-- The width of the text area stays the same, so scaled up text
-- breaks into more lines, and the horizontal alignment still
-- refers to the full text area. Nodes are scaled around their
-- top left corner (top centre in right-to-left mode), and the
-- layout takes care of the new advances and line heights: the
-- characters of a line sit on a common baseline, like with size
-- tags. The scale is independent of the size tags and adds to
-- them (scale 1.5 with = 3 times as large).
-- fadein_x/_y and fadeout_x/_y are pixels and not scaled.
-- scale_x, scale_y number, optional
-- Replace the x or y factor of "scale", e.g. scale = 2,
-- scale_y = 1 stretches the text horizontally only.
-- alignment "left" | "right" | "center" | "justified"
-- Horizontal alignment of the lines inside the text area.
-- Default: "left", or "right" for right-to-left text.
-- The names are physical: "left" always means the left edge
-- of the text area, also in right-to-left text.
-- Trailing spaces at a line break are ignored for right and
-- center. "justified" stretches the spaces of every line that
-- was broken automatically so that it touches both sides of
-- the text area. Lines that end with a manual line break and
-- the last line are not stretched, they stay left aligned.
-- Lines without any space (e.g. CJK text) are stretched
-- between the characters instead. A line that is wider than
-- the text area (a single long word) is never moved or
-- stretched. Needs a width > 0, otherwise the text is left
-- aligned.
-- balanced false | true (default false)
-- false: lines are filled greedily, as wide as possible.
-- true: the line breaks are moved so that the longest line
-- gets as short as possible, while the number of lines stays
-- the same as without balancing. Alignment still refers to
-- the full text area, so e.g. "center" centers the balanced
-- lines in the area. Needs a width > 0, otherwise it has no
-- effect.
-- disable_auto_blocking
-- false | true (default false)
-- Some tags apply to the text between their opening and closing
-- tag as a whole (a shake that moves it rigidly, a wave or a
-- gradient across it). Such a tag only works as intended if the
-- renderer gets the text as ONE text, not one character at a
-- time. Quotes and case of the attributes do not matter.
-- false: the content of these tags is made a block, as if it
-- were inside a tag (see there): the text is wrapped as
-- usual, and every line of it becomes one node, with the tags
-- around the whole line. The tags that count:
-- * with fit=span or without a fit attribute, and
-- with the attribute left or tl (the colours only make
-- sense for the whole text):
-- Horizontal
-- Four
-- corners
-- * with fit=glyph or without a fit attribute:
-- Glyph shake
-- * with fit=glyph or without a fit attribute:
-- Travelling wave
-- A tag with any other fit value (e.g. fit=line, or fit=glyph on
-- a gradient) is not changed. Only these tags are grouped, so a
-- long text inside one of them can still break lines, e.g. a
-- horizontal gradient on a paragraph is applied to every line.
-- Details:
-- * A block of this kind behaves like : spaces at the
-- start or end of a line are nodes of their own,
-- "justified" does not stretch the gaps inside a line, the
-- node of a line appears when its first character is due,
-- and what follows waits for every character of it.
-- * The tags inside stay inside the node, the tags around it
-- are written only once.
-- * A or inside is a node of its own and cuts
-- the line in two. For nested blocks, whether automatic or
-- written (, ), the outermost one counts.
-- * A line of one character is just that character.
-- * The number of nodes does not depend on the line breaks (the
-- other characters of a line get empty nodes), so
-- Textbox:animate() can re-wrap the text; the nodes change
-- their text then. Characters that have already appeared
-- show the new text at once, without fading.
-- * Right-to-left mode: the words are the nodes there, so the
-- words of one line start together instead.
-- * See AUTO_BLOCK to extend the list of tags.
-- true: nothing is grouped automatically, every character is its
-- own node and the tag is wrapped around each of them. It is then
-- up to you to write the tags so that they do what you want, e.g.
-- put a or inside (or around) them, or use
-- another fit value.
-- font string, optional
-- The name of a font of the GUI scene. Short for fonts.latin.
-- fonts table, optional
-- { language or name = font name }, see "Fonts" above.
-- If font and fonts.latin are both given, fonts.latin counts.
-- rtl nil | true | false (default nil)
-- nil: right-to-left mode is used automatically if the text
-- contains at least one Arabic letter.
-- true: right-to-left mode is forced (e.g. for Hebrew, or for
-- a text that only consists of digits and punctuation).
-- false: never use it, even if there are Arabic letters.
-- See "Right-to-left mode" below.
--
-- Rearranging a text that is already there:
--
-- self.box:animate({ scale = 2.0, alignment = "right" }, 1.0, gui.EASING_LINEAR)
--
-- Textbox:animate(options[, duration, easing])
-- Calculates the layout (line breaks, positions, scales) the current text would
-- have with the given options, and moves/scales all its nodes there with
-- gui.animate(). Doubling the scale at the same width therefore re-wraps the text
-- into more lines, and the characters glide to their new places.
-- options any of scale (x and y), scale_x, scale_y, width, alignment, tracking,
-- leading, with the same meaning as in new()/type(). (Tracking is not
-- interpolated: the nodes change it at once, only the positions glide.) They replace the options of the
-- current text, so the next animate() starts from the new state; texts
-- typed later use the options of the textbox as before.
-- duration seconds, default 1.0. 0 (or less) changes everything at once.
-- easing gui.EASING_*, default gui.EASING_LINEAR
-- It can be called while the text is still being typed (characters that have not
-- appeared yet are simply placed at their new position) and while an earlier
-- animate() is still running (the nodes continue from where they are). Nodes that
-- do not change are not animated. Every animated node uses up to two of the 1024
-- animations Defold allows at once. To keep that number low, the letters of a word
-- that is completely faded in are moved by ONE node (a word node, created by the
-- first animate() call that finds the word complete), and spaces without tags are
-- not animated at all. Only letters that are still fading in, single-letter words
-- (e.g. CJK text, where lines can break between any two characters) and spaces with
-- tags (e.g. underlined) cost animations of their own. Very long texts can still
-- run into "Out of animation resources" when they are rearranged while still
-- fading in.
-- The word nodes are children of the run node (so they count as GUI nodes: one
-- more per word that was rearranged), and they are drawn after the nodes of the
-- run that are not in a word. This only matters if the outline or shadow of one
-- word overlaps the letters of another.
--
-- Notes:
-- * Positive x moves to the right, positive y moves up (Defold GUI axes).
-- * Lines only break after spaces, after hyphens inside words, after dashes
-- (em/en), and between CJK characters (with a few no-start/no-end rules).
-- A single word that is wider than the text area is never split, it overflows.
--
-- Right-to-left mode:
-- * Arabic letters change their shape depending on their neighbours, so the
-- letters of a word must stay together. In this mode a word (everything
-- between two spaces) is ONE text node. It is measured, positioned and faded
-- in as a whole, and your font/text renderer does the joining of the letters
-- (and the order of letters inside the word). Spaces get no node.
-- Tags inside a word stay inside its node; a tag that changes between two
-- letters of one word interrupts the joining of these letters, as the
-- renderer sees two separate pieces of text.
-- * , and work here too. A chunk is one node, whatever is
-- inside (spaces included), and it ends the Arabic word before it and starts a
-- new one after it, so a chunk inside an Arabic word interrupts the joining.
-- The words inside a stay together on one line.
-- * and (and the automatic blocks) cannot make one node of a line here (the words are the
-- nodes), so the words of one line of a , or of a whole , simply
-- start together. The lines still wrap as usual.
-- * The first word of every line is at the right edge of the text area, the
-- following words go to the left. Lines are filled from the right edge, the
-- text area itself is not mirrored: the right edge is at pivot + width, and if
-- there is no width (> 0) the text starts at the right edge of the node
-- (x = 0 after the pivot offset) and grows to the left, without alignment.
-- * Words in another direction (English words, numbers) are placed from left to
-- right inside the right-to-left line, e.g. "buy iPhone 15 now" in Arabic
-- reads "iPhone 15" in its normal order. The ordering follows the Unicode
-- bidirectional algorithm on word level (strong words, numbers, and neutral
-- words/spaces between them), it does not look inside a word.
-- * Lines only break at spaces (and around self-closing tags). A single word
-- wider than the text area overflows.
-- * Speed: see the option "speed". Combining marks (e.g. harakat) do not count
-- as characters.
--------------------------------------------------------------------------------
local M = {}
local v3 = vmath.vector3
local LINEAR = gui.EASING_LINEAR
local ZWSP = "\226\128\139" -- zero width space (U+200B)
local DEFAULTS = {
speed = 30,
fadein_duration = 0.2,
fadein_x = 0,
fadein_y = 0,
fadeout_duration = 0.2,
fadeout_x = 0,
fadeout_y = 0,
fade_cross = 1,
width = nil,
tracking = nil, -- nil = tracking of the GUI node
leading = nil, -- nil = leading of the GUI node
scale = 1, -- number, or vector with x and y
scale_x = nil, -- overrides the x factor of scale
scale_y = nil, -- overrides the y factor of scale
alignment = nil, -- nil = "left", or "right" for right-to-left text
balanced = false,
disable_auto_blocking = false, -- false: shake / wave / gradient that need one node get a block
rtl = nil, -- nil = automatic (text has Arabic letters), true = always, false = never
}
local byte, sub, find, match, lower = string.byte, string.sub, string.find, string.match, string.lower
local concat = table.concat
--------------------------------------------------------------------------------
-- Character classification
--------------------------------------------------------------------------------
local function make_set(list)
local set = {}
for _, v in ipairs(list) do set[v] = true end
return set
end
-- The tags that are added to the text of the drawn characters. All other tags are
-- dropped, except the ones in GROUPING, which only this module needs.
local DRAWN = make_set({
"color", "size", "gradient", "ul", "strike", "outline", "shadow", "shake", "wave", "sprite", "link",
})
-- The tags only this module handles (never added to the text of a node, see M.parse).
local GROUPING = make_set({ "word", "chunk", "letter", "block", "sync", "font" })
-- Characters that should not start a line / end a line (CJK line breaking).
local NO_START = make_set({
0x2E, 0x2C, 0x21, 0x3F, 0x3B, 0x3A, 0x29, 0x5D, 0x7D, -- . , ! ? ; : ) ] }
0x3001, 0x3002, 0xFF0C, 0xFF0E, 0x30FB, 0xFF1A, 0xFF1B, 0xFF01, 0xFF1F, -- 、。,.・:;!?
0xFF09, 0xFF3D, 0xFF5D, 0x3015, 0x3009, 0x300B, 0x300D, 0x300F, 0x3011, -- )]}〕〉》」』】
0x30FC, -- ー
})
local NO_END = make_set({
0x28, 0x5B, 0x7B, -- ( [ {
0xFF08, 0xFF3B, 0xFF5B, 0x3014, 0x3008, 0x300A, 0x300C, 0x300E, 0x3010, -- ([{〔〈《「『【
})
local SPACES = make_set({ 0x20, 0x09, 0x3000 }) -- space, tab, ideographic space
local function is_cjk(cp)
return (cp >= 0x2E80 and cp <= 0x2FDF)
or (cp >= 0x3000 and cp <= 0x30FF)
or (cp >= 0x31F0 and cp <= 0x31FF)
or (cp >= 0x3400 and cp <= 0x4DBF)
or (cp >= 0x4E00 and cp <= 0x9FFF)
or (cp >= 0xF900 and cp <= 0xFAFF)
or (cp >= 0xFF00 and cp <= 0xFFEF)
or (cp >= 0x20000 and cp <= 0x2FA1F)
end
-- Alphanumeric
local function is_alnum(cp)
return (cp >= 48 and cp <= 57) or (cp >= 65 and cp <= 90) or (cp >= 97 and cp <= 122)
or (cp >= 0xC0 and cp < 0x2000 and cp ~= 0xD7 and cp ~= 0xF7)
end
-- Marks that belong to the character before them (accents, dakuten, variation
-- selectors, emoji skin tones, zero width joiner, the vowel and tone marks of Thai
-- and the vowel signs of Devanagari). Alone in a node they would be drawn on a
-- dotted circle.
local function is_combining(cp)
return (cp >= 0x0300 and cp <= 0x036F)
or (cp >= 0x0900 and cp <= 0x0903) -- Devanagari
or (cp >= 0x093A and cp <= 0x093C)
or (cp >= 0x093E and cp <= 0x094F)
or (cp >= 0x0951 and cp <= 0x0957)
or (cp >= 0x0962 and cp <= 0x0963)
or cp == 0x0E31 or (cp >= 0x0E34 and cp <= 0x0E3A) -- Thai
or (cp >= 0x0E47 and cp <= 0x0E4E)
or (cp >= 0x1AB0 and cp <= 0x1AFF)
or (cp >= 0x1DC0 and cp <= 0x1DFF)
or (cp >= 0x20D0 and cp <= 0x20FF)
or (cp >= 0x3099 and cp <= 0x309A)
or (cp >= 0xFE00 and cp <= 0xFE0F)
or (cp >= 0xFE20 and cp <= 0xFE2F)
or (cp >= 0x1F3FB and cp <= 0x1F3FF)
or (cp >= 0xE0100 and cp <= 0xE01EF)
or cp == 0x200D
end
-- Devanagari consonants. A virama (0x094D) joins the one in front of it with the
-- next one into a conjunct, which has to stay in one node.
local function is_devanagari_consonant(cp)
return (cp >= 0x0915 and cp <= 0x0939) or (cp >= 0x0958 and cp <= 0x095F)
end
-- Thai characters that no line break may be placed in front of (following vowels,
-- the repetition mark, the abbreviation sign) or behind (leading vowels).
local THAI_NO_START = make_set({ 0x0E2F, 0x0E30, 0x0E32, 0x0E33, 0x0E45, 0x0E46, 0x0E4F, 0x0E5A, 0x0E5B })
local function is_thai(cp)
return cp >= 0x0E01 and cp <= 0x0E5B
end
-- Arabic letters (not digits, marks or punctuation). Enough to detect Arabic text.
local function is_arabic_letter(cp)
return (cp >= 0x0620 and cp <= 0x064A)
or (cp >= 0x066E and cp <= 0x06D3) or cp == 0x06D5
or (cp >= 0x06E5 and cp <= 0x06E6) or (cp >= 0x06EE and cp <= 0x06EF)
or (cp >= 0x06FA and cp <= 0x06FF) or (cp >= 0x0750 and cp <= 0x077F)
or (cp >= 0x0870 and cp <= 0x088E) or (cp >= 0x08A0 and cp <= 0x08C9)
or (cp >= 0xFB50 and cp <= 0xFBC2) or (cp >= 0xFBD3 and cp <= 0xFD3D)
or (cp >= 0xFD50 and cp <= 0xFD8F) or (cp >= 0xFD92 and cp <= 0xFDC7)
or (cp >= 0xFDF0 and cp <= 0xFDFB) or (cp >= 0xFE70 and cp <= 0xFEFC)
end
local function is_hebrew(cp)
return (cp >= 0x05D0 and cp <= 0x05F4) or (cp >= 0xFB1D and cp <= 0xFB4F)
end
-- Letters that run right to left (Arabic and Hebrew).
local function is_rtl_letter(cp)
return is_arabic_letter(cp) or is_hebrew(cp)
end
-- Bidirectional class of a character, reduced to what the word level needs:
-- "R" strong right to left "L" strong left to right
-- "EN" European digit "AN" Arabic-Indic digit
-- "N" everything else (neutral, marks, punctuation)
local function char_class(cp)
if cp >= 0x30 and cp <= 0x39 then return "EN" end
if cp >= 0x06F0 and cp <= 0x06F9 then return "EN" end
if cp >= 0x0660 and cp <= 0x0669 then return "AN" end
if is_rtl_letter(cp) then return "R" end
if (cp >= 0x0590 and cp <= 0x06FF) or is_combining(cp) then return "N" end
if is_alnum(cp) or is_cjk(cp) then return "L" end
return "N"
end
--------------------------------------------------------------------------------
-- UTF-8
--------------------------------------------------------------------------------
-- Byte length of the UTF-8 character starting at position i. Invalid bytes count
-- as a character of length 1, so broken input never loops or errors.
local function utf8_length(s, i)
local b = byte(s, i)
local len
if b >= 0xC2 and b <= 0xDF then
len = 2
elseif b >= 0xE0 and b <= 0xEF then
len = 3
elseif b >= 0xF0 and b <= 0xF4 then
len = 4
else
return 1 -- ASCII or an invalid first byte
end
if i + len - 1 > #s then return 1 end
for j = i + 1, i + len - 1 do
local continuation = byte(s, j)
if continuation < 0x80 or continuation > 0xBF then return 1 end
end
return len
end
local function codepoint(ch)
local b1, b2, b3, b4 = byte(ch, 1, 4)
local len = #ch
if len == 1 then
return b1
elseif len == 2 then
return (b1 - 0xC0) * 64 + (b2 - 0x80)
elseif len == 3 then
return (b1 - 0xE0) * 4096 + (b2 - 0x80) * 64 + (b3 - 0x80)
else
return (b1 - 0xF0) * 262144 + (b2 - 0x80) * 4096 + (b3 - 0x80) * 64 + (b4 - 0x80)
end
end
--------------------------------------------------------------------------------
-- Parsing: text -> tokens
--------------------------------------------------------------------------------
-- Wraps every character token in the tags that are open at that point.
-- Only the tags in DRAWN are wrapped around it, other tags are ignored.
-- Tags with the same name override each other: the most recently opened one wins
-- until it is closed, then the previous one is active again. Different names are
-- all applied, outermost = opened first. Unclosed tags stay open until the end.
-- , , , , and are not wrapped around anything, a
-- character only gets to know the outermost one of each kind that is open at its
-- position (the opening tag token, which is also the identity of the group):
-- .word
-- .chunk or (one node for all characters, never broken)
-- .block or (may be broken into lines, one node per line)
-- .font_tag the innermost (its .value is the name, its .parent the next
-- around it), see assign_fonts()
-- With auto_block, a drawn tag that needs its whole content in one node (see
-- needs_block()) counts as a : it is wrapped around the characters as usual,
-- and in addition the characters get it as .block (if no outer block is open).
-- Every character also gets .stack, the active tags (outermost first), which is what
-- the tags of a block node are written from.
-- Which drawn tags need all characters of a line of their content in ONE text node
-- to work as intended (a rigid shake, a wave or gradient across the whole text), and
-- under which conditions. `fits`: the values of the attribute fit for which the tag
-- needs it; a tag without any fit attribute counts as well. `attributes` (optional):
-- one of them must be present, they only make sense for the whole text, e.g. the
-- colours of a gradient. A tag with another fit value is never grouped, so the user
-- decides.
local AUTO_BLOCK = {
gradient = { fits = { span = true }, attributes = { "left", "tl" } },
shake = { fits = { glyph = true } },
wave = { fits = { glyph = true } },
}
-- Does this opening tag need a block of its own (see AUTO_BLOCK)? Quotes and case of
-- the attributes do not matter.
local function needs_block(name, raw)
local rule = AUTO_BLOCK[name]
if not rule then return false end
raw = lower(raw)
if find(raw, "[%s]fit%s*=") then
local fit = match(raw, "[%s]fit%s*=%s*[\"']?([^\"'%s/>]*)")
if not rule.fits[fit] then return false end
end
if rule.attributes then
for _, attribute in ipairs(rule.attributes) do
if find(raw, "[%s]" .. attribute .. "%s*=") then return true end
end
return false
end
return true
end
-- Removes the most recently opened tag with this name from the stack.
local function close_tag(stack, name)
for k = #stack, 1, -1 do
if stack[k].name == name then
table.remove(stack, k)
return
end
end
end
-- Gives a char or newline token the tags that are open at its position (see above).
-- A newline gets .open and .close, too: its size counts for the height of the line
-- it ends, which also gives empty lines their height.
local function apply_open_tags(tok, stack)
local seen, active = {}, {} -- active: newest first
local word, chunk, block, font_tag
for k = #stack, 1, -1 do -- newest first, the newest of each name wins
local tag = stack[k]
if tag.name == "word" then
word = tag -- the loop ends with the outermost one
elseif tag.name == "chunk" or tag.name == "letter" then
chunk = tag
elseif tag.name == "block" or tag.name == "sync" then
block = tag
elseif tag.name == "font" then
font_tag = font_tag or tag -- the newest one
elseif not seen[tag.name] then
seen[tag.name] = true
active[#active + 1] = tag
if tag.is_block then block = tag end -- the loop ends with the outermost
end
end
local outermost_first, open, close = {}, {}, {}
for k = #active, 1, -1 do
outermost_first[#outermost_first + 1] = active[k]
open[#open + 1] = active[k].raw
end
for _, tag in ipairs(active) do -- closed in reverse order: newest first
close[#close + 1] = "" .. tag.name .. ">"
end
tok.open, tok.close = concat(open), concat(close)
if tok.type == "char" then
tok.markup = tok.open .. tok.text .. tok.close
tok.word, tok.chunk, tok.block, tok.font_tag = word, chunk, block, font_tag
tok.stack = outermost_first
-- inside or (see underlined spaces)
tok.ul = seen.ul == true or seen.strike == true
end
end
-- The name in a tag (quotes are allowed), nil if there is none.
local function font_value(raw)
local value = match(sub(raw, 6), "^%s*=%s*[\"']?([^\"'%s/>]*)") -- behind " this one is inside of
if stack[k].name == "font" then
tok.parent = stack[k]
break
end
end
end
stack[#stack + 1] = tok
end
else -- a char or a newline
apply_open_tags(tok, stack)
end
end
end
-- The value of a tag: nil = no value (wait for continue()), a number =
-- seconds (0 or more), false = a value that is not a number (the tag is ignored).
local function wait_value(raw)
local value = match(sub(raw, 6), "^%s*=%s*[\"']?([^\"'%s/>]*)") -- behind " tags before it) and
-- .stop (a plain is in the gap in front of it). The tags themselves stay in
-- the token list, they are only looked at here. Timed waits in the same gap as a
-- plain one are not counted: the plain one decides when the text goes on.
-- .inner_stop starts as false; the groupings (chunk, block ...) set it when a plain
-- inside a node has to be held in front of the item after that node.
local function apply_waits(tokens)
local delay = 0 -- seconds of all timed waits so far
local gap_delay = 0 -- the part of it that was added in the current gap
local stop = false -- a plain wait is in the current gap
for _, tok in ipairs(tokens) do
if tok.type == "tag" then
if tok.name == "wait" and not tok.closing then
local value = wait_value(tok.raw)
if value == nil then
delay = delay - gap_delay -- the timed waits of this gap do not count
gap_delay, stop = 0, true
elseif value and not stop then
delay = delay + value
gap_delay = gap_delay + value
end
end
elseif tok.type == "char" then
tok.delay, tok.stop, tok.inner_stop = delay, stop, false
gap_delay, stop = 0, false
end
end
end
-- Reads the tag that starts at position i of the text. Returns its token and the
-- position behind it, or nil if there is no tag at i.
local function read_tag(text, i)
local _, name_end, slash, name = find(text, "^<(/?)([%a_][%w_%-%.:]*)", i)
if not name_end then return nil end
local tag_end
if slash == "/" then
local _, e = find(text, "^%s*>", name_end + 1)
tag_end = e
else
local following = sub(text, name_end + 1, name_end + 1)
if following == ">" then
tag_end = name_end + 1
elseif following == "/" and sub(text, name_end + 2, name_end + 2) == ">" then
tag_end = name_end + 2 -- ""
elseif following == "=" or find(following, "^%s") then
local _, e = find(text, "^[^<>]*>", name_end + 1)
tag_end = e
end
end
if not tag_end then return nil end
local raw = sub(text, i, tag_end)
local lname = lower(name)
local closing = slash == "/"
local self_closing = not closing and sub(raw, -2) == "/>"
if self_closing and DRAWN[lname] then
-- "": the whole tag is ONE character, drawn and measured with
-- exactly this text. It is a word of its own.
return { type = "char", text = raw, cp = 0, is_space = false, inline = true }, tag_end + 1
end
return {
type = "tag", name = lname, closing = closing, raw = raw, self_closing = self_closing,
}, tag_end + 1
end
--- Splits a string into tokens.
-- Token types:
-- { type = "char", text = "a", cp = 97, is_space = false,
-- delay = seconds of timed tags before it (added up),
-- stop = true if a plain is directly in front of it,
-- inner_stop = false (see apply_waits()),
-- markup = "a",
-- open = "", close = "",
-- word = tag token or nil, chunk = tag token or nil }
-- { type = "char", text = "", cp = 0, inline = true, ... }
-- a self-closing tag from DRAWN: ONE character whose text is
-- the tag itself (open/close/markup/word/chunk as above)
-- { type = "tag", name = "color", closing = false, raw = "",
-- self_closing = false }
-- (self-closing tags that are not drawn are tags with
-- self_closing = true, they have no effect)
-- { type = "newline", open = "", close = "" } (tags active there)
-- The open/close/markup of a char only contain the tags in DRAWN.
-- A char with .break_after = true was followed by a zero width space (U+200B): a
-- line may break behind it, as if there were a space (which is not drawn).
-- A "char" is one UTF-8 character plus the combining marks attached to it (for
-- Devanagari also the consonants joined to it by a virama).
-- auto_block (optional, default false): the characters inside tags that need a
-- block get it as .block (see apply_tags()); the option disable_auto_blocking =
-- false of the textbox.
function M.parse(text, auto_block)
text = text or ""
local tokens = {}
local i, n = 1, #text
local last_char -- the previous token, if it is a char that can still be extended
local after_joiner = false -- the previous character was a zero width joiner
local after_virama = false -- ... or a Devanagari virama
local prev_char -- the last char token of the line (tags do not count), for U+200B
while i <= n do
local b = byte(text, i)
local tag, after_tag = read_tag(text, i)
if tag then
tokens[#tokens + 1] = tag
i = after_tag
last_char, after_joiner, after_virama = nil, false, false
elseif b == 10 or b == 13 then
if b == 13 and byte(text, i + 1) == 10 then i = i + 1 end
tokens[#tokens + 1] = { type = "newline" }
i = i + 1
last_char, after_joiner, after_virama, prev_char = nil, false, false, nil
else
local len = utf8_length(text, i)
local ch = sub(text, i, i + len - 1)
local cp = codepoint(ch)
if cp == 0x200B then
-- zero width space: no node, no width, only a place where the line
-- may break (behind the character before it)
if prev_char then prev_char.break_after = true end
last_char, after_joiner, after_virama = nil, false, false
else
if last_char and (after_joiner or is_combining(cp)
or (after_virama and is_devanagari_consonant(cp))) then
last_char.text = last_char.text .. ch
else
last_char = { type = "char", text = ch, cp = cp, is_space = SPACES[cp] == true }
tokens[#tokens + 1] = last_char
end
prev_char = last_char
after_joiner = cp == 0x200D
after_virama = cp == 0x094D
end
i = i + len
end
end
apply_tags(tokens, auto_block)
apply_waits(tokens)
return tokens
end
--------------------------------------------------------------------------------
-- Chunks and words
--------------------------------------------------------------------------------
-- Joins the char tokens of one node (an Arabic word or a chunk) into its text.
-- Returns the markup (the characters with the tags around them, tags that stay
-- the same from one character to the next are only written once, which keeps the
-- letters joined) and the largest size factor of its characters.
local function join_chars(chars)
local parts = {}
local size = 0
local prev
for _, c in ipairs(chars) do
size = math.max(size, c.size)
if not prev then
parts[#parts + 1] = c.open .. c.text
elseif c.open == prev.open then -- same tags: no tag text, keeps joining
parts[#parts + 1] = c.text
else
parts[#parts + 1] = prev.close .. c.open .. c.text
end
prev = c
end
parts[#parts + 1] = prev.close
return concat(parts), size
end
-- Is a plain in front of any character but the first? Such a wait cannot
-- interrupt the node the characters are drawn in, it is held behind the node.
local function has_inner_stop(chars)
for k = 2, #chars do
if chars[k].stop then return true end
end
return false
end
-- Are a and b (items next to each other) inside the same ? A chunk item
-- counts with its last character at its end and with its first at its start.
local function same_word(a, b)
local w = (a.tail or a).word
return w ~= nil and w == (b.head or b).word
end
-- The item for the characters of one or , see build_chunks().
local function make_chunk(chars)
local markup, size = join_chars(chars)
local width = 0
for _, c in ipairs(chars) do width = width + c.adv end
local first, last = chars[1], chars[#chars]
return {
type = "chunk", markup = markup, adv = width, size = size,
index = first.index, is_space = false, cp = 0,
head = first, tail = last, tail_prev = chars[#chars - 1], word = first.word,
block = first.block, font = first.font, chunk = first.chunk,
-- a wait inside the chunk cannot interrupt it: a plain one is held in front
-- of the next item, a timed one is part of its .delay already
delay = first.delay, stop = first.stop, inner_stop = has_inner_stop(chars),
}
end
-- Left-to-right counterpart of build_words(): turns the tokens into the items
-- that layout() works on. Tags have no item.
-- char: the char token itself, with .index = number of characters before it
-- (this is what the speed counts), .adv = its advance and .size = its
-- size factor
-- chunk: one item for all characters of a or :
-- { type = "chunk", markup = text with tags,
-- adv = width, size = largest size factor of its characters,
-- index = like a char, is_space = false, cp = 0,
-- head / tail = first / last char token (they decide about the
-- line breaks before / after it), tail_prev = the char before
-- tail inside the chunk, word = the it starts in,
-- block = the / it starts in }
-- newline: the newline token itself
-- A chunk of one character stays a plain char. The characters of a are
-- counted like any others, so what follows it starts after the time they need.
-- A is the same node, but all its characters count as ONE character, so
-- what follows it starts after the time of a single character.
-- A node has one font, so a chunk is cut where the font changes. The pieces are
-- items of their own, with the same .chunk, and share_times() lets them appear
-- together (a piece of one character is a plain char, as above).
local function build_chunks(tokens)
local items = {}
local count = 0
local group -- chars of the chunk that is open
local chunk_tag, chunk_index -- the / the previous character was in, and its index
local function close_chunk()
if not group then return end
local chars = group
group = nil
items[#items + 1] = (#chars == 1) and chars[1] or make_chunk(chars)
end
for _, tok in ipairs(tokens) do
if tok.type == "char" then
-- a node has one font: a change of font ends the chunk, too
if group and (group[1].chunk ~= tok.chunk or group[1].font ~= tok.font) then close_chunk() end
if tok.chunk and tok.chunk == chunk_tag and tok.chunk.name == "letter" then
tok.index = chunk_index -- the rest of a takes no time
else
tok.index = count
count = count + 1
end
chunk_tag, chunk_index = tok.chunk, tok.index
tok.adv = tok.font.advance(tok.text, tok.open, tok.close)
tok.size = tok.font.vertical.size_factor(tok.open, tok.close)
if tok.chunk then
group = group or {}
group[#group + 1] = tok
else
items[#items + 1] = tok
end
elseif tok.type == "newline" then
close_chunk() -- a line break ends the chunk
chunk_tag = nil
items[#items + 1] = tok
end
end
close_chunk()
return items
end
--------------------------------------------------------------------------------
-- Layout
--------------------------------------------------------------------------------
local advance_caches = {}
-- Returns a function giving the horizontal advance of a character in the font,
-- including tracking. Measured as width(ch.."X") - width("X"): the engine adds the
-- tracking only between two characters, so a lone character would come without it.
-- The function takes the tags around the character (open = the opening tags,
-- close = the matching closing tags) and measures with them, so that e.g. a size
-- tag makes the advance bigger. Without tags it is the plain advance.
-- scale_x is the horizontal scale of the nodes. The engine does not know about it,
-- so the measured advance is multiplied with it. The cache holds the plain
-- measurements, so it can be shared between different scales.
local function get_advance_function(font, tracking, scale_x)
local key = tostring(font) .. "|" .. tostring(tracking)
local cache = advance_caches[key]
if not cache then
cache = { opts = { tracking = tracking }, tags = {} }
advance_caches[key] = cache
end
return function(ch, open, close)
open, close = open or "", close or ""
local t = cache.tags[open] -- one table per combination of tags
if not t then
local ref = resource.get_text_metrics(font, open .. "X" .. close, cache.opts).width
t = { ref = ref, glyphs = {} }
cache.tags[open] = t
end
local advance = t.glyphs[ch]
if not advance then
advance = resource.get_text_metrics(font, open .. ch .. "X" .. close, cache.opts).width - t.ref
t.glyphs[ch] = advance
end
return advance * scale_x
end
end
local vertical_caches = {}
-- Returns what the vertical layout needs to know about a font:
-- ascent, descent distance of the baseline from the top / bottom of a line of
-- normal size (size 1)
-- factor the leading: how much it stretches a line. The engine's line
-- pitch is leading * (ascent + descent), so for two lines of
-- normal size the baselines are exactly one pitch apart.
-- size_factor(open, close) size of text inside these tags (1 = normal), times
-- scale_y, the vertical scale of the nodes. That is the factor
-- the nodes are really drawn with, and everything that uses it
-- (baselines, line heights, the item sizes of chunks and words)
-- therefore treats the scale like a size tag.
-- resource.get_text_metrics() scales width and height with a size tag, but not
-- max_ascent and max_descent, so the size is taken from the height.
-- ascent, descent and factor are those of the font, they are multiplied with the
-- size by the layout.
local function get_vertical_function(font, tracking, leading, scale_y)
local key = tostring(font) .. "|" .. tostring(tracking)
local cached = vertical_caches[key]
if not cached then
local opts = { tracking = tracking }
local plain = resource.get_text_metrics(font, "X", opts)
local tag_sizes = {}
cached = {
ascent = plain.max_ascent,
descent = plain.max_descent,
tag_size = function(open, close)
if not open or open == "" then return 1 end
local size = tag_sizes[open]
if not size then
local height = resource.get_text_metrics(font, open .. "X" .. close, opts).height
size = (plain.height > 0 and height > 0) and height / plain.height or 1
tag_sizes[open] = size
end
return size
end,
}
vertical_caches[key] = cached
end
return {
ascent = cached.ascent,
descent = cached.descent,
factor = leading,
size_factor = function(open, close) return cached.tag_size(open, close) * scale_y end,
}
end
-- Vertical layout. Every line gets .size (the largest size of its items, newline
-- included) and .baseline (distance of its baseline below the top of the text
-- area), and every item of it gets .y, the distance of its own top below the top of
-- the text area. An item (character or word) is a text node whose baseline lies
-- size * ascent below its top (the ascent is the one of ITS font: fonts differ), so
-- putting its top at baseline - size * ascent makes all items of the line sit on the
-- same baseline, whatever their size and font.
-- The next line is placed below the largest descent of the previous one and above
-- the largest ascent of its own, stretched by the leading factor. For lines of one
-- font and normal size this is the line pitch.
-- The sizes of the items include the vertical scale of the nodes, see
-- get_vertical_function().
local function place_lines(lines, fontset)
local factor = fontset.leading
local baseline, prev_descent
for _, line in ipairs(lines) do
local size, ascent, descent = 0, 0, 0
for _, item in ipairs(line.items) do
local vertical = item.font.vertical
size = math.max(size, item.size)
ascent = math.max(ascent, item.size * vertical.ascent)
descent = math.max(descent, item.size * vertical.descent)
end
local newline = line.newline
if newline then -- counts for the line it ends, which gives empty lines their height
local vertical = (newline.font or fontset.default).vertical
local newline_size = vertical.size_factor(newline.open, newline.close)
size = math.max(size, newline_size)
ascent = math.max(ascent, newline_size * vertical.ascent)
descent = math.max(descent, newline_size * vertical.descent)
end
if size == 0 then -- an empty last line has the normal size
local vertical = fontset.default.vertical
size = vertical.size_factor()
ascent, descent = size * vertical.ascent, size * vertical.descent
end
if baseline then
baseline = baseline + factor * (prev_descent + ascent)
else
baseline = ascent
end
line.size, line.baseline = size, baseline
for _, item in ipairs(line.items) do
item.y = baseline - item.size * item.font.vertical.ascent
end
prev_descent = descent
end
end
-- May a line break be placed between item a and item b? (prev is the item before a)
-- Items are chars or chunks. Never inside a .
local function can_break(prev, a, b)
if same_word(a, b) then return false end
if prev and prev.tail then prev = prev.tail end
if a.tail then prev, a = a.tail_prev or prev, a.tail end -- a chunk: its last char counts
if b.head then b = b.head end -- a chunk: its first char counts
if a.is_space then return not b.is_space end -- after a run of spaces
if b.is_space then return false end -- spaces hang at the end of the word
if a.break_after then return true end -- a zero width space
if a.inline or b.inline then -- a self-closing tag is a word of its own
return not NO_START[b.cp] and not NO_END[a.cp]
end
local cp_a, cp_b = a.cp, b.cp
if cp_a == 0x2D then -- hyphen inside a word: "well-known"
return prev ~= nil and is_alnum(prev.cp) and is_alnum(cp_b)
end
if cp_a == 0x2014 or cp_a == 0x2013 then -- em/en dash
return not NO_START[cp_b]
end
if is_cjk(cp_a) or is_cjk(cp_b) then
return not NO_START[cp_b] and not NO_END[cp_a]
end
if is_thai(cp_a) and is_thai(cp_b) then
-- Thai has no spaces between words and this module has no dictionary, so
-- lines may break between syllables, but not inside one: not behind a
-- leading vowel and not in front of a following vowel.
return not (cp_a >= 0x0E40 and cp_a <= 0x0E44) and not THAI_NO_START[cp_b]
end
return false
end
-- Fills lines with units: runs of items that are not split by a line break (a word
-- with the spaces behind it). Used by layout() and layout_rtl().
-- Every line is { items = { ... }, hard = true if the line ends with a manual line
-- break or is the last line, newline = the newline token that ends the line (nil if
-- there is none) }.
local Flow = {}
Flow.__index = Flow
function Flow.new(max_width)
local flow = setmetatable({ max_width = max_width, lines = {}, x = 0 }, Flow)
flow:start_line()
return flow
end
function Flow:start_line()
self.lines[#self.lines + 1] = { items = {}, hard = false }
self.x = 0
end
-- Adds a unit to the current line, or to a new one if it does not fit. Gives its
-- items .x (offset from the left edge) and .line (number of the line, from 1).
function Flow:add_unit(unit)
local width, glyph_width = 0, 0
for _, item in ipairs(unit) do
width = width + item.adv
if not item.is_space then glyph_width = width end -- trailing spaces may hang over
end
if self.x > 0 and self.x + glyph_width > self.max_width then
self:start_line()
end
local line = self.lines[#self.lines]
for _, item in ipairs(unit) do
item.x, item.line = self.x, #self.lines
self.x = self.x + item.adv
line.items[#line.items + 1] = item
end
end
-- Ends the current line with a manual line break.
function Flow:end_line(newline)
local line = self.lines[#self.lines]
line.hard, line.newline = true, newline
self:start_line()
end
-- Returns the lines. The last one always counts as ended by a line break.
function Flow:finish()
self.lines[#self.lines].hard = true
return self.lines
end
-- Assigns .x, .line and .unit to every char token and chunk item (see
-- build_chunks()). .unit numbers the "words" of the text (the items between two
-- break opportunities, trailing spaces included) from 1 in reading order. Where the
-- breaks are possible does not depend on the width, the scale or the alignment, so
-- the same text always gets the same units.
-- Returns the lines, see Flow.
local function layout(items, max_width)
local flow = Flow.new(max_width)
local unit = {} -- items between two break opportunities
local unit_count = 0
local function flush_unit()
if #unit == 0 then return end
unit_count = unit_count + 1
flow:add_unit(unit)
for _, item in ipairs(unit) do item.unit = unit_count end
unit[#unit].brk = true -- a line may break after this item
unit = {}
end
for index, item in ipairs(items) do
if item.type == "newline" then
flush_unit()
flow:end_line(item)
else
unit[#unit + 1] = item
local following = items[index + 1]
if not following or following.type == "newline" or can_break(items[index - 1], item, following) then
flush_unit()
end
end
end
flush_unit()
return flow:finish()
end
-- Like layout(), but the line breaks are chosen so that the longest line is as
-- short as possible without increasing the number of lines.
-- The greedy layout never gets fewer lines when the width shrinks, so the
-- smallest width that still gives the original line count is found by binary
-- search over whole pixels. The final layout call leaves the positions of the
-- chosen width in the items.
-- `layout_fn` is the layout function to use (layout, or layout_rtl for words).
local function layout_balanced(items, max_width, layout_fn)
local lines = layout_fn(items, max_width)
local target = #lines
if target <= 1 or max_width == math.huge then return lines end
local low, high, best = 0, math.floor(max_width), max_width
while low <= high do
local middle = math.floor((low + high) / 2)
if #layout_fn(items, middle) <= target then
best, high = middle, middle - 1
else
low = middle + 1
end
end
return layout_fn(items, best)
end
-- Writes the characters of one block fragment as ONE text. Unlike join_chars() the
-- tags that two neighbours have in common stay open, so an effect of the block
-- (and anything around it) is written once around the whole fragment, even when
-- other tags start and end inside it. Returns the markup.
local function join_nested(chars)
local parts, open = {}, {}
for _, c in ipairs(chars) do
local wanted = c.stack
local keep = 0
while keep < #open and keep < #wanted and open[keep + 1] == wanted[keep + 1] do
keep = keep + 1
end
for k = #open, keep + 1, -1 do
parts[#parts + 1] = "" .. open[k].name .. ">"
open[k] = nil
end
for k = keep + 1, #wanted do
parts[#parts + 1] = wanted[k].raw
open[k] = wanted[k]
end
parts[#parts + 1] = c.text
end
for k = #open, 1, -1 do parts[#parts + 1] = "" .. open[k].name .. ">" end
return concat(parts)
end
-- Puts the items first..last of a line (all in one block, next to each other) into
-- one fragment: the first non-space item becomes the node of the whole fragment
-- (.frag_markup, and .size = the largest size inside it, which its baseline needs),
-- the others become empty nodes (.member). Spaces at both ends stay separate nodes.
local function make_fragment(items, first, last)
while first <= last and items[first].is_space do first = first + 1 end
while last > first and items[last].is_space do last = last - 1 end
if first > last then return end -- only spaces
if last == first then return first, last end -- one character is just that character
local fragment, size = {}, 0
for k = first, last do
local c = items[k]
fragment[#fragment + 1] = c
size = math.max(size, c.size)
if k > first then
c.member = true
if c.stop then
items[last].inner_stop = true -- a wait cannot interrupt the fragment, it is held behind it
end
end
if k < last then c.in_frag = true end
end
local head = items[first]
head.frag_markup = join_nested(fragment)
head.size = size
return first, last
end
-- Block grouping, left-to-right mode. (A fragment has one font: it ends where the
-- font changes, and a or is never part of one.)
-- The text has been laid out character by
-- character, so lines break as usual. Now the characters of one block (,
-- , or a tag that needs one, see AUTO_BLOCK) that are next to each other in one
-- line are put together, see make_fragment(). A block that continues in the next
-- line has a fragment there, too. The node count stays the same, which
-- Textbox:animate() relies on.
-- * Every character of a fragment but the last gets .in_frag: gaps inside a
-- fragment are not stretched by align_lines(), the node cannot be stretched.
-- * A item inside a block is a node of its own and ends the fragment.
-- * A plain inside a fragment cannot interrupt it, like in a , so it
-- is held in front of the item that follows the fragment (.inner_stop of its
-- last character).
local function group_blocks(lines)
for _, line in ipairs(lines) do
local items = line.items
local pieces = {} -- the fragments (or single characters) of the blocks in this line
local i = 1
while i <= #items do
-- (a or is a node of its own, and so is a change of font)
local item = items[i]
local block = item.type == "char" and not item.chunk and item.block
if block then
local last = i
while last < #items do
local following = items[last + 1]
if following.type ~= "char" or following.block ~= block
or following.chunk or following.font ~= item.font then
break
end
last = last + 1
end
local first_node, last_node = make_fragment(items, i, last)
if first_node then
pieces[#pieces + 1] = {
block = block, first = first_node, last = last_node,
range_first = i, range_last = last,
}
end
i = last + 1
else
i = i + 1
end
end
-- A line of a block that a change of font cut into several nodes (they follow
-- each other directly) still appears at once: the nodes from the first to the
-- last one start together, see share_times().
local k = 1
while k <= #pieces do
local stop = k
while stop < #pieces and pieces[stop + 1].block == pieces[k].block
and pieces[stop + 1].range_first == pieces[stop].range_last + 1 do
stop = stop + 1
end
if stop > k then
local group = {}
for n = pieces[k].first, pieces[stop].last do items[n].cut_group = group end
end
k = stop + 1
end
end
end
-- Collects the items that appear together into groups. key_of(item) returns the
-- group of an item (nil: none) and optionally a second key that splits the group
-- further (right-to-left mode: the line).
local function collect_groups(items, key_of)
local buckets, groups = {}, {}
for _, item in ipairs(items) do
local key, sub_key = key_of(item)
if key then
sub_key = sub_key or 0
local bucket = buckets[key]
if not bucket then
bucket = {}
buckets[key] = bucket
end
local group = bucket[sub_key]
if not group then
group = {}
bucket[sub_key] = group
groups[#groups + 1] = group
end
group[#group + 1] = item
end
end
return groups
end
-- Every node of a group takes the index and the delay of the first node of it, so it
-- starts at the same moment, while what follows the group keeps its own index, which
-- counts all characters of the group. A plain inside the group cannot
-- interrupt it: it is held in front of the item after the group (.inner_stop of the
-- last item).
-- (Items without .index have no node, e.g. the spaces in right-to-left mode.)
local function start_together(group)
local first -- the first item that has a node
local held = false -- a plain inside the group
for _, item in ipairs(group) do
if item.index == nil then
-- no node: a wait on it would go to the next node (before the first
-- node that is where the group starts, so it stays)
if first then
held = held or item.stop
item.stop = false
end
elseif not first then
first = item
held = item.inner_stop
item.inner_stop = false
else
item.index, item.delay = first.index, first.delay
held = held or item.stop or item.inner_stop
item.stop, item.inner_stop = false, false
end
end
if held then group[#group].inner_stop = true end
end
-- Makes the nodes of a group appear at the same time. `items` is the list the layout
-- worked on, key_of(item) returns the group an item belongs to, see collect_groups().
local function share_times(items, key_of)
for _, group in ipairs(collect_groups(items, key_of)) do
start_together(group)
end
end
-- The first and the last item of a line that are not a space (nil if there is none).
local function get_visible_range(items)
local first, last
for i, item in ipairs(items) do
if not item.is_space then
first = first or i
last = i
end
end
return first, last
end
-- Can the gap behind an item be stretched by "justified"? That is a space, or a
-- break opportunity next to a CJK character. Never inside a or inside the
-- node of a block fragment.
local function is_stretchable_gap(item, following)
if item.in_frag then return false end
local space_gap = item.is_space and not same_word(item, following)
local cjk_gap = item.brk and not following.is_space
and (is_cjk((item.tail or item).cp) or is_cjk((following.head or following).cp))
return space_gap or cjk_gap
end
-- Spreads `extra` pixels over the gaps of a line, so it touches both sides. The
-- width added behind an item is kept in item.stretch.
local function justify_line(items, first, last, extra)
local gaps, count = {}, 0
for i = first, last - 1 do
if is_stretchable_gap(items[i], items[i + 1]) then
gaps[i] = true
count = count + 1
end
end
if count == 0 then return end
local per_gap, shift = extra / count, 0
for i, item in ipairs(items) do
item.x = item.x + shift
if gaps[i] then
item.stretch = per_gap -- extra width behind this space
shift = shift + per_gap
end
end
end
-- Moves the items of every line according to the alignment.
local function align_lines(lines, max_width, alignment)
if alignment ~= "right" and alignment ~= "center" and alignment ~= "justified" then
return
end
if max_width == math.huge then return end
for _, line in ipairs(lines) do
local items = line.items
local first, last = get_visible_range(items)
if first then
-- visible width: trailing spaces hang over the edge and do not count
local extra = max_width - (items[last].x + items[last].adv)
if extra > 0 then
if alignment == "justified" then
if not line.hard then -- automatic line breaks only
justify_line(items, first, last, extra)
end
else
local offset = (alignment == "right") and extra or extra / 2
for _, item in ipairs(items) do item.x = item.x + offset end
end
end
end
end
end
-- Marks the spaces at the end of every line (item.trailing = true): the spaces that
-- are followed by no other character in the same line, i.e. the ones that hang at
-- an automatic line break, in front of a manual one, or at the end of the text.
-- Every character gets the flag (true or false), so older values never stay.
local function mark_line_ends(lines)
for _, line in ipairs(lines) do
local trailing = true
for k = #line.items, 1, -1 do
local item = line.items[k]
if not item.is_space then trailing = false end
item.trailing = trailing
end
end
end
-- Is this a space inside or that is not at the end of its line?
-- Such a space gets a zero width space behind it, because the text renderer only
-- draws the line for a space when there is a letter inside the tag. Spaces at the
-- end of a line hang over the edge and get no line.
local function is_underlined_space(item)
return item.is_space and item.ul and not item.trailing
end
--------------------------------------------------------------------------------
-- Right-to-left mode: words instead of characters
--------------------------------------------------------------------------------
local function contains_arabic(tokens)
for _, t in ipairs(tokens) do
if t.type == "char" and is_arabic_letter(t.cp) then return true end
end
return false
end
-- Returns a function giving the width of a whole word. A word is measured as one
-- piece of text, because its letters change their shapes (and widths) depending
-- on their neighbours. It is measured with its tags (the markup), so that size
-- tags count. The cache only lives as long as the function.
-- scale_x is the horizontal scale of the nodes, which the measurement does not know
-- about: the measured width is multiplied with it.
local function get_word_width_function(font, tracking, scale_x)
local opts = { tracking = tracking }
local cache = {}
return function(word)
local width = cache[word]
if not width then
width = resource.get_text_metrics(font, word, opts).width
cache[word] = width
end
return width * scale_x
end
end
-- Resolves the embedding level (1 = right to left, 2 = left to right inside it)
-- of every item of one paragraph (items a..b), following the rules of the Unicode
-- bidirectional algorithm on word level:
-- W7 a number after a strong left-to-right word is left-to-right
-- N1 neutrals between two items of the same direction take that direction
-- (numbers count as right to left here)
-- N2 all other neutrals take the paragraph direction (right to left)
local function resolve_paragraph(items, a, b)
local types = {}
local last_strong = "R" -- start of the paragraph
for i = a, b do
local t = items[i].cls
if t == "R" or t == "L" then
last_strong = t
elseif t == "EN" and last_strong == "L" then
t = "L"
end
types[i] = t
end
local i = a
while i <= b do
if types[i] == "N" then
local j = i
while j < b and types[j + 1] == "N" do j = j + 1 end
local before = (i > a and types[i - 1] == "L") and "L" or "R"
local after = (j < b and types[j + 1] == "L") and "L" or "R"
local direction = (before == after) and before or "R"
for k = i, j do types[k] = direction end
i = j + 1
else
i = i + 1
end
end
for k = a, b do
items[k].base_level = (types[k] == "R") and 1 or 2
end
end
-- Every line break (newline) starts a new paragraph.
local function resolve_levels(items)
local start, n = 1, #items
for i = 1, n + 1 do
if i > n or items[i].type == "newline" then
if i - 1 >= start then resolve_paragraph(items, start, i - 1) end
start = i + 1
end
end
end
-- The direction class of a word: its first strong character decides, a word
-- without one gets the class of its first digit, otherwise it is neutral.
local function get_word_class(chars)
local digit_class
for _, c in ipairs(chars) do
local class = char_class(c.cp)
if class == "R" or class == "L" then return class end
if not digit_class and (class == "EN" or class == "AN") then digit_class = class end
end
return digit_class or "N"
end
-- The item for the characters of one Arabic word (or chunk), see build_words().
local function make_word(chars, index, chunk)
local markup, size = join_chars(chars)
local first = chars[1]
return {
type = "word", is_space = false, cp = 0,
markup = markup, adv = first.font.measure(markup), size = size, cls = get_word_class(chars),
break_after = chars[#chars].break_after,
index = index, chunk = chunk,
word = first.word, block = first.block, font = first.font,
-- plain waits inside the word are held after it
delay = first.delay, stop = first.stop, inner_stop = has_inner_stop(chars),
}
end
-- Turns the tokens into the items the right-to-left layout works with:
-- word: { type = "word", is_space = false, cp = 0, adv = width,
-- markup = text with tags, index = number of characters before the
-- word (this is what the speed counts),
-- size = largest size factor of its characters,
-- word = the its first character is in,
-- block = the / its first character is in }
-- space: the char token itself, with .adv and .size
-- newline: the newline token itself
-- Tags have no item, they are part of the markup of the words.
-- All characters of a or (spaces included) make ONE word item, and
-- a chunk always starts a new item and ends the current one. Together with
-- build_chunks() this is what makes a chunk appear like a single character (and the
-- characters of a count as one).
-- A node has one font, so a word (or chunk) is cut where the font changes. The
-- pieces are items of their own: after each other, or, for a chunk or letter,
-- together (see share_times()).
local function build_words(tokens)
local items = {}
local count = 0
local chars, word_index, word_chunk -- the word that is open
local letter, letter_index -- the the previous character was in, and its index
local function close_word()
if not chars then return end
items[#items + 1] = make_word(chars, word_index, word_chunk)
chars = nil
end
for _, tok in ipairs(tokens) do
if tok.type == "char" then
tok.size = tok.font.vertical.size_factor(tok.open, tok.close)
if chars and (word_chunk ~= tok.chunk or chars[1].font ~= tok.font) then close_word() end
-- the rest of a takes no time of its own
local rest_of_letter = tok.chunk ~= nil and tok.chunk == letter and tok.chunk.name == "letter"
local index = rest_of_letter and letter_index or count
if not rest_of_letter then count = count + 1 end
letter, letter_index = tok.chunk, index
if tok.inline and not tok.chunk then
-- a self-closing tag is a word of its own, whatever is next to it
close_word()
local word = make_word({ tok }, index, nil)
word.inline = true
items[#items + 1] = word
elseif tok.is_space and not tok.chunk then
close_word()
tok.adv = tok.font.advance(tok.text, tok.open, tok.close)
tok.cls = "N"
items[#items + 1] = tok
else
if not chars then
chars, word_index, word_chunk = {}, index, tok.chunk
end
chars[#chars + 1] = tok
if tok.break_after and not tok.chunk then close_word() end -- (U+200B)
end
elseif tok.type == "newline" then
close_word()
letter = nil
items[#items + 1] = tok
end
end
close_word()
resolve_levels(items)
return items
end
-- Reverses the items first..last of the list.
local function reverse_range(list, first, last)
while first < last do
list[first], list[last] = list[last], list[first]
first, last = first + 1, last - 1
end
end
-- Puts the items of one line (still in reading order) into their visual order,
-- following rule L2 of the Unicode bidirectional algorithm, and sets .x for every
-- item. The line is then listed from the RIGHT edge to the left, and .x is the
-- distance of the item's right edge from the right edge of the line start.
local function arrange_line(line)
local items = line.items
local count = #items
for _, item in ipairs(items) do item.level = item.base_level end
for k = count, 1, -1 do -- trailing spaces get the paragraph direction
if not items[k].is_space then break end
items[k].level = 1
end
local highest = 1
for _, item in ipairs(items) do
highest = math.max(highest, item.level)
end
for level = highest, 1, -1 do -- reverse every run at this level or higher
local i = 1
while i <= count do
if items[i].level >= level then
local j = i
while j < count and items[j + 1].level >= level do j = j + 1 end
reverse_range(items, i, j)
i = j + 1
else
i = i + 1
end
end
end
-- items is now the visual order from left to right; list it from the right
local ordered, x = {}, 0
for i = count, 1, -1 do
local item = items[i]
item.x = x
x = x + item.adv
ordered[#ordered + 1] = item
end
line.items = ordered
end
-- Right-to-left counterpart of layout(): same result structure, but it works on
-- the items made by build_words() and breaks lines only before words that follow
-- a space (and are not in the same as that space). The items are not
-- changed, apart from their positions.
-- Every item gets .line and .x, where .x is measured from the RIGHT edge of the
-- text area (the start edge of the line). Alignment is done by align_lines() with
-- mirrored names, see MIRRORED_ALIGNMENT.
local function layout_rtl(items, max_width)
local flow = Flow.new(max_width)
local unit = {} -- a word with the spaces behind it
local function flush_unit()
if #unit == 0 then return end
flow:add_unit(unit)
unit = {}
end
for index, item in ipairs(items) do
if item.type == "newline" then
flush_unit()
flow:end_line(item)
else
local prev = items[index - 1]
-- a line may break before a word that follows a space, and around a
-- self-closing tag (a word of its own) even without a space
if not item.is_space and prev and not same_word(prev, item)
and (prev.is_space or prev.inline or item.inline or prev.break_after) then
flush_unit()
end
unit[#unit + 1] = item
end
end
flush_unit()
local lines = flow:finish()
for _, line in ipairs(lines) do arrange_line(line) end
return lines
end
-- Positions in right-to-left mode are measured from the right edge, so the
-- alignment names are swapped for align_lines(): the line start (right edge) is
-- "left" there. Unknown names mean "left" (as in left-to-right mode).
local MIRRORED_ALIGNMENT = {
left = "right", right = "left", center = "center", justified = "justified",
}
--------------------------------------------------------------------------------
-- Fonts
--------------------------------------------------------------------------------
-- The languages that fonts can be given for (the keys of the option "fonts"), in the
-- order that decides a tie when the language of a line is chosen.
local GROUPS = {
"latin", "arabic", "japanese", "chinese", "chinese_traditional", "korean", "thai", "devanagari",
}
-- Which fonts stand in for a language that has none of its own (after that: latin).
-- Only for the CJK languages: a CJK font is the only one with their glyphs.
local FALLBACKS = {
chinese = { "chinese_traditional", "japanese", "korean" },
chinese_traditional = { "chinese", "japanese", "korean" },
japanese = { "chinese", "chinese_traditional", "korean" },
korean = { "japanese", "chinese", "chinese_traditional" },
}
local CJK_GROUPS = make_set({ "japanese", "chinese", "chinese_traditional", "korean" })
-- Scripts that belong to exactly one language.
local STRONG_GROUPS = make_set({ "arabic", "japanese", "korean", "thai", "devanagari" })
-- The Han characters that tell simplified and traditional Chinese apart: the common
-- ones that exist in only one of the two. Han characters that are in neither list
-- (most of them) are the same in both. Add more here if you need them.
local SIMPLIFIED_ONLY = table.concat({
"国学这们说时来会个对见开门问间马鸟鱼车书长东难电买卖读写红绿听现实气爱儿韩过还进",
"达边远连运动办务业产经济体点应当无与专义乐习乡亲仅从优伤价传倾关兴养决况净减凤击",
"创别刘则剧劳势勋区医华协单卫厅厂历县参双变号吗员响园围图圆场块声处头夹奋妇孙宝审",
"导将尔尘层属岁岛师帅带帮广庆库张归录彻忆总恶惊战报择摄据数断旧显暂机杀杂权条极样",
"标梦检楼欢毕汉汤沟没灯灭热爷犹狮猫独环画疗盖监码离种积称竞笔简类粮纪约级纸线细织",
"终组结给络统继续维罗联职肤胜脑脸节艺苏药获虑虽补装观规视觉计订认讨让训议讯记讲许",
"论设访证评识诉词译试诗话该详语误请诸课谁调谈谢谱贝负财责贤败货质购贯贵费资赏赛赢",
"赵趋跃转轮软轻载较辈辉输辑辞迟适选递逻遗邮郑酱释针钱铁银铜链销锁错键镇镜阅队阳阴",
"阵阶际陆陈险随隐雾静页顶项须顾顿预领频题颜额风飞饭饮饱馆驱驶验骑鲜鸡麦黄齐齿龙龟",
"剑剂劝却厉压厌吓吨咏圣坚垒够夺奖妈娱宪宽寻尽币弃弹强径恋恼悬惯愿懒扩扫执扬护拟拥",
"拨挤损换捡掷摆敌敛旷晓术构柜栏桥档橱欧残毁洁浅渐滚满滩灾炉烦烧牵状猎玛琐痒盘矿础",
"礼祸稳穷窃竖篮粤紧纯纵练绍绑绳缘罚网羡翘耻聪肠肿胀舰艰芦苹茧荣莲营萝蓝蜡衬袜讶谊",
"贪贫贴贷贸赔赚踪躯轨辆辩迁迈违钓钢钥钦铃铺锅锋锐镰闪闭闯闲闷闹闻阔陕雳韦顺顽颁颗",
"颠飘饰饼骂骄骗鸭鸣鹅鹰为众钟湿签",
})
local TRADITIONAL_ONLY = table.concat({
"國學這們說時來會個對見開門問間馬鳥魚車書長東難電買賣讀寫紅綠聽現實氣愛兒韓過還進",
"達邊遠連運動辦務業產經濟體點應當無與專義樂習鄉親僅從優傷價傳傾關興養決況淨減鳳擊",
"創別劉則劇勞勢勳區醫華協單衛廳廠歷縣參雙變號嗎員響園圍圖圓場塊聲處頭夾奮婦孫寶審",
"導將爾塵層屬歲島師帥帶幫廣慶庫張歸錄徹憶總惡驚戰報擇攝據數斷舊顯暫機殺雜權條極樣",
"標夢檢樓歡畢漢湯溝沒燈滅熱爺猶獅貓獨環畫療蓋監碼離種積稱競筆簡類糧紀約級紙線細織",
"終組結給絡統繼續維羅聯職膚勝腦臉節藝蘇藥獲慮雖補裝觀規視覺計訂認討讓訓議訊記講許",
"論設訪證評識訴詞譯試詩話該詳語誤請諸課誰調談謝譜貝負財責賢敗貨質購貫貴費資賞賽贏",
"趙趨躍轉輪軟輕載較輩輝輸輯辭遲適選遞邏遺郵鄭醬釋針錢鐵銀銅鏈銷鎖錯鍵鎮鏡閱隊陽陰",
"陣階際陸陳險隨隱霧靜頁頂項須顧頓預領頻題顏額風飛飯飲飽館驅駛驗騎鮮雞麥黃齊齒龍龜",
"劍劑勸卻厲壓厭嚇噸詠聖堅壘夠奪獎媽娛憲寬尋盡幣棄彈強徑戀惱懸慣願懶擴掃執揚護擬擁",
"撥擠損換撿擲擺敵斂曠曉術構櫃欄橋檔櫥歐殘毀潔淺漸滾滿灘災爐煩燒牽狀獵瑪瑣癢盤礦礎",
"禮禍穩窮竊豎籃粵緊純縱練紹綁繩緣罰網羨翹恥聰腸腫脹艦艱蘆蘋繭榮蓮營蘿藍蠟襯襪訝誼",
"貪貧貼貸貿賠賺蹤軀軌輛辯遷邁違釣鋼鑰欽鈴鋪鍋鋒銳鐮閃閉闖閒悶鬧聞闊陝靂韋順頑頒顆",
"顛飄飾餅罵驕騙鴨鳴鵝鷹為爲眾衆鐘鍾濕溼簽籤",
})
local function make_codepoint_set(chars)
local set = {}
local i = 1
while i <= #chars do
local len = utf8_length(chars, i)
set[codepoint(sub(chars, i, i + len - 1))] = true
i = i + len
end
return set
end
local SIMPLIFIED = make_codepoint_set(SIMPLIFIED_ONLY)
local TRADITIONAL = make_codepoint_set(TRADITIONAL_ONLY)
-- "s" for a character that only simplified Chinese uses, "t" for one that only
-- traditional Chinese uses (Bopomofo, the phonetic script of Taiwan, counts), nil
-- for a Han character that is the same in both.
local function han_form(cp)
if SIMPLIFIED[cp] then return "s" end
if TRADITIONAL[cp] or (cp >= 0x3100 and cp <= 0x312F) or (cp >= 0x31A0 and cp <= 0x31BF) then
return "t"
end
end
-- What a character is, as far as the choice of a font goes:
-- "flex" a Latin letter of Basic Latin, Latin-1 or Latin Extended-A, and
-- "digit" a digit 0-9: they have no language of their own, see
-- resolve_line_fonts()
-- "latin" any other letter of the latin group (Cyrillic, Greek, Vietnamese, ...)
-- and of the scripts that have no group of their own
-- "arabic", "japanese" (kana), "korean" (Hangul), "thai", "devanagari"
-- "han" a Han character (or Bopomofo): its language depends on the line
-- "cjk_punct" CJK punctuation and the fullwidth forms
-- "neutral" everything else: spaces, punctuation, symbols, emoji
local function script_of(cp)
if cp < 0x80 then
if cp >= 0x30 and cp <= 0x39 then return "digit" end
if (cp >= 0x41 and cp <= 0x5A) or (cp >= 0x61 and cp <= 0x7A) then return "flex" end
return "neutral"
end
if cp < 0x2B0 then -- Latin-1, Latin Extended-A and -B, IPA
if cp < 0xC0 or cp == 0xD7 or cp == 0xF7 then return "neutral" end
return (cp < 0x180) and "flex" or "latin"
end
if cp < 0x370 then return "neutral" end -- modifier letters, combining marks
if cp < 0x900 then -- Greek, Cyrillic, Armenian, Hebrew, Arabic ...
if (cp >= 0x600 and cp <= 0x6FF) or (cp >= 0x750 and cp <= 0x77F) or cp >= 0x870 then
return "arabic"
end
return "latin"
end
if cp < 0x980 then return "devanagari" end
if cp < 0xE00 then return "latin" end
if cp < 0xE80 then return "thai" end
if cp < 0x1100 then return "latin" end
if cp < 0x1200 then return "korean" end -- Hangul Jamo
if cp < 0x2000 then
if (cp >= 0x1AB0 and cp <= 0x1AFF) or (cp >= 0x1DC0 and cp <= 0x1DFF) then return "neutral" end
if cp >= 0x1CD0 and cp <= 0x1CFF then return "devanagari" end
return "latin" -- Latin Extended Additional (Vietnamese), Greek Extended ...
end
if cp < 0x2C00 then return "neutral" end -- punctuation, symbols, arrows, shapes
if cp < 0x2E00 then return "latin" end -- Latin Extended-C, Cyrillic Extended-A ...
if cp < 0x2E80 then return "neutral" end
if cp < 0x3000 then return "han" end -- radicals
if cp < 0x3040 then
if cp == 0x3005 or cp == 0x3006 or cp == 0x3007
or (cp >= 0x3021 and cp <= 0x3029) or (cp >= 0x3038 and cp <= 0x303B) then
return "han"
end
return "cjk_punct"
end
if cp < 0x3100 then
if cp == 0x3099 or cp == 0x309A then return "neutral" end
return "japanese"
end
if cp < 0x3130 then return "han" end -- Bopomofo
if cp < 0x3190 then return "korean" end
if cp < 0x31A0 then return "cjk_punct" end
if cp < 0x31F0 then return "han" end -- Bopomofo Extended, strokes
if cp < 0x3200 then return "japanese" end -- Katakana Phonetic Extensions
if cp < 0x3400 then return "cjk_punct" end -- enclosed and compatibility forms
if cp < 0x4DC0 then return "han" end
if cp < 0x4E00 then return "neutral" end
if cp < 0xA000 then return "han" end
if cp < 0xA8E0 then return "latin" end
if cp < 0xA900 then return "devanagari" end
if cp < 0xA960 then return "latin" end
if cp < 0xA980 then return "korean" end
if cp < 0xAC00 then return "latin" end
if cp < 0xD800 then return "korean" end -- Hangul syllables
if cp < 0xF900 then return "neutral" end -- surrogates, private use
if cp < 0xFB00 then return "han" end -- compatibility ideographs
if cp < 0xFB50 then return "latin" end
if cp < 0xFE00 then return "arabic" end
if cp < 0xFE10 then return "neutral" end
if cp < 0xFE20 then return "cjk_punct" end
if cp < 0xFE30 then return "neutral" end
if cp < 0xFE70 then return "cjk_punct" end
if cp < 0xFF00 then return (cp == 0xFEFF) and "neutral" or "arabic" end
if cp < 0xFF66 then return "cjk_punct" end -- fullwidth forms
if cp < 0xFFA0 then return "japanese" end -- halfwidth Katakana
if cp < 0xFFDD then return "korean" end -- halfwidth Hangul
if cp < 0xFFF0 then return "cjk_punct" end
if cp < 0x1B000 then return "neutral" end
if cp < 0x1B170 then return "japanese" end -- Kana Supplement
if cp < 0x1EE00 then return "neutral" end
if cp < 0x1EF00 then return "arabic" end
if cp >= 0x20000 and cp < 0x323B0 then return "han" end
return "neutral"
end
-- The font object of one GUI font, as the layout needs it: .id for gui.set_font(),
-- and the functions that measure with it (see get_advance_function(),
-- get_word_width_function() and get_vertical_function()).
local function new_font(id, resource, tracking, leading, sx, sy)
return {
id = id,
advance = get_advance_function(resource, tracking, sx),
measure = get_word_width_function(resource, tracking, sx),
vertical = get_vertical_function(resource, tracking, leading, sy),
}
end
--- Collects the fonts of a text: the font object of every language (see GROUPS)
-- and of every name in the option "fonts". Fonts are compared by object, and one
-- object stands for a font resource, so two names of the same font are the same.
-- latin without a font gets the font of the container node.
local function build_fontset(container, opts, tracking, leading, sx, sy)
local objects = {} -- font resource -> font object
local function get(id)
local ok, resource = pcall(gui.get_font_resource, id)
assert(ok and resource, "typewriter: there is no font '" .. tostring(id) .. "' in the GUI scene")
local key = tostring(resource)
local object = objects[key]
if not object then
object = new_font(id, resource, tracking, leading, sx, sy)
objects[key] = object
end
return object
end
local by_name, by_lower_name = {}, {}
for name, id in pairs(opts.fonts or {}) do
assert(type(name) == "string", "typewriter: the keys of the option 'fonts' must be strings")
local font = get(id)
by_name[name] = font
by_lower_name[lower(name)] = by_lower_name[lower(name)] or font
end
local latin = by_name.latin or get(gui.get_font(container))
local group_fonts = { latin = latin }
for _, group in ipairs(GROUPS) do
if group ~= "latin" then
local font = by_name[group]
if not font then
for _, other in ipairs(FALLBACKS[group] or {}) do
font = by_name[other]
if font then break end
end
end
group_fonts[group] = font or latin
end
end
return {
default = latin, group_fonts = group_fonts, leading = leading,
by_name = by_name, by_lower_name = by_lower_name,
}
end
local warned_fonts = {}
-- The font of a tag: a font object, or false if it has no (known) name.
local function resolve_font_tag(tag, fontset)
if tag.font == nil then
local name = tag.value
local font = name and (fontset.by_name[name] or fontset.by_lower_name[lower(name)])
if name and not font and not warned_fonts[name] then
warned_fonts[name] = true
print("typewriter: is ignored, 'fonts' has no entry '" .. name .. "'")
end
tag.font = font or false
end
return tag.font
end
-- The font that the tags around a character ask for (the innermost one that
-- has a known name), nil if there is none.
local function get_manual_font(tok, fontset)
local tag = tok.font_tag
while tag do
local font = resolve_font_tag(tag, fontset)
if font then return font end
tag = tag.parent
end
end
local FORM_GROUP = { s = "chinese", t = "chinese_traditional" }
-- Gives the Han characters of one line (tokens) their language group: .group.
-- `language` is the one the line has for Han characters, `variant` how the Chinese
-- of the whole text is written: "simplified", "traditional" or "mixed".
local function resolve_han(han, language, variant)
if language ~= "chinese" then
for _, c in ipairs(han) do c.group = language end
elseif variant ~= "mixed" then
local group = (variant == "traditional") and "chinese_traditional" or "chinese"
for _, c in ipairs(han) do c.group = group end
else
-- Both kinds occur: a character that is one of them keeps its kind, one that
-- is the same in both follows the one before it (or, at the start, after it).
local previous
for _, c in ipairs(han) do
local form = han_form(c.cp)
if form then previous = form end
c.group = previous and FORM_GROUP[previous] or nil
end
local following
for k = #han, 1, -1 do
local c = han[k]
if han_form(c.cp) then
following = c.group
elseif not c.group then
c.group = following or "chinese"
end
end
end
end
-- The language group of the CJK character nearest to chars[k]: before it, else after it.
local function find_cjk_group(chars, k)
for j = k - 1, 1, -1 do
local group = chars[j].group
if group and CJK_GROUPS[group] then return group end
end
for j = k + 1, #chars do
local c = chars[j]
if c.script ~= "cjk_punct" and c.group and CJK_GROUPS[c.group] then return c.group end
end
end
-- Gives every character of one line (paragraph.chars) that has no font yet its font.
-- paragraph.han: its Han characters, paragraph.language: their language.
-- The steps are explained in the "Fonts" section at the top of the file:
-- 1. Han characters, other scripts: their own language.
-- 2. Runs of Latin letters and digits: no language of their own, "flex". (A run
-- with a letter of the other Latin scripts in it, e.g. a Vietnamese word, is
-- latin as a whole, so that it is not cut.) They vote for the language of the line.
-- 3. The language of the line ("host") is the one with the most votes: a character
-- of a non-Latin script counts 1, a run of Latin letters counts 1. A tie goes
-- to the first one of GROUPS, which is latin.
-- 4. CJK punctuation follows the nearest CJK character, flex characters the host,
-- and everything else (neutral) the character before it.
local function resolve_line_fonts(paragraph, fontset, variant)
local chars = paragraph.chars
if #paragraph.han > 0 then resolve_han(paragraph.han, paragraph.language, variant) end
local votes = {}
local function vote(group) votes[group] = (votes[group] or 0) + 1 end
local run_first, run_strong, run_letters -- the run of Latin letters and digits that is open
local function end_run(last)
if not run_first then return end
if run_letters then vote("latin") end
local group = run_strong and "latin" or "flex"
for k = run_first, last do chars[k].group = group end
run_first = nil
end
for k, c in ipairs(chars) do
local script = c.script -- nil: it has a manual font
if script == "flex" or script == "digit" or script == "latin" then
if not run_first then run_first, run_strong, run_letters = k, false, false end
if script == "latin" then
run_strong, run_letters = true, true
elseif script == "flex" then
run_letters = true
end
else
end_run(k - 1)
if script == "han" then
vote(c.group)
elseif STRONG_GROUPS[script] then
c.group = script
vote(script)
end
end
end
end_run(#chars)
local host, best = "latin", votes.latin or 0
for _, group in ipairs(GROUPS) do
local count = votes[group] or 0
if count > best then host, best = group, count end
end
for k, c in ipairs(chars) do
if c.script == "cjk_punct" then c.group = find_cjk_group(chars, k) end
end
local previous -- the group of the last character that had one
for _, c in ipairs(chars) do
if not c.font then
local group = c.group
if group == "flex" then group = host end
if group then
previous = group
else
group = previous or host
end
c.font = fontset.group_fonts[group]
end
end
-- a line break counts with the font of the line (it gives an empty line its height)
local newline = paragraph.newline
if newline then
local last = chars[#chars]
newline.font = last and last.font or fontset.group_fonts[host]
end
end
--- Gives every char token (and every newline) its font: .font, a font object of
-- the fontset. The font tags decide first, then the language of the text, see "Fonts"
-- at the top of the file. The tokens come from M.parse().
local function assign_fonts(tokens, fontset)
local paragraphs = {}
local traditional, simplified = false, false
local first = 1
for index = 1, #tokens + 1 do
local tok = tokens[index]
if tok == nil or tok.type == "newline" then
local paragraph = { newline = tok, chars = {}, han = {} }
local kana, hangul = false, false
for k = first, index - 1 do
local c = tokens[k]
if c.type == "char" then
paragraph.chars[#paragraph.chars + 1] = c
c.font = get_manual_font(c, fontset) -- nil: automatic
if not c.font then
local script = script_of(c.cp)
c.script = script
if script == "japanese" then
kana = true
elseif script == "korean" then
hangul = true
elseif script == "han" then
paragraph.han[#paragraph.han + 1] = c
end
end
end
end
paragraph.language = kana and "japanese" or (hangul and "korean" or "chinese")
if paragraph.language == "chinese" then
for _, c in ipairs(paragraph.han) do
local form = han_form(c.cp)
if form == "t" then traditional = true elseif form == "s" then simplified = true end
end
end
paragraphs[#paragraphs + 1] = paragraph
first = index + 1
end
end
local variant = "simplified" -- also if nothing tells
if traditional and simplified then
variant = "mixed"
elseif traditional then
variant = "traditional"
end
for _, paragraph in ipairs(paragraphs) do
resolve_line_fonts(paragraph, fontset, variant)
end
end
--------------------------------------------------------------------------------
-- GUI helpers
--------------------------------------------------------------------------------
local function resolve_node(node)
if type(node) == "string" then return gui.get_node(node) end
return node
end
-- Pivot -> { fraction from the left, fraction from the bottom } of the box.
local pivot_factors = {
[gui.PIVOT_CENTER] = { 0.5, 0.5 },
[gui.PIVOT_N] = { 0.5, 1 },
[gui.PIVOT_NE] = { 1, 1 },
[gui.PIVOT_E] = { 1, 0.5 },
[gui.PIVOT_SE] = { 1, 0 },
[gui.PIVOT_S] = { 0.5, 0 },
[gui.PIVOT_SW] = { 0, 0 },
[gui.PIVOT_W] = { 0, 0.5 },
[gui.PIVOT_NW] = { 0, 1 },
}
local function get_pivot_factors(pivot)
return pivot_factors[pivot] or pivot_factors[gui.PIVOT_CENTER]
end
local function merge(base, extra)
local out = {}
for k, v in pairs(base) do out[k] = v end
if extra then
for k, v in pairs(extra) do out[k] = v end
end
return out
end
-- Like merge(), but the option "fonts" is merged entry by entry (so type() can add
-- or replace single fonts), and "font" is turned into fonts.latin. Within one level
-- fonts.latin wins over font. The result has no "font".
local function merge_options(base, extra)
local out = merge(base, extra)
local fonts = merge(base.fonts or {})
if extra then
if extra.fonts ~= nil then
assert(type(extra.fonts) == "table", "typewriter: the option 'fonts' must be a table")
for name, font in pairs(extra.fonts) do fonts[name] = font end
end
if extra.font ~= nil and not (extra.fonts and extra.fonts.latin ~= nil) then
fonts.latin = extra.font
end
end
out.fonts, out.font = fonts, nil
return out
end
-- Reads the "scale", "scale_x" and "scale_y" options. "scale" is a number (x and
-- y) or a vector with x and y fields (vmath.vector3, vmath.vector4 or a table);
-- scale_x / scale_y, if given, replace the factor of their axis.
-- Returns scale x and scale y.
local function resolve_scale(scale, scale_x, scale_y)
local sx, sy
if scale == nil then
sx, sy = 1, 1
elseif type(scale) == "number" then
sx, sy = scale, scale
else
sx, sy = scale.x, scale.y
end
if scale_x ~= nil then sx = scale_x end
if scale_y ~= nil then sy = scale_y end
assert(type(sx) == "number" and type(sy) == "number",
"typewriter: scale must be a number or a vector with x and y")
assert(sx > 0 and sy > 0, "typewriter: scale must be greater than 0")
return sx, sy
end
local function same_vector(a, b)
return math.abs(a.x - b.x) < 0.01
and math.abs(a.y - b.y) < 0.01
and math.abs(a.z - b.z) < 0.01
end
local function delete_callback(_, node)
gui.delete_node(node)
end
-- Creates an empty text node below `parent`, at the origin of the container and
-- with alpha `alpha`. It is used to move whole groups of characters at once.
local function new_group_node(container, parent, alpha)
local node = gui.clone(container)
gui.set_parent(node, parent, false)
gui.set_text(node, "")
gui.set_position(node, v3(0))
gui.set_scale(node, v3(1, 1, 1))
gui.set_euler(node, v3(0))
gui.set_alpha(node, alpha)
return node
end
--------------------------------------------------------------------------------
-- Sprites and images
--------------------------------------------------------------------------------
-- Sprites: the text renderer only reserves the space for a tag, it
-- draws nothing. gui.get_layout_objects() tells where (x, y = lower left corner,
-- relative to the origin of the text node) and how large (width, height) the
-- sprite is, so a box node is created there, with `src` as its texture and
-- `animation` as its flipbook animation. The box node is a CHILD of the node of
-- the letter, so it moves, scales, fades and is deleted together with it
-- (it inherits the alpha of its parent). Without `src` no box node is created.
-- External images: a src that ends with .png, .jpg or .jpeg (any capitalization) is
-- an image file, not an atlas. A path starting with "/" is a custom resource of the
-- bundle (sys.load_resource), any other path is a file of the file system (io.open).
-- The image becomes a GUI texture, created once per textbox and src (`images` is
-- the cache of the textbox: src -> texture id, or false if loading failed).
local IMAGE_EXTENSIONS = make_set({ "png", "jpg", "jpeg" })
local image_counter = 0
local function is_image_path(src)
local extension = match(src, "%.(%w+)$")
return extension ~= nil and IMAGE_EXTENSIONS[lower(extension)]
end
local function read_image_file(src)
if byte(src, 1) == 47 then -- "/"
return sys.load_resource(src)
end
local file, err = io.open(src, "rb")
if not file then return nil, err end
local data = file:read("*a")
file:close()
return data
end
-- Returns the id of the GUI texture of the image, or nil if it cannot be loaded
-- (a warning is printed once; the sprite then stays empty).
local function get_image_texture(images, src)
local id = images[src]
if id ~= nil then return id or nil end
images[src] = false
local data, err = read_image_file(src)
local img
if data then img, err = image.load(data, { premultiply_alpha = true }) end
if not img then
print("typewriter: cannot load image '" .. src .. "': " .. tostring(err))
return nil
end
image_counter = image_counter + 1
id = "typewriter_image_" .. image_counter
local ok = gui.new_texture(id, img.width, img.height, img.type, img.buffer, false)
if not ok then
print("typewriter: cannot create a texture for '" .. src .. "'")
return nil
end
images[src] = id
return id
end
local function clear_sprites(letter)
if letter.sprites then
for _, box in ipairs(letter.sprites) do gui.delete_node(box) end
letter.sprites = nil
end
end
-- Creates the box node of one sprite as a child of `parent`, the node of a letter.
-- `flipbook` is the name of the animation to play, or nil.
local function new_sprite_box(parent, object, texture, flipbook, layer)
local w, h = object.width, object.height
-- x / y is the lower left corner of the sprite relative to the pivot of
-- the text node, so the box gets the pivot SW and exactly that position
local box = gui.new_box_node(v3(object.x, object.y, 0), v3(w, h, 0))
gui.set_parent(box, parent, false)
gui.set_pivot(box, gui.PIVOT_SW)
gui.set_size_mode(box, gui.SIZE_MODE_MANUAL)
gui.set_size(box, v3(w, h, 0))
gui.set_position(box, v3(object.x, object.y, 0))
gui.set_inherit_alpha(box, true)
pcall(gui.set_layer, box, layer) -- same layer as the text, if it has one
gui.set_texture(box, texture)
if flipbook then gui.play_flipbook(box, flipbook) end
return box
end
-- (Re)creates the box nodes of all sprite tags in the text of the node of `letter`.
-- Has to be called whenever the text of that node was set.
local function make_sprites(letter, container, images)
clear_sprites(letter)
if not find(lower(letter.markup), " inside a chunk, held for the next item
for _, item in ipairs(items) do
if item.type ~= "newline" then
local markup, scale_x = item.markup, sx
if item.frag_markup then
markup = item.frag_markup -- first node of a block fragment
elseif item.member then
markup = "" -- the fragment is drawn by its first node
elseif is_underlined_space(item) then
markup = item.open .. item.text .. ZWSP .. item.close
if item.stretch and item.adv > 0 then
-- justified gap: item.adv and item.stretch both include sx
scale_x = sx * (item.adv + item.stretch) / item.adv
end
end
placements[#placements + 1] = {
markup = markup,
font = item.font,
pos = v3(area.left + item.x, area.top - item.y, 0),
pivot = gui.PIVOT_NW,
scale = v3(scale_x, sy, 1),
index = item.index,
delay = item.delay,
stop = item.stop or carry,
unit = item.unit,
-- a space is never part of a word node (it may be stretched
-- by "justified"); one without any tag draws nothing at all
space = item.is_space,
hidden = item.member or (item.is_space and item.open == ""),
}
carry = item.inner_stop
end
end
return placements
end
-- Placements of the nodes in right-to-left mode: one node per word (or chunk).
-- Since Defold 1.13.2 the engine mirrors the meaning of the horizontal pivot for
-- right-to-left text (an East pivot no longer puts the text's right edge at the node
-- position, it left-aligns the line inside the node's width). A North pivot
-- (horizontally centred) is the same in both directions, so each word is placed by
-- its centre. This also works in 1.13.1 and earlier, where the centre pivot centres
-- the text too.
-- The scale works around the same pivot, so the centre of the word stays where it is
-- and item.adv (which includes the scale) is its width.
local function get_rtl_placements(items, area, sx, sy)
local placements = {}
local right = area.left + area.width
local carry = false -- a plain that is held for the next word
for _, item in ipairs(items) do
if item.type == "word" then
placements[#placements + 1] = {
markup = item.markup,
font = item.font,
pos = v3(right - item.x - item.adv / 2, area.top - item.y, 0),
pivot = gui.PIVOT_N,
scale = v3(sx, sy, 1),
index = item.index,
delay = item.delay,
stop = item.stop or carry,
}
carry = item.inner_stop
elseif item.type == "char" then
-- a space has no node, so its wait goes to the next word
carry = carry or item.stop or item.inner_stop
end
end
return placements
end
-- The nodes that one line of a was cut into by a change of font (see
-- group_blocks()) appear together, too.
local function get_cut_group(item)
return item.cut_group
end
-- The nodes that one or was cut into (at a change of font) appear
-- together. (A chunk that was not cut is a group of one, which changes nothing.)
local function get_chunk(item)
return item.chunk
end
-- The items of a start together, whatever the lines are.
local function get_sync(item)
local block = item.block
if block and block.name == "sync" then return block end
end
-- In right-to-left mode the words are the nodes and cannot be put together into
-- lines, so the words of one line of a block (and of a whole ) simply start
-- together.
local function get_sync_or_line(item)
local block = item.block
if not block then return nil end
if block.name == "sync" then return block end
if not item.chunk then return block, item.line end
end
--- Lays out a text and returns where every node of it goes.
-- This is the part of type() that animate() needs again with changed options, so
-- it only reads the container (font, tracking, leading, size, pivot) and creates
-- nothing. `opts` is a complete set of options (defaults merged).
-- The result has one entry per node, in the order the nodes are created (reading
-- order), and does not depend on width, scale or alignment, so two results for the
-- same text and the same rtl setting can be compared entry by entry:
-- { markup = text of the node, pos = vector3 (relative to the container's
-- origin), pivot = gui.PIVOT_*, scale = vector3, index = number of characters
-- before it (what the speed counts), delay = seconds of timed waits before it,
-- stop = a plain holds it, and for left-to-right text also
-- unit = number of its word (see layout()), space = true for a space,
-- hidden = true for a space without tags, which draws nothing }
local function compute_placements(container, text, opts)
local tracking = opts.tracking
if tracking == nil then tracking = gui.get_tracking(container) end
local leading = opts.leading
if leading == nil then leading = gui.get_leading(container) end
assert(type(tracking) == "number" and type(leading) == "number",
"typewriter: tracking and leading must be numbers")
local size = gui.get_size(container)
local width = opts.width or size.x
local max_width = (width > 0) and width or math.huge
-- always fresh: layout writes its results into them. Unless
-- disable_auto_blocking is true, the content of the tags that need it (see
-- AUTO_BLOCK) is a block, like with the tag.
local tokens = M.parse(text, not opts.disable_auto_blocking)
local rtl = opts.rtl
if rtl == nil then rtl = contains_arabic(tokens) end
-- The nodes are scaled by sx, sy. The text area keeps its width, so the layout
-- works with advances and sizes that include the scale.
local sx, sy = resolve_scale(opts.scale, opts.scale_x, opts.scale_y)
-- every character gets its font (tok.font) before anything is measured
local fontset = build_fontset(container, opts, tracking, leading, sx, sy)
assign_fonts(tokens, fontset)
local items, layout_fn -- what the layout works on, and how
if rtl then
items = build_words(tokens)
layout_fn = layout_rtl
else
items = build_chunks(tokens)
layout_fn = layout
end
local lines
if opts.balanced then
lines = layout_balanced(items, max_width, layout_fn)
else
lines = layout_fn(items, max_width)
end
if rtl then
align_lines(lines, max_width, MIRRORED_ALIGNMENT[opts.alignment or "right"] or "right")
else
group_blocks(lines) -- before align_lines()
align_lines(lines, max_width, opts.alignment or "left")
end
place_lines(lines, fontset) -- baselines, so that different sizes and fonts line up
share_times(items, get_chunk)
share_times(items, get_cut_group)
share_times(items, rtl and get_sync_or_line or get_sync)
-- top left corner of the text area, relative to the pivot of the node
local factors = get_pivot_factors(gui.get_pivot(container))
local area = { left = -factors[1] * width, top = (1 - factors[2]) * size.y, width = width }
if rtl then
return get_rtl_placements(items, area, sx, sy)
end
mark_line_ends(lines)
return get_ltr_placements(items, area, sx, sy)
end
-- Finds the words of a text whose letters can be moved by one node: the letters of
-- one unit (see layout()) without its spaces, at least two of them. Within such a
-- word the letters always keep the same distances to each other, whatever the
-- width, scale or alignment is: they are never separated by a line break, and the
-- advances and sizes they are placed with are linear in the scale. Only a space may
-- be stretched by "justified", which is why spaces are left out.
-- Returns a list in reading order of { members = { indices into the letters } }.
-- Right-to-left text has no units: its nodes are words already.
local function find_words(placements)
local units = {}
local current, current_unit
for n, p in ipairs(placements) do
if p.unit and not p.space then
if p.unit ~= current_unit then
current_unit = p.unit
current = { members = {} }
units[#units + 1] = current
end
current.members[#current.members + 1] = n
end
end
local words = {}
for _, unit in ipairs(units) do
if #unit.members >= 2 then words[#words + 1] = unit end
end
return words
end
--------------------------------------------------------------------------------
-- Runs: the typing of one text
--------------------------------------------------------------------------------
local function stop_timer(run)
if run.timer then
timer.cancel(run.timer)
run.timer = nil
end
end
local function complete_run(run)
if run.finished then return end
run.finished = true
local box = run.box
for _, callback in ipairs(run.callbacks) do callback(box, run.text) end
if box.listener then box.listener(box, run.text) end
end
-- Counts letters that are completely faded in (or need no fade in).
local function count_done(run, amount)
run.done = run.done + amount
if run.done >= #run.letters then complete_run(run) end
end
local function fades_in_moving(options)
return options.fadein_x ~= 0 or options.fadein_y ~= 0
end
local function fades_out_moving(options)
return options.fadeout_x ~= 0 or options.fadeout_y ~= 0
end
-- Stops the animations of Textbox:animate() on a node.
local function cancel_arrangement(node)
gui.cancel_animations(node, "position")
gui.cancel_animations(node, "scale")
end
-- Stops the fade in of a node.
local function cancel_fade_in(node, moving)
gui.cancel_animations(node, "color.w")
if moving then gui.cancel_animations(node, "position") end
end
-- Characters that are still fading in or moving stop where they are. This frees
-- their animation slots, the fade out of the run node takes over.
local function cancel_character_animations(run)
local moving = fades_in_moving(run.options)
if run.arranged then -- running animations of Textbox:animate()
for n = 1, run.next - 1 do
cancel_arrangement(run.letters[n].node)
end
for _, word in ipairs(run.words) do
if word.node then cancel_arrangement(word.node) end
end
end
for _, group in ipairs(run.groups) do -- see finish_letters()
if not group.done then cancel_fade_in(group.node, moving) end
end
for n = 1, run.next - 1 do
local letter = run.letters[n]
if not letter.done then cancel_fade_in(letter.node, moving) end
end
end
-- Fades a whole run out by animating its run node, then deletes it together with
-- all its characters. `fadeout` holds the fadeout options to use. Returns the fade
-- out duration actually used (0 if the run was deleted at once because nothing was
-- visible or the duration is 0).
local function fade_out_run(run, fadeout)
if not run then return 0 end
stop_timer(run)
local node = run.parent
local duration = fadeout.fadeout_duration
if duration <= 0 or run.next <= 1 then -- instant, or no character has started yet
gui.delete_node(node) -- also deletes the character nodes
return 0
end
cancel_character_animations(run)
if fades_out_moving(fadeout) then
gui.animate(node, "position", v3(fadeout.fadeout_x, fadeout.fadeout_y, 0), LINEAR, duration)
end
gui.animate(node, "color.w", 0, LINEAR, duration, 0, delete_callback)
return duration
end
-- When is this letter due, counted from the start of the run (without the plain
-- waits released so far)? The typing speed and the timed waits decide.
local function get_start_time(placement, speed)
local typing_time = (speed and speed > 0) and placement.index / speed or 0
return typing_time + placement.delay
end
-- Creates the node of one letter (or, in right-to-left mode, one word) as a child
-- of the run node `parent`. It is invisible until start_letter() fades it in.
-- Its placement says where it goes, see compute_placements(). The node is scaled
-- with the vector placement.scale: the "scale" option, and for the space of a
-- justified line also the stretching of the node horizontally from its left edge,
-- which makes the underline / strikethrough as wide as the gap.
local function new_letter(box, parent, placement, opts)
local node = gui.clone(box.node)
gui.set_parent(node, parent, false)
gui.set_font(node, placement.font.id) -- before the text: the layout objects of a sprite depend on it
if opts.tracking ~= nil then gui.set_tracking(node, opts.tracking) end
if opts.leading ~= nil then gui.set_leading(node, opts.leading) end
gui.set_line_break(node, false)
gui.set_pivot(node, placement.pivot)
-- The text is set after the pivot and the line break: the layout objects of a
-- sprite (position relative to the pivot) are made from the layout at the time
-- the text is set.
gui.set_text(node, placement.markup)
gui.set_scale(node, placement.scale)
gui.set_euler(node, v3(0))
gui.set_position(node, placement.pos)
gui.set_alpha(node, 0)
local letter = {
node = node, pos = placement.pos, scale = placement.scale, markup = placement.markup,
hidden = placement.hidden, stop = placement.stop, done = false,
time = get_start_time(placement, opts.speed),
}
make_sprites(letter, box.node, box.images) -- after everything that changes the layout
return letter
end
-- Does this letter draw nothing? (the rest of a block fragment, or a space without
-- tags): no animation needed, it is just there.
local function draws_nothing(letter)
return letter.markup == "" or letter.hidden
end
-- Starts the fade in of one character. Only called when its time has come, so no
-- delay is needed and nothing is animated before that.
local function start_letter(run, letter)
local o = run.options
local node, pos = letter.node, letter.pos
if draws_nothing(letter) then
-- It gets alpha 1 in case animate() makes it draw something.
gui.set_alpha(node, 1)
letter.done = true
count_done(run, 1)
return
end
if fades_in_moving(o) then
gui.set_position(node, v3(pos.x - o.fadein_x, pos.y - o.fadein_y, pos.z))
gui.animate(node, "position", pos, LINEAR, o.fadein_duration)
end
gui.animate(node, "color.w", 1, LINEAR, o.fadein_duration, 0, function()
letter.done = true
count_done(run, 1)
end)
end
-- Is this letter held back by a plain in front of it? (Not when the wait was
-- already released. Not for the empty node of a block fragment, which draws nothing:
-- the fragment is shown by its first node, so there is nothing left to wait for.)
local function is_stop(letter)
return letter.stop and not letter.released and letter.markup ~= ""
end
-- Starts all characters whose time (counted from the start of the run) has come.
-- A letter is due at letter.time + run.shift: letter.time is its time from the speed
-- and the timed waits, run.shift is what the plain waits that were released have
-- added. A letter behind a plain is not started, the run waits for it
-- (run.waiting = its index) until release_wait() is called.
-- run.elapsed is negative while the run waits for the old text to fade out.
local function start_due(run)
if run.elapsed < 0 or run.waiting then return end
local letters, count = run.letters, #run.letters
local shift, elapsed = run.shift, run.elapsed
local n = run.next
while n <= count do
local letter = letters[n]
if letter.time + shift > elapsed then break end
if is_stop(letter) then
run.waiting = n
break
end
start_letter(run, letter)
n = n + 1
end
run.next = n
if n > count or run.waiting then stop_timer(run) end
end
-- One timer per run drives start_due(). Nothing to do when everything has started
-- or the run waits at a plain (the clock stands still then, too).
local function start_timer(run)
if run.timer or run.waiting or run.next > #run.letters then return end
run.timer = timer.delay(0, true, function(_, _, dt)
run.elapsed = run.elapsed + dt
start_due(run)
end)
end
-- Ends the wait at a plain : the letter behind it is due right now, and the
-- clock goes on from there.
local function release_wait(run)
local n = run.waiting
run.waiting = nil
local letter = run.letters[n]
letter.released = true
run.shift = math.max(run.elapsed, 0) - letter.time
start_due(run)
start_timer(run)
end
-- Fades in the letters run.next .. last right now, together as one group. Letters
-- already fading keep going on their own. run.next is moved behind `last`.
-- The letters are moved into one group node and only that node is animated (2
-- animations instead of 2 per letter). The letters themselves become fully opaque,
-- the group node is the one that is transparent. There can be several groups in
-- one run (Textbox:continue() finishes up to a plain only).
local function finish_letters(run, last)
local first = run.next
run.next = last + 1
if first > last then return end
local o = run.options
local node = new_group_node(run.box.node, run.parent, 0)
local group = { node = node, done = false }
run.groups[#run.groups + 1] = group
if fades_in_moving(o) then
gui.set_position(node, v3(-o.fadein_x, -o.fadein_y, 0))
gui.animate(node, "position", v3(0), LINEAR, o.fadein_duration)
end
for n = first, last do
local letter = run.letters[n]
letter.done = true -- accounted for by the group below
letter.group = group -- but not really shown before group.done
gui.set_parent(letter.node, node, false) -- keeps the local position
gui.set_alpha(letter.node, 1)
end
local count = last - first + 1
gui.animate(node, "color.w", 1, LINEAR, o.fadein_duration, 0, function()
group.done = true
count_done(run, count)
end)
end
--------------------------------------------------------------------------------
-- Rearranging a text that is already there
--------------------------------------------------------------------------------
local ANIMATE_KEYS = make_set({"scale", "scale_x", "scale_y", "width", "alignment", "tracking", "leading"})
local INSTANT = { instant = true }
-- Sets or animates the position or the scale of a node. `motion` is INSTANT, or
-- { easing = ..., duration = ... }.
local function glide(run, node, property, target, motion)
if motion.instant then
gui.cancel_animations(node, property)
if property == "position" then
gui.set_position(node, target)
else
gui.set_scale(node, target)
end
else
gui.animate(node, property, target, motion.easing, motion.duration)
run.arranged = true
end
end
-- Moves and scales the node of a letter to its placement, if that changes anything.
local function move_letter(run, letter, placement, motion)
if not same_vector(placement.pos, letter.pos) then
glide(run, letter.node, "position", placement.pos, motion)
end
if not same_vector(placement.scale, letter.scale) then
glide(run, letter.node, "scale", placement.scale, motion)
end
end
-- Is every letter of the word completely faded in (and so not moved or faded by
-- anything else any more)? Letters that finish_letters() put into a group count
-- only after that group has arrived.
local function word_is_shown(run, word)
for _, n in ipairs(word.members) do
local letter = run.letters[n]
if not letter.done or (letter.group and not letter.group.done) then
return false
end
end
return true
end
-- Puts the letters of a word below one new word node, from now on only that node
-- is animated by animate(), whatever the number of letters. The word node starts
-- at the origin with scale 1, so the letters keep their local position and scale
-- (nothing jumps, and an animation a letter may still have is not disturbed).
-- Their position and scale at that moment are the reference (letter.base_pos /
-- base_scale) for move_word(). The word node is a child of the run node, so fading
-- out and deleting the run still takes everything with it.
local function group_word(run, word)
local node = new_group_node(run.box.node, run.parent, 1)
word.node, word.pos, word.scale = node, v3(0), v3(1, 1, 1)
for _, n in ipairs(word.members) do
local letter = run.letters[n]
gui.set_parent(letter.node, node, false) -- keeps the local position
letter.base_pos, letter.base_scale = letter.pos, letter.scale
end
end
-- Moves and scales a word node so that its letters end up where `placement` (the
-- new placement of its first letter) says. All letters of the word are placed
-- relative to each other the same way as before, only multiplied with the new
-- scale, so the transformation of the first one is the one of all: the node scales
-- with the ratio of the new and the old scale, and is positioned so that the first
-- letter lands on its new position. Position and scale are interpolated linearly,
-- like the letters would be, so with every easing the path is the one they would
-- take.
local function move_word(run, word, placement, motion)
local first = run.letters[word.members[1]]
local base_pos, base_scale = first.base_pos, first.base_scale
local sx, sy = placement.scale.x / base_scale.x, placement.scale.y / base_scale.y
local scale = v3(sx, sy, 1)
local pos = v3(
placement.pos.x - sx * base_pos.x,
placement.pos.y - sy * base_pos.y,
placement.pos.z - base_pos.z)
if not same_vector(pos, word.pos) then
glide(run, word.node, "position", pos, motion)
word.pos = pos
end
if not same_vector(scale, word.scale) then
glide(run, word.node, "scale", scale, motion)
word.scale = scale
end
end
-- The options of a run after animate() changed some of them. A given "scale"
-- replaces the factors of both axes; the stored options always hold the resulting
-- vector, so scale_x / scale_y from before cannot override a new "scale".
local function change_options(old, changes)
local scale = changes.scale
if scale == nil then
local current_x, current_y = resolve_scale(old.scale, old.scale_x, old.scale_y)
scale = v3(current_x, current_y, 1)
end
local sx, sy = resolve_scale(scale, changes.scale_x, changes.scale_y)
local new = merge(old)
new.scale, new.scale_x, new.scale_y = v3(sx, sy, 1), nil, nil
for _, key in ipairs({ "width", "alignment", "tracking", "leading" }) do
if changes[key] ~= nil then new[key] = changes[key] end
end
return new
end
--------------------------------------------------------------------------------
-- Textbox
--------------------------------------------------------------------------------
local Textbox = {}
Textbox.__index = Textbox
--- Registers a text node as a new textbox.
-- @param node GUI text node (or its id string)
-- @param options table, overrides the defaults for every text of this textbox
-- @param listener function(textbox, text), called every time a text is done
function M.new(node, options, listener)
local box = setmetatable({}, Textbox)
box.node = assert(resolve_node(node), "typewriter: new() needs a GUI text node")
box.options = merge_options(DEFAULTS, options)
box.listener = listener
box.images = {} -- GUI textures made from image files, see get_image_texture()
gui.set_text(box.node, "")
return box
end
--- Starts typing a text. Text that is currently shown is faded out.
-- The fadeout options used are the ones valid for this call.
-- A text that is interrupted by a new one never finishes, so neither its callback
-- nor the listener are called for it.
-- @param text string, may contain tags
-- @param options optional, overrides the textbox options for this text
-- @param callback optional function(textbox, text), called once this text is done
function Textbox:type(text, options, callback)
text = text or ""
local opts = merge_options(self.options, options)
local container = self.node
local fade_time = fade_out_run(self.run, opts)
self.run = nil
gui.set_text(container, "")
local placements = compute_placements(container, text, opts)
-- The run node: parent of all characters of this text.
local parent = new_group_node(container, container, 1)
-- The text starts after (1 - fade_cross) of the fade out of the old text.
local cross = math.max(0, math.min(1, opts.fade_cross or 1))
local run = {
box = self, text = text, options = opts, letters = {}, done = 0,
callbacks = {}, finished = false,
parent = parent,
next = 1, -- index of the first letter that has not been started
elapsed = -(1 - cross) * fade_time,
shift = 0, -- seconds added to the time of every letter by released waits
waiting = nil, -- index of the letter held back by a plain
groups = {}, -- the groups made by finish_letters()
}
if callback then run.callbacks[1] = callback end
self.run = run
for _, placement in ipairs(placements) do
run.letters[#run.letters + 1] = new_letter(self, parent, placement, opts)
end
-- Words that animate() may move with one node once all their letters are shown.
run.words = find_words(placements)
for _, word in ipairs(run.words) do
for _, n in ipairs(word.members) do run.letters[n].word = word end
end
if #run.letters == 0 then -- nothing to type (empty or tags only)
run.timer = timer.delay(0, false, function()
run.timer = nil
complete_run(run)
end)
return
end
-- Start what is due right now, a single timer takes care of the rest.
start_due(run)
start_timer(run)
end
--- Rearranges the current text smoothly, as if it had been typed with changed
-- options: the new positions and scales of all nodes are calculated (new line
-- breaks, new alignment ...) and every node that has to move or change its size is
-- animated there with gui.animate().
-- Works at any time: characters that have not appeared yet are simply put at their
-- new place, characters that are still fading in move on while they do, and
-- calling it again while a rearrangement is running continues from where the
-- nodes are. The changed options stay valid for this text (a later animate() builds
-- on them), but not for texts typed afterwards, those use the options of the
-- textbox and of the type() call as before.
-- Nodes that keep their position / scale are not animated, which saves animation
-- slots (every animated node needs up to two of the 1024 that Defold allows at
-- once). It saves more in two ways:
-- * Once all letters of a word are completely faded in, they are put below one
-- word node, and only that node is moved and scaled (2 animations for the
-- whole word). A word is what a line can not be broken in (see "Notes"); its
-- spaces are not part of it. Letters that are still fading in, words of only
-- one letter (e.g. CJK text) and spaces with tags are animated one by one.
-- In right-to-left mode a word is one node anyway.
-- * A space without any tag draws nothing, so it is just put at its new place.
-- @param options table with any of
-- scale number | vector, scales x and y (replaces an
-- earlier scale_x / scale_y)
-- scale_x number, x only
-- scale_y number, y only
-- width width of the text area (0 = no line breaks)
-- alignment "left" | "right" | "center" | "justified"
-- tracking number, extra space between characters
-- leading number, line height factor
-- scale_x / scale_y given together with scale win over it.
-- @param duration seconds, default 1.0 (0 or less: change immediately)
-- @param easing gui.EASING_*, default gui.EASING_LINEAR
function Textbox:animate(options, duration, easing)
options = options or {}
for key in pairs(options) do
assert(ANIMATE_KEYS[key], "typewriter: animate() does not know the option '"
.. tostring(key) .. "'")
end
duration = duration or 1.0
easing = easing or LINEAR
local run = self.run
if not run then return end
local old_options = run.options
local new_options = change_options(old_options, options)
local placements = compute_placements(self.node, run.text, new_options)
local letters = run.letters
assert(#placements == #letters, "typewriter: the layout changed the number of nodes")
run.options = new_options
local motion = (duration <= 0) and INSTANT or { easing = easing, duration = duration }
-- Tracking and leading are node properties that cannot be animated: the nodes
-- get the new values at once (the positions below glide as usual).
if options.tracking ~= nil and options.tracking ~= old_options.tracking then
for _, letter in ipairs(letters) do gui.set_tracking(letter.node, options.tracking) end
end
if options.leading ~= nil and options.leading ~= old_options.leading then
for _, letter in ipairs(letters) do gui.set_leading(letter.node, options.leading) end
end
-- Words that are completely shown move as one node. This has to happen before
-- the loop below, which overwrites letter.pos and letter.scale.
for _, word in ipairs(run.words) do
if not word.node and word_is_shown(run, word) then
group_word(run, word)
end
if word.node then
move_word(run, word, placements[word.members[1]], motion)
end
end
for n, placement in ipairs(placements) do
local letter = letters[n]
-- (also the text of the nodes of block fragments, which depends on the
-- line breaks: a node may start or stop drawing a fragment)
if placement.markup ~= letter.markup then
gui.set_text(letter.node, placement.markup)
letter.markup = placement.markup
make_sprites(letter, self.node, self.images)
end
letter.hidden = placement.hidden
if n >= run.next then
-- Has not started yet, so it is invisible: put it where it will start
-- from (start_letter() uses letter.pos).
gui.set_position(letter.node, placement.pos)
gui.set_scale(letter.node, placement.scale)
elseif letter.word and letter.word.node then
-- moved by the node of its word (above)
elseif letter.hidden then
-- draws nothing, so nobody sees it move
move_letter(run, letter, placement, INSTANT)
else
-- replaces the fade in movement of a node that still has one
move_letter(run, letter, placement, motion)
end
letter.pos, letter.scale = placement.pos, placement.scale
end
end
--- Deletes the GUI textures that were created for sprites with an image file as
-- src. Call it when the textbox is not used any more (e.g. in final()), after the
-- text is gone: sprites that still show such an image lose their texture.
function Textbox:release_images()
for _, id in pairs(self.images) do
if id then gui.delete_texture(id) end
end
self.images = {}
end
--- If the text is still typing (or still waiting for the old text to fade out),
-- fades in all characters that have not begun yet right now, together as one
-- group. Characters already fading keep going on their own.
-- All tags that have not been reached are skipped. Use continue() to go
-- on only up to the next plain .
-- If the text is already done the callback is called immediately.
-- @param callback optional function(textbox, text), called when the last character
-- is fully faded in
function Textbox:finish(callback)
local run = self.run
if not run or run.finished then
if callback then callback(self, run and run.text) end
return
end
if callback then run.callbacks[#run.callbacks + 1] = callback end
local count = #run.letters
if count == 0 then return end -- empty text: its own timer completes it
stop_timer(run)
run.waiting = nil
finish_letters(run, count)
end
-- The index of the first letter, from run.next on, that is held back by a plain
-- . nil if there is none.
local function find_next_stop(run)
for n = run.next, #run.letters do
if is_stop(run.letters[n]) then return n end
end
end
--- The "accept" button of a dialogue box. What it does depends on the state:
-- * The text waits at a plain : the typing goes on. Returns false.
-- * The text is still typing: it is finished like finish() does, but only up to
-- the next plain (or to the end if there is none). The textbox then waits
-- at that tag, the next call goes on. Returns false.
-- * The text is completely there: it fades out (with the fadeout options of this
-- text) and true is returned. This is the signal that the textbox is ended, so
-- the next text can be typed, or the textbox hidden. The next type() starts
-- at once, whatever fade_cross is, since there is no old text to wait for any more.
-- Without any text (nothing typed yet, or already faded out) it returns true.
-- @return true if the text is fading out now, false if it is (still) being shown
function Textbox:continue()
local run = self.run
if not run then return true end
if run.finished then
fade_out_run(run, run.options)
self.run = nil
return true
end
if run.waiting then
release_wait(run)
return false
end
local count = #run.letters
if count == 0 then return false end -- empty text: its own timer completes it
local stop = find_next_stop(run)
stop_timer(run)
if stop then
finish_letters(run, stop - 1)
run.waiting = stop
else
finish_letters(run, count)
end
return false
end
return M