commit 110169a93340a8db86f677b6455dee8e78ed7329 Author: halfstar Date: Sat Oct 24 17:18:55 2026 +0000 Init diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..187cdf4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,42 @@ +# Defold Protocol Buffer Text Files (https://github.com/github/linguist/issues/5091) +*.animationset linguist-language=JSON5 +*.atlas linguist-language=JSON5 +*.camera linguist-language=JSON5 +*.collection linguist-language=JSON5 +*.collectionfactory linguist-language=JSON5 +*.collectionproxy linguist-language=JSON5 +*.collisionobject linguist-language=JSON5 +*.cubemap linguist-language=JSON5 +*.display_profiles linguist-language=JSON5 +*.factory linguist-language=JSON5 +*.font linguist-language=JSON5 +*.gamepads linguist-language=JSON5 +*.go linguist-language=JSON5 +*.gui linguist-language=JSON5 +*.input_binding linguist-language=JSON5 +*.label linguist-language=JSON5 +*.material linguist-language=JSON5 +*.mesh linguist-language=JSON5 +*.model linguist-language=JSON5 +*.particlefx linguist-language=JSON5 +*.render linguist-language=JSON5 +*.sound linguist-language=JSON5 +*.sprite linguist-language=JSON5 +*.spinemodel linguist-language=JSON5 +*.spinescene linguist-language=JSON5 +*.texture_profiles linguist-language=JSON5 +*.tilemap linguist-language=JSON5 +*.tilesource linguist-language=JSON5 + +# Defold JSON Files +*.buffer linguist-language=JSON + +# Defold GLSL Shaders +*.fp linguist-language=GLSL +*.vp linguist-language=GLSL + +# Defold Lua Files +*.editor_script linguist-language=Lua +*.render_script linguist-language=Lua +*.script linguist-language=Lua +*.gui_script linguist-language=Lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f4d613 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/.editor_settings +/.internal +/build +.externalToolBuilders +.DS_Store +Thumbs.db +.lock-wscript +*.pyc +.project +.cproject +builtins \ No newline at end of file diff --git a/assets/NotoSans-Regular.ttf b/assets/NotoSans-Regular.ttf new file mode 100644 index 0000000..04be6f5 Binary files /dev/null and b/assets/NotoSans-Regular.ttf differ diff --git a/assets/NotoSansArabic-Regular.ttf b/assets/NotoSansArabic-Regular.ttf new file mode 100644 index 0000000..3279938 Binary files /dev/null and b/assets/NotoSansArabic-Regular.ttf differ diff --git a/assets/NotoSansDevanagari-Regular.ttf b/assets/NotoSansDevanagari-Regular.ttf new file mode 100644 index 0000000..ab3f433 Binary files /dev/null and b/assets/NotoSansDevanagari-Regular.ttf differ diff --git a/assets/NotoSansJP-Regular.ttf b/assets/NotoSansJP-Regular.ttf new file mode 100644 index 0000000..84f1705 Binary files /dev/null and b/assets/NotoSansJP-Regular.ttf differ diff --git a/assets/NotoSansKR-Regular.ttf b/assets/NotoSansKR-Regular.ttf new file mode 100644 index 0000000..8e0e109 Binary files /dev/null and b/assets/NotoSansKR-Regular.ttf differ diff --git a/assets/NotoSansSC-Regular.ttf b/assets/NotoSansSC-Regular.ttf new file mode 100644 index 0000000..9c21fd3 Binary files /dev/null and b/assets/NotoSansSC-Regular.ttf differ diff --git a/assets/NotoSansTC-Regular.ttf b/assets/NotoSansTC-Regular.ttf new file mode 100644 index 0000000..807c2af Binary files /dev/null and b/assets/NotoSansTC-Regular.ttf differ diff --git a/assets/NotoSansThai-Regular.ttf b/assets/NotoSansThai-Regular.ttf new file mode 100644 index 0000000..54f3e29 Binary files /dev/null and b/assets/NotoSansThai-Regular.ttf differ diff --git a/assets/NotoSerif-Regular.ttf b/assets/NotoSerif-Regular.ttf new file mode 100644 index 0000000..14e4150 Binary files /dev/null and b/assets/NotoSerif-Regular.ttf differ diff --git a/example/example.collection b/example/example.collection new file mode 100644 index 0000000..156475c --- /dev/null +++ b/example/example.collection @@ -0,0 +1,10 @@ +name: "main" +scale_along_z: 0 +embedded_instances { + id: "go" + data: "components {\n" + " id: \"example\"\n" + " component: \"/example/example.gui\"\n" + "}\n" + "" +} diff --git a/example/example.gui b/example/example.gui new file mode 100644 index 0000000..afc79c1 --- /dev/null +++ b/example/example.gui @@ -0,0 +1,76 @@ +script: "/example/example.gui_script" +fonts { + name: "japanese" + font: "/example/fonts/japanese.font" +} +fonts { + name: "chinese" + font: "/example/fonts/chinese.font" +} +fonts { + name: "chinese_traditional" + font: "/example/fonts/chinese_traditional.font" +} +fonts { + name: "devanagari" + font: "/example/fonts/devanagari.font" +} +fonts { + name: "korean" + font: "/example/fonts/korean.font" +} +fonts { + name: "thai" + font: "/example/fonts/thai.font" +} +fonts { + name: "arabic" + font: "/example/fonts/arabic.font" +} +fonts { + name: "noto_sans" + font: "/example/fonts/noto_sans.font" +} +nodes { + position { + x: 240.0 + y: 600.0 + } + size { + x: 400.0 + y: 500.0 + } + color { + x: 0.3019608 + y: 0.3019608 + z: 0.3019608 + } + type: TYPE_BOX + id: "box1" + pivot: PIVOT_N + inherit_alpha: true + size_mode: SIZE_MODE_AUTO +} +nodes { + position { + x: 240.0 + y: 600.0 + } + scale { + x: 0.5 + y: 0.5 + } + size { + x: 800.0 + y: 1000.0 + } + type: TYPE_TEXT + text: "text" + font: "noto_sans" + id: "text1" + pivot: PIVOT_N + inherit_alpha: true +} +material: "/builtins/materials/gui.material" +adjust_reference: ADJUST_REFERENCE_PARENT +max_nodes: 2048 diff --git a/example/example.gui_script b/example/example.gui_script new file mode 100644 index 0000000..50df211 --- /dev/null +++ b/example/example.gui_script @@ -0,0 +1,136 @@ +local typewriter = require "typewriter.typewriter" + +local ZWSP = "\226\128\139" + +local example0 = "Lorem ipsum dolor sit." +local example1 = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example2 = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum." +local example3 = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + +local example_mix = [[Text Color +Horizontal Vertical Corners +Animated +Shake Wave]] + +local example_color = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_color_2 = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_size = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_shake = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_shake_span = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_wave = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_wave_chunk = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_wave_block = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_gradient_h = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_gradient_h_full = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_gradient_v_anim = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_gradient_corners = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_gradient_corners_span = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_ul = "Lorem ipsum dolor sit amet,
    consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt
ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_ul_dashed = "Lorem ipsum dolor sit amet,
    consetetur sadipscing elitr,
sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_strike = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_word = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_chunk = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_block = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_sync = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_letter = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_chunk_shake = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_gradient_h_anim_chunk = "Lorem ipsum dolor sit amet consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_sprite_atlas = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_sprite_resource = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_wait = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_shadow = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +local example_entities = "& ' > < "" +local example_zwsp = "L"..ZWSP.."orem ipsum dolor sit a"..ZWSP.."met." + + +local example_english = "All human beings are born free and equal in dignity and rights. They are endowed with reason and conscience and should act towards one another in a spirit of brotherhood." +local example_arabic = "يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق. وقد وهبوا عقلاً وضميرًا وعليهم أن يعامل بعضهم بعضًا بروح الإخاء." +local example_japanese = "すべての人間は、生まれながらにして自由であり、かつ、尊厳と権利と について平等である。人間は、理性と良心とを授けられており、互いに同 胞の精神をもって行動しなければならない。" +local example_chinese = "人人生而自由,在尊严和权利上一律平等。他们赋有理性和良心,并应以兄弟关系的精神相对待。" +local example_chinese_t = "人人生而自由,在尊嚴及權利上一律平等。他們賦有理性和良心,並應以兄弟關係的精神相對待。" +local example_korean = "모든 인간은 태어날 때부터 자유로우며 그 존엄과 권리에 있어 동등하다. 인간은 천부적으로 이성과 양심을 부여받았으며 서로 형제애의 정신으로 행동하여야 한다." +local example_greek = "'Ολοι οι άνθρωποι γεννιούνται ελεύθεροι και ίσοι στην αξιοπρέπεια και τα δικαιώματα. Είναι προικισμένοι με λογική και συνείδηση, και οφείλουν να συμπεριφέρονται μεταξύ τους με πνεύμα αδελφοσύνης." +local example_russian = "Все люди рождаются свободными и равными в своем достоинстве и правах. Они наделены разумом и совестью и должны поступать в отношении друг друга в духе братства." +local example_persian = "تمام افراد بشر آزاد به دنیا می‌آیند و از لحاظ حیثیت و حقوق با هم برابرند. همه دارای عقل و وجدان می‌باشند و باید نسبت به یکدیگر با روح برادری رفتار کنند." +local example_vietnamese = "Tất cả mọi người sinh ra đều được tự do và bình đẳng về nhân phẩm và quyền. Mọi con người đều được tạo hoá ban cho lý trí và lương tâm và cần phải đối xử với nhau trong tình bằng hữu." +local example_german = "Alle Menschen sind frei und gleich an Würde und Rechten geboren. Sie sind mit Vernunft und Gewissen begabt und sollen einander im Geist der Brüderlichkeit begegnen." +local example_emoji = "🥰💀✌️🌴🐢🐐🍄⚽🍻👑📸😬👀🚨🏡🕊️🏆😻🌟🧿🍀🎨🍜" +local example_ = "" +local example_multiple_lanugages = example_arabic.." "..example_english + +-- Does not work yet +local example_thai = "มนุษย์ทั้งหลายเกิดมามีอิสระและเสมอภาคกันในเกียรติศักดิ์และสิทธิ ต่างมีเหตุผลและมโนธรรม และควรปฏิบัติต่อกันด้วยเจตนารมณ์แห่งภราดรภาพ" +local example_hindi = "सभी मनुष्यों को गौरव और अधिकारों के मामले में जन्मजात स्वतन्त्रता और समानता प्राप्त है । उन्हें बुद्धि और अन्तरात्मा की देन प्राप्त है और परस्पर उन्हें भाईचारे के भाव से बर्ताव करना चाहिए ।" + + +local example_arabic_short = "يولد جميع الناس أحراراً ومتساوين في الكرامة والحقوق." +local example_arabic_tashkeel = "يُولَدُ جَمِيعُ النَّاسِ أَحْرَارًا وَمُتَسَاوِينَ فِي الْكَرَامَةِ وَالْحُقُوقِ." +local example_ar_number = "ٱللُّغَةُ ٱلْعَرَبِيَّة هي أكثر اللغات السامية تحدثًا، وإحدى أكثر اللغات انتشاراً في العالم، يتحدثها أكثر من 467 مليون نسمة." +local example_ar_shake = "ٱللُّغَةُ ٱلْعَرَبِيَّة هي أكثر اللغات السامية تحدثًا، وإحدى أكثر اللغات انتشاراً في العالم، يتحدثها أكثر من 467 مليون نسمة." + +local text1 = example_japanese +local text2 = example_arabic + +function init(self) + msg.post(".", "acquire_input_focus") + local options = { + --speed = 5, + fadein_duration = 0.2, + fadeout_y = 16, + fadein_x = -8, + fadein_y = 12, + fadeout_duration = 0.1, + --scale = 1.5, + --scale = vmath.vector3(0.5, 1.0, 1.0), + --alignment = "right", + --balanced = true, + --alignment = "center", + --alignment = "justified", + --width = 100, + --width = 1, + --leading = 1.5, + --tracking = 0.2, + --disable_auto_blocking = true, + fonts = { + arabic = "arabic", + chinese = "chinese", + chinese_traditional = "chinese_traditional", + devanagari = "devanagari", + japanese = "japanese", + korean = "korean", + thai = "thai", + }, + } + self.textbox = typewriter.new(gui.get_node("text1"), options, function() end) + self.textbox:type(text1) + + --gui.set_texture(gui.get_node("box"), "example") + --gui.play_flipbook(gui.get_node("box"), "logo_256") +end + +function update(self, dt) +end + +function on_message(self, message_id, message, sender) +end + +local test = false + +function on_input(self, action_id, action) + if action.pressed then + --if self.textbox:continue() then + --self.textbox:type(text1) + --end + if test then + --self.textbox:type(text1) + self.textbox:animate({scale = 1.0, alignment = "left"}, 0.2, gui.EASING_OUTSINE) + --self.textbox2:type(text2) + test = false + else + --self.textbox:finish() + self.textbox:animate({scale = 1.5, alignment = "right"}, 0.2, gui.EASING_OUTSINE) + --self.textbox2:finish() + test = true + end + end +end diff --git a/example/fonts/arabic.font b/example/fonts/arabic.font new file mode 100644 index 0000000..ef9f80f --- /dev/null +++ b/example/fonts/arabic.font @@ -0,0 +1,20 @@ +font: "/assets/NotoSansArabic-Regular.ttf" +material: "/builtins/fonts/font-df.material" +size: 64 +output_format: TYPE_DISTANCE_FIELD +characters: " !\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" +styles { + name: "default" +} +styles { + name: "link" + markup: "
    " +} +styles { + name: "link:hover" + markup: "" +} +styles { + name: "link:active" + markup: "" +} diff --git a/example/fonts/chinese.font b/example/fonts/chinese.font new file mode 100644 index 0000000..dadd652 --- /dev/null +++ b/example/fonts/chinese.font @@ -0,0 +1,20 @@ +font: "/assets/NotoSansSC-Regular.ttf" +material: "/builtins/fonts/font-df.material" +size: 64 +output_format: TYPE_DISTANCE_FIELD +characters: " !\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" +styles { + name: "default" +} +styles { + name: "link" + markup: "
      " +} +styles { + name: "link:hover" + markup: "" +} +styles { + name: "link:active" + markup: "" +} diff --git a/example/fonts/chinese_traditional.font b/example/fonts/chinese_traditional.font new file mode 100644 index 0000000..4668260 --- /dev/null +++ b/example/fonts/chinese_traditional.font @@ -0,0 +1,20 @@ +font: "/assets/NotoSansTC-Regular.ttf" +material: "/builtins/fonts/font-df.material" +size: 64 +output_format: TYPE_DISTANCE_FIELD +characters: " !\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" +styles { + name: "default" +} +styles { + name: "link" + markup: "
        " +} +styles { + name: "link:hover" + markup: "" +} +styles { + name: "link:active" + markup: "" +} diff --git a/example/fonts/devanagari.font b/example/fonts/devanagari.font new file mode 100644 index 0000000..6314e00 --- /dev/null +++ b/example/fonts/devanagari.font @@ -0,0 +1,20 @@ +font: "/assets/NotoSansDevanagari-Regular.ttf" +material: "/builtins/fonts/font-df.material" +size: 64 +output_format: TYPE_DISTANCE_FIELD +characters: " !\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" +styles { + name: "default" +} +styles { + name: "link" + markup: "
          " +} +styles { + name: "link:hover" + markup: "" +} +styles { + name: "link:active" + markup: "" +} diff --git a/example/fonts/japanese.font b/example/fonts/japanese.font new file mode 100644 index 0000000..974a075 --- /dev/null +++ b/example/fonts/japanese.font @@ -0,0 +1,20 @@ +font: "/assets/NotoSansJP-Regular.ttf" +material: "/builtins/fonts/font-df.material" +size: 64 +output_format: TYPE_DISTANCE_FIELD +characters: " !\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" +styles { + name: "default" +} +styles { + name: "link" + markup: "
            " +} +styles { + name: "link:hover" + markup: "" +} +styles { + name: "link:active" + markup: "" +} diff --git a/example/fonts/korean.font b/example/fonts/korean.font new file mode 100644 index 0000000..e0052de --- /dev/null +++ b/example/fonts/korean.font @@ -0,0 +1,20 @@ +font: "/assets/NotoSansKR-Regular.ttf" +material: "/builtins/fonts/font-df.material" +size: 64 +output_format: TYPE_DISTANCE_FIELD +characters: " !\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" +styles { + name: "default" +} +styles { + name: "link" + markup: "
              " +} +styles { + name: "link:hover" + markup: "" +} +styles { + name: "link:active" + markup: "" +} diff --git a/example/fonts/noto_sans.font b/example/fonts/noto_sans.font new file mode 100644 index 0000000..6987646 --- /dev/null +++ b/example/fonts/noto_sans.font @@ -0,0 +1,20 @@ +font: "/assets/NotoSans-Regular.ttf" +material: "/builtins/fonts/font-df.material" +size: 64 +output_format: TYPE_DISTANCE_FIELD +characters: " !\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" +styles { + name: "default" +} +styles { + name: "link" + markup: "
                " +} +styles { + name: "link:hover" + markup: "" +} +styles { + name: "link:active" + markup: "" +} diff --git a/example/fonts/thai.font b/example/fonts/thai.font new file mode 100644 index 0000000..fb4d465 --- /dev/null +++ b/example/fonts/thai.font @@ -0,0 +1,23 @@ +font: "/assets/NotoSansThai-Regular.ttf" +material: "/builtins/fonts/font-df.material" +size: 64 +output_format: TYPE_DISTANCE_FIELD +all_chars: true +cache_width: 4096 +cache_height: 4096 +characters: " !\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" +styles { + name: "default" +} +styles { + name: "link" + markup: "
                  " +} +styles { + name: "link:hover" + markup: "" +} +styles { + name: "link:active" + markup: "" +} diff --git a/example/main.appmanifest b/example/main.appmanifest new file mode 100644 index 0000000..6dbccd4 --- /dev/null +++ b/example/main.appmanifest @@ -0,0 +1,97 @@ +platforms: + arm64-ios: + context: + excludeLibs: [font] + excludeSymbols: [] + symbols: [] + libs: [font_skribidi, harfbuzz, sheenbidi, unibreak, skribidi] + frameworks: [] + linkFlags: [] + arm64_sim-ios: + context: + excludeLibs: [font] + excludeSymbols: [] + symbols: [] + libs: [font_skribidi, harfbuzz, sheenbidi, unibreak, skribidi] + frameworks: [] + linkFlags: [] + armv7-android: + context: + excludeLibs: [font] + excludeJars: [] + excludeSymbols: [] + symbols: [] + libs: [font_skribidi, harfbuzz, sheenbidi, unibreak, skribidi] + linkFlags: [] + jetifier: true + arm64-android: + context: + excludeLibs: [font] + excludeJars: [] + excludeSymbols: [] + symbols: [] + libs: [font_skribidi, harfbuzz, sheenbidi, unibreak, skribidi] + linkFlags: [] + jetifier: true + x86_64-android: + context: + excludeLibs: [font] + excludeJars: [] + excludeSymbols: [] + symbols: [] + libs: [font_skribidi, harfbuzz, sheenbidi, unibreak, skribidi] + linkFlags: [] + jetifier: true + arm64-osx: + context: + excludeLibs: [font] + excludeSymbols: [] + symbols: [] + libs: [font_skribidi, harfbuzz, sheenbidi, unibreak, skribidi] + frameworks: [] + linkFlags: [] + x86_64-osx: + context: + excludeLibs: [font] + excludeSymbols: [] + symbols: [] + libs: [font_skribidi, harfbuzz, sheenbidi, unibreak, skribidi] + frameworks: [] + linkFlags: [] + x86_64-linux: + context: + excludeLibs: [font] + excludeSymbols: [] + symbols: [] + libs: [font_skribidi, harfbuzz, sheenbidi, unibreak, skribidi] + linkFlags: [] + arm64-linux: + context: + excludeLibs: [font] + excludeSymbols: [] + symbols: [] + libs: [font_skribidi, harfbuzz, sheenbidi, unibreak, skribidi] + linkFlags: [] + x86_64-win32: + context: + excludeLibs: [font] + excludeSymbols: [] + symbols: [] + libs: [font_skribidi, libharfbuzz, libsheenbidi, libunibreak, libskribidi] + linkFlags: [] + wasm-web: + context: + excludeLibs: [font] + excludeJsLibs: [] + excludeSymbols: [] + symbols: [] + libs: [font_skribidi, harfbuzz, sheenbidi, unibreak, skribidi] + linkFlags: [] + wasm_pthread-web: + context: + excludeLibs: [font] + excludeJsLibs: [] + excludeSymbols: [] + symbols: [] + libs: [font_skribidi, harfbuzz, sheenbidi, unibreak, skribidi] + linkFlags: [] diff --git a/game.project b/game.project new file mode 100644 index 0000000..d9f23d4 --- /dev/null +++ b/game.project @@ -0,0 +1,27 @@ +[bootstrap] +main_collection = /example/example.collectionc + +[display] +width = 960 +height = 640 +high_dpi = 1 + +[android] +input_method = HiddenInputField + +[physics] +gravity_y = -1000.0 +scale = 0.01 + +[html5] +scale_mode = stretch + +[project] +title = test + +[native_extension] +app_manifest = /example/main.appmanifest + +[font] +runtime_generation = 1 + diff --git a/input/game.input_binding b/input/game.input_binding new file mode 100644 index 0000000..8ed1d4e --- /dev/null +++ b/input/game.input_binding @@ -0,0 +1,4 @@ +mouse_trigger { + input: MOUSE_BUTTON_1 + action: "touch" +} diff --git a/typewriter/typewriter.lua b/typewriter/typewriter.lua new file mode 100644 index 0000000..817a081 --- /dev/null +++ b/typewriter/typewriter.lua @@ -0,0 +1,3456 @@ +-------------------------------------------------------------------------------- +-- 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] = "" + 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] = 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] = "" 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