/**
 * @file
 * @brief Hierarchical layout system.
**/

#include "AppHdr.h"

#include <numeric>
#include <stack>
#include <chrono>
#include <cwctype>

#include "ui.h"
#include "cio.h"
#include "macro.h"
#include "state.h"
#include "tileweb.h"
#include "unicode.h"
#include "libutil.h"
#include "windowmanager.h"
#include "ui-scissor.h"

#ifdef USE_TILE_LOCAL
# include "glwrapper.h"
# include "tilebuf.h"
# include "tilepick-p.h"
# include "tile-player-flag-cut.h"
#else
# if defined(UNIX) || defined(TARGET_COMPILER_MINGW)
#  include <unistd.h>
# endif
// needed for usleep replacement on platforms that don't support it
# include "syscalls.h"
# include "output.h"
# include "stringutil.h"
# include "view.h"
#endif

#ifdef USE_TILE_WEB
# include <unordered_map>
#endif

namespace ui {

#ifndef USE_TILE_LOCAL
static void clear_text_region(Region region, COLOURS bg);
#endif

// helper functions for instantiating ui classes
command_type get_action(int keyin)
{
    // TODO: support other menu commands (perhaps at the ui level?)
    static const unordered_set<command_type, std::hash<int>> handled =
        { CMD_MENU_EXIT };
    command_type c = key_to_command(keyin, KMC_MENU);
    if (handled.count(c))
        return c;
    return CMD_NO_CMD;
}

bool key_exits_popup(int keyin, bool extended)
{
    return get_action(keyin) == CMD_MENU_EXIT
        || extended && keyin == CK_ENTER; // ugh
}


static struct UIRoot
{
public:
    void push_child(shared_ptr<Widget> child, KeymapContext km);
    void pop_child();

    shared_ptr<Widget> top_child()
    {
        const auto sz = m_root.num_children();
        return sz > 0 ? m_root.get_child(sz-1) : nullptr;
    }

    size_t num_children() const
    {
        return m_root.num_children();
    };

    bool widget_is_in_layout(const Widget* w)
    {
        for (; w; w = w->_get_parent())
            if (w == &m_root)
                return true;
        return false;
    }

    void resize(int w, int h);
    void layout();
    void render();
    void swap_buffers();

    bool on_event(wm_event& event);
    bool deliver_event(Event& event);

    void queue_layout()
    {
        m_needs_layout = true;
    }

    void expose_region(Region r)
    {
        if (r.empty())
            return;
        if (m_dirty_region.empty())
            m_dirty_region = r;
        else
            m_dirty_region = m_dirty_region.aabb_union(r);
        needs_paint = true;
    }

    bool needs_paint;
    bool needs_swap;

#ifdef DEBUG
    bool debug_draw = false;
    bool debug_on_event(const wm_event& event);
    void debug_render();
#endif

    struct LayoutInfo
    {
        KeymapContext keymap = KMC_NONE;
        Widget* current_focus = nullptr;
        Widget* default_focus = nullptr;
        int generation_id = 0;
    };

    LayoutInfo state;  // current keymap and focus info
    int next_generation_id = 1;

    vector<int> cutoff_stack;
    vector<Widget*> default_focus_stack;
    vector<Widget*> focus_order;

    void update_focus_order();
    void focus_next();
    void focus_prev();

    struct RestartAllocation {};

#ifdef USE_TILE_LOCAL
    vector<Widget*> hover_path;

    void update_hover_path();
    void update_hover_path_for_widget(Widget* widget);
    void send_mouse_enter_leave_events(
            const vector<Widget*>& old_hover_path,
            const vector<Widget*>& new_hover_path);
#endif

#ifdef USE_TILE_WEB
    void update_synced_widgets();
    unordered_map<string, Widget*> synced_widgets;
    bool receiving_ui_state = false;
    void sync_state();
    void recv_ui_state_change(const JsonNode *state);
#endif

    coord_def cursor_pos;

protected:
    int m_w, m_h;
    Region m_region;
    Region m_dirty_region;
    Stack m_root;
    bool m_needs_layout{false};
    bool m_changed_layout_since_click = false;
    vector<LayoutInfo> saved_layout_info;
} ui_root;

static ScissorStack scissor_stack;

struct Widget::slots Widget::slots = {};

Event::Event(Event::Type _type) : m_type(_type)
{
}

KeyEvent::KeyEvent(Event::Type _type, const wm_keyboard_event& wm_ev) : Event(_type)
{
    m_key = wm_ev.keysym.sym;
}

#ifdef USE_TILE_LOCAL
MouseEvent::MouseEvent(Event::Type _type, const wm_mouse_event& wm_ev)
    : Event(_type),
        m_x(0), m_y(0)
{
    m_button = static_cast<MouseEvent::Button>(wm_ev.button);
    // XXX: is it possible that the cursor has moved since the SDL event fired?
    if (wm)
        wm->get_mouse_state(&m_x, &m_y);
    m_wheel_dx = _type == MouseWheel ? wm_ev.px : 0;
    m_wheel_dy = _type == MouseWheel ? wm_ev.py : 0;
}
#endif

FocusEvent::FocusEvent(Event::Type typ) : Event(typ)
{
}

ActivateEvent::ActivateEvent() : Event(Event::Type::Activate)
{
}

Widget::~Widget()
{
    Widget::slots.event.remove_by_target(this);
    Widget::slots.hotkey.remove_by_target(this);
    if (m_parent && get_focused_widget() == this)
        set_focused_widget(nullptr);
    _set_parent(nullptr);
    erase_val(ui_root.focus_order, this);
#ifdef USE_TILE_WEB
    if (!m_sync_id.empty())
        ui_root.synced_widgets.erase(m_sync_id);
#endif
}

void Widget::_emit_layout_pop()
{
    Widget::slots.layout_pop.emit(this);
    Widget::slots.layout_pop.remove_by_target(this);
}

bool Widget::on_event(const Event& event)
{
    return Widget::slots.event.emit(this, event);
}

void OverlayWidget::allocate_region(Region)
{
    // Occupies 0 space, and therefore will never clear the screen.
    m_region = {0, 0, 0, 0};
    _allocate_region();
}

void OverlayWidget::_expose()
{
    // forcibly ensure that renders will be called. This sidesteps the region
    // based code for deciding whether anything should be rendered, and leaves
    // it up to the OverlayWidget.
    ui_root.needs_paint = true;
}

shared_ptr<Widget> ContainerVec::get_child_at_offset(int x, int y)
{
    for (shared_ptr<Widget>& child : m_children)
        if (child->get_region().contains_point(x, y))
            return child;
    return nullptr;
}

shared_ptr<Widget> Bin::get_child_at_offset(int x, int y)
{
    if (m_child && m_child->get_region().contains_point(x, y))
        return m_child;
    return nullptr;
}

void Bin::set_child(shared_ptr<Widget> child)
{
    child->_set_parent(this);
    m_child = std::move(child);
    _invalidate_sizereq();
}

void Widget::render()
{
    if (m_visible)
        _render();
}

SizeReq Widget::get_preferred_size(Direction dim, int prosp_width)
{
    ASSERT((dim == HORZ) == (prosp_width == -1));

    if (!m_visible)
        return { 0, 0 };

    if (cached_sr_valid[dim] && (!dim || cached_sr_pw == prosp_width))
        return cached_sr[dim];

    prosp_width = dim ? prosp_width - margin.right - margin.left : prosp_width;
    SizeReq ret = _get_preferred_size(dim, prosp_width);
    ASSERT(ret.min <= ret.nat);

    // Order is important: max sizes limit expansion, and don't include margins
    const bool expand = dim ? expand_v : expand_h;
    const bool shrink = dim ? shrink_v : shrink_h;
    ASSERT(!(expand && shrink));
    constexpr int ui_expand_sz = 0xffffff;

    if (expand)
        ret.nat = ui_expand_sz;
    else if (shrink)
        ret.nat = ret.min;

    int& _min_size = dim ? m_min_size.height : m_min_size.width;
    int& _max_size = dim ? m_max_size.height : m_max_size.width;

    ASSERT(_min_size <= _max_size);
    ret.min = max(ret.min, _min_size);
    ret.nat = min(ret.nat, max(_max_size, ret.min));
    ret.nat = max(ret.nat, ret.min);
    ASSERT(ret.min <= ret.nat);

    const int m = dim ? margin.top + margin.bottom : margin.left + margin.right;
    ret.min += m;
    ret.nat += m;

    ret.nat = min(ret.nat, ui_expand_sz);

    cached_sr_valid[dim] = true;
    cached_sr[dim] = ret;
    if (dim)
        cached_sr_pw = prosp_width;

    return ret;
}

void Widget::allocate_region(Region region)
{
    if (!m_visible)
        return;

    Region new_region = {
        region.x + margin.left,
        region.y + margin.top,
        region.width - margin.left - margin.right,
        region.height - margin.top - margin.bottom,
    };

    if (m_region == new_region && !alloc_queued)
        return;
    ui_root.expose_region(m_region);
    ui_root.expose_region(new_region);
    m_region = new_region;
    alloc_queued = false;

    ASSERT(m_region.width >= 0);
    ASSERT(m_region.height >= 0);
    _allocate_region();
}

SizeReq Widget::_get_preferred_size(Direction, int)
{
    return { 0, 0xffffff };
}

void Widget::_allocate_region()
{
}

/**
 * Determine whether a widget contains the given widget.
 *
 * @param child   The other widget.
 * @return        True if the other widget is a descendant of this widget.
 */
bool Widget::is_ancestor_of(const shared_ptr<Widget>& other)
{
    for (Widget* w = other.get(); w; w = w->_get_parent())
        if (w == this)
            return true;
    return false;
}

void Widget::_set_parent(Widget* p)
{
    m_parent = p;
#ifdef USE_TILE_LOCAL
    ui_root.update_hover_path_for_widget(this);
#endif
}

/**
 * Unparent a widget.
 *
 * This function verifies that a widget has the correct parent before orphaning.
 * Intended for use in container widget destructors.
 *
 * @param child   The child widget to unparent.
 */
void Widget::_unparent(shared_ptr<Widget>& child)
{
    if (child->m_parent == this)
        child->_set_parent(nullptr);
}

void Widget::_invalidate_sizereq(bool immediate)
{
    for (auto w = this; w; w = w->m_parent)
        fill(begin(w->cached_sr_valid), end(w->cached_sr_valid), false);
    if (immediate)
        ui_root.queue_layout();
}

void Widget::_queue_allocation(bool immediate)
{
    for (auto w = this; w && !w->alloc_queued; w = w->m_parent)
        w->alloc_queued = true;
    if (immediate)
        ui_root.queue_layout();
}

void Widget::_expose()
{
    ui_root.expose_region(m_region);
}

void Widget::set_visible(bool visible)
{
    if (m_visible == visible)
        return;
    m_visible = visible;
    _invalidate_sizereq();
}

void Widget::add_internal_child(shared_ptr<Widget> child)
{
    child->_set_parent(this);
    m_internal_children.emplace_back(std::move(child));
}

void Widget::set_sync_id(string id)
{
    ASSERT(!_get_parent()); // synced widgets are collected on layout push/pop
    m_sync_id = id;
}

#ifdef USE_TILE_WEB
void Widget::sync_save_state()
{
}

void Widget::sync_load_state(const JsonNode *)
{
}

void Widget::sync_state_changed()
{
    if (m_sync_id.empty())
        return;
    const auto& top = ui_root.top_child();
    if (!top || !top->is_ancestor_of(get_shared()))
        return;
    tiles.json_open_object();
    sync_save_state();
    tiles.json_write_string("msg", "ui-state-sync");
    tiles.json_write_string("widget_id", m_sync_id);
    tiles.json_write_bool("from_webtiles", ui_root.receiving_ui_state);
    tiles.json_write_int("generation_id", ui_root.state.generation_id);
    tiles.json_close_object();
    tiles.finish_message();
}
#endif

void Box::add_child(shared_ptr<Widget> child)
{
    child->_set_parent(this);
    m_children.push_back(std::move(child));
    _invalidate_sizereq();
}

void Box::_render()
{
    for (auto const& child : m_children)
        child->render();
}

vector<int> Box::layout_main_axis(vector<SizeReq>& ch_psz, int main_sz)
{
    // find the child sizes on the main axis
    vector<int> ch_sz(m_children.size());

    int extra = main_sz;
    for (size_t i = 0; i < m_children.size(); i++)
    {
        ch_sz[i] = ch_psz[i].min;
        extra -= ch_psz[i].min;
        if (align_main == Align::STRETCH)
            ch_psz[i].nat = INT_MAX;
    }
    ASSERT(extra >= 0);

    while (extra > 0)
    {
        int sum_flex_grow = 0, remainder = 0;
        for (size_t i = 0; i < m_children.size(); i++)
            sum_flex_grow += ch_sz[i] < ch_psz[i].nat ? m_children[i]->flex_grow : 0;
        if (!sum_flex_grow)
            break;

        // distribute space to children, based on flex_grow
        for (size_t i = 0; i < m_children.size(); i++)
        {
            float efg = ch_sz[i] < ch_psz[i].nat ? m_children[i]->flex_grow : 0;
            int ch_extra = extra * efg / sum_flex_grow;
            ASSERT(ch_sz[i] <= ch_psz[i].nat);
            int taken = min(ch_extra, ch_psz[i].nat - ch_sz[i]);
            ch_sz[i] += taken;
            remainder += ch_extra - taken;
        }
        extra = remainder;
    }

    return ch_sz;
}

vector<int> Box::layout_cross_axis(vector<SizeReq>& ch_psz, int cross_sz)
{
    vector<int> ch_sz(m_children.size());

    for (size_t i = 0; i < m_children.size(); i++)
    {
        const bool stretch = align_cross == STRETCH;
        ch_sz[i] = stretch ? cross_sz : min(max(ch_psz[i].min, cross_sz), ch_psz[i].nat);
    }

    return ch_sz;
}

SizeReq Box::_get_preferred_size(Direction dim, int prosp_width)
{
    vector<SizeReq> sr(m_children.size());

    // Get preferred widths
    for (size_t i = 0; i < m_children.size(); i++)
        sr[i] = m_children[i]->get_preferred_size(Widget::HORZ, -1);

    if (dim)
    {
        // Get actual widths
        vector<int> cw = horz ? layout_main_axis(sr, prosp_width) : layout_cross_axis(sr, prosp_width);

        // Get preferred heights
        for (size_t i = 0; i < m_children.size(); i++)
            sr[i] = m_children[i]->get_preferred_size(Widget::VERT, cw[i]);
    }

    // find sum/max of preferred sizes, as appropriate
    bool main_axis = dim == !horz;
    SizeReq r = { 0, 0 };
    for (auto const& c : sr)
    {
        r.min = main_axis ? r.min + c.min : max(r.min, c.min);
        r.nat = main_axis ? r.nat + c.nat : max(r.nat, c.nat);
    }
    return r;
}

void Box::_allocate_region()
{
    vector<SizeReq> sr(m_children.size());

    // Get preferred widths
    for (size_t i = 0; i < m_children.size(); i++)
        sr[i] = m_children[i]->get_preferred_size(Widget::HORZ, -1);

    // Get actual widths
    vector<int> cw = horz ? layout_main_axis(sr, m_region.width) : layout_cross_axis(sr, m_region.width);

    // Get preferred heights
    for (size_t i = 0; i < m_children.size(); i++)
        sr[i] = m_children[i]->get_preferred_size(Widget::VERT, cw[i]);

    // Get actual heights
    vector<int> ch = horz ? layout_cross_axis(sr, m_region.height) : layout_main_axis(sr, m_region.height);

    auto const &m = horz ? cw : ch;
    int extra_main_space = (horz ? m_region.width : m_region.height) - accumulate(m.begin(), m.end(), 0);
    ASSERT(extra_main_space >= 0);

    // main axis offset
    int mo;
    switch (align_main)
    {
        case Widget::START:   mo = 0; break;
        case Widget::CENTER:  mo = extra_main_space/2; break;
        case Widget::END:     mo = extra_main_space; break;
        case Widget::STRETCH: mo = 0; break;
        default: ASSERT(0);
    }
    int ho = m_region.x + (horz ? mo : 0);
    int vo = m_region.y + (!horz ? mo : 0);

    Region cr = {ho, vo, 0, 0};
    for (size_t i = 0; i < m_children.size(); i++)
    {
        // cross axis offset
        int extra_cross_space = horz ? m_region.height - ch[i] : m_region.width - cw[i];

        const Align child_align = align_cross;
        int xo;
        switch (child_align)
        {
            case Widget::START:   xo = 0; break;
            case Widget::CENTER:  xo = extra_cross_space/2; break;
            case Widget::END:     xo = extra_cross_space; break;
            case Widget::STRETCH: xo = 0; break;
            default: ASSERT(0);
        }

        int& cr_cross_offset = horz ? cr.y : cr.x;
        int& cr_cross_size = horz ? cr.height : cr.width;

        cr_cross_offset = (horz ? vo : ho) + xo;
        cr.width = cw[i];
        cr.height = ch[i];
        if (child_align == STRETCH)
            cr_cross_size = (horz ? ch : cw)[i];
        m_children[i]->allocate_region(cr);

        int& cr_main_offset = horz ? cr.x : cr.y;
        int& cr_main_size = horz ? cr.width : cr.height;
        cr_main_offset += cr_main_size;
    }
}

Text::Text()
{
#ifdef USE_TILE_LOCAL
    set_font(tiles.get_crt_font());
#endif
}

void Text::set_text(const formatted_string &fs)
{
    if (fs == m_text)
        return;
    m_text.clear();
    m_text += fs;
    _invalidate_sizereq();
    _expose();
    m_wrapped_size = Size(-1);
    _queue_allocation();
}

#ifdef USE_TILE_LOCAL
void Text::set_font(FontWrapper *font)
{
    if (in_headless_mode())
        return;
    ASSERT(font);
    m_font = font;
    _queue_allocation();
}
#endif

void Text::set_highlight_pattern(string pattern, bool line)
{
    hl_pat = pattern;
    hl_line = line;
    _expose();
}

void Text::wrap_text_to_size(int width, int height)
{
    // don't recalculate if the previous calculation imposed no constraints
    // on the text, and the new calculation would be the same or greater in
    // size. This can happen through a sequence of _get_preferred_size calls
    // for example.
    if (m_wrapped_size.is_valid())
    {
        const bool cached_width_max = m_wrapped_sizereq.width <= 0
                        || m_wrapped_sizereq.width > m_wrapped_size.width;
        const bool cached_height_max = m_wrapped_sizereq.height <= 0
                        || m_wrapped_sizereq.height > m_wrapped_size.height;
        if ((width == m_wrapped_size.width || cached_width_max && (width <= 0 || width >= m_wrapped_size.width))
            && (height == m_wrapped_size.height || cached_height_max && (height <= 0 || height >= m_wrapped_size.height)))
        {
            return;
        }
    }

    m_wrapped_sizereq = Size(width, height);

    height = height ? height : 0xfffffff;

#ifdef USE_TILE_LOCAL
    if (wrap_text || ellipsize)
        m_text_wrapped = m_font->split(m_text, width, height);
    else
        m_text_wrapped = m_text;

    m_brkpts.clear();
    m_brkpts.emplace_back(brkpt({0, 0}));
    unsigned tally = 0, acc = 0;
    for (unsigned i = 0; i < m_text_wrapped.ops.size(); i++)
    {
        formatted_string::fs_op &op = m_text_wrapped.ops[i];
        if (op.type != FSOP_TEXT)
            continue;
        if (acc > 0)
        {
            m_brkpts.emplace_back(brkpt({i, tally}));
            acc = 0;
        }
        unsigned n = count(op.text.begin(), op.text.end(), '\n');
        acc += n;
        tally += n;
    }

    // if the request was to figure out the height, record the height found
    m_wrapped_size.height = m_font->string_height(m_text_wrapped);
    m_wrapped_size.width = m_font->string_width(m_text_wrapped);
#else
    m_wrapped_lines.clear();
    formatted_string::parse_string_to_multiple(m_text.to_colour_string(), m_wrapped_lines, width);
    // add ellipsis to last line of text if necessary
    if (height < (int)m_wrapped_lines.size())
    {
        auto& last_line = m_wrapped_lines[height-1], next_line = m_wrapped_lines[height];
        last_line += " ";
        last_line += next_line;
        last_line = last_line.chop(width-2);
        last_line += "..";
        m_wrapped_lines.resize(height);
    }
    if (m_wrapped_lines.empty())
        m_wrapped_lines.emplace_back("");

    m_wrapped_size.height = m_wrapped_lines.size();
    if (width <= 0)
    {
        // only bother recalculating if there was no requested width --
        // parse_string_to_multiple will exactly obey any explicit width value
        int max_width = 0;
        for (auto &fs : m_wrapped_lines)
            max_width = max(max_width, fs.width());
        m_wrapped_size.width = max_width;
    }
    else
        m_wrapped_size.width = width;
#endif
}

static vector<size_t> _find_highlights(const string& haystack, const string& needle, int a, int b)
{
    vector<size_t> highlights;
    size_t pos = haystack.find(needle, max(a-(int)needle.size()+1, 0));
    while (pos != string::npos && pos < b+needle.size()-1)
    {
        highlights.push_back(pos);
        pos = haystack.find(needle, pos+1);
    }
    return highlights;
}

void Text::_render()
{
    Region region = m_region;
    region = region.aabb_intersect(scissor_stack.top());
    if (region.width <= 0 || region.height <= 0)
        return;

    wrap_text_to_size(m_region.width, m_region.height);

#ifdef USE_TILE_LOCAL
    const int dev_line_height = m_font->char_height(false);
    const int line_min_pos = display_density.logical_to_device(
                                                    region.y - m_region.y);
    const int line_max_pos = display_density.logical_to_device(
                                        region.y + region.height - m_region.y);
    const int line_min = line_min_pos / dev_line_height;
    const int line_max = line_max_pos / dev_line_height;

    // find the earliest and latest ops in the string that could be displayed
    // in the currently visible region, as well as the line offset of the
    // earliest.
    int line_off = 0;
    int ops_min = 0, ops_max = m_text_wrapped.ops.size();
    {
        int i = 1;
        for (; i < (int) m_brkpts.size(); i++)
            if (static_cast<int>(m_brkpts[i].line) >= line_min)
            {
                ops_min = m_brkpts[i - 1].op;
                line_off = m_brkpts[i - 1].line;
                break;
            }
        for (; i < (int)m_brkpts.size(); i++)
            if (static_cast<int>(m_brkpts[i].line) > line_max)
            {
                ops_max = m_brkpts[i].op;
                break;
            }
    }

    // the slice of the contained string that will be displayed
    formatted_string slice;
    slice.ops = vector<formatted_string::fs_op>(
        m_text_wrapped.ops.begin() + ops_min,
        m_text_wrapped.ops.begin() + ops_max);

    // TODO: this is really complicated, can it be refactored? Does it
    // really need to iterate over the formatted_text slices? I'm not sure
    // the formatting ever matters for highlighting locations.
    if (!hl_pat.empty())
    {
        const auto& full_text = m_text.tostring();

        // need to find the byte ranges in full_text that our slice corresponds to
        // note that the indexes are the same in both m_text and m_text_wrapped
        // only because wordwrapping only replaces ' ' with '\n': in other words,
        // this is fairly brittle
        ASSERT(full_text.size() == m_text_wrapped.tostring().size());
        // index of the first and last ops that are being displayed
        int begin_idx = ops_min == 0 ?
                        0 :
                        m_text_wrapped.tostring(0, ops_min - 1).size();
        int end_idx = begin_idx
                        + m_text_wrapped.tostring(ops_min, ops_max-1).size();

        vector<size_t> highlights = _find_highlights(full_text, hl_pat,
                                                     begin_idx, end_idx);

        int ox = m_region.x;
        const int oy = display_density.logical_to_device(m_region.y) +
                                                dev_line_height * line_off;
        size_t lacc = 0; // the start char of the current op relative to region
        size_t line = 0; // the line we are at relative to the region

        bool inside = false;
        // Iterate over formatted_string op slices, looking for highlight
        // sequences. Highlight sequences may span multiple op slices, hence
        // some of the complexity here.
        // All the y math in this section needs to be done in device pixels,
        // in order to handle fractional advances.
        set<size_t> block_lines;
        for (unsigned i = 0; i < slice.ops.size() && !highlights.empty(); i++)
        {
            const auto& op = slice.ops[i];
            if (op.type != FSOP_TEXT)
                continue;
            size_t oplen = op.text.size();

            // dimensions in chars of the pattern relative to current op
            size_t start = highlights[0] - begin_idx - lacc;
            size_t end = highlights[0] - begin_idx - lacc + hl_pat.size();

            size_t op_line_start = begin_idx + lacc > 0
                        ? full_text.rfind('\n', begin_idx + lacc - 1)
                        : string::npos;
            op_line_start = op_line_start == string::npos
                        ? 0
                        : op_line_start + 1;
            string line_before_op = full_text.substr(op_line_start,
                                            begin_idx + lacc - op_line_start);
            // pixel positions for the current op
            size_t op_x = m_font->string_width(line_before_op.c_str());
            const size_t op_y =
                            m_font->string_height(line_before_op.c_str(), false)
                            - dev_line_height;

            // positions in device pixels to highlight relative to current op
            size_t sx = 0, ex = m_font->string_width(op.text.c_str());
            size_t sy = 0, ey = dev_line_height;

            bool started = false; // does the highlight start in the current op?
            bool ended = false;   // does the highlight end in the current op?

            if (start < oplen) // assume start is unsigned and so >=0
            {
                // hacky: reset op x to 0 if we've hit a linebreak in the op
                // before start. This is to handle cases where the op starts
                // midline, but the pattern is after a linebreak in the op.
                const size_t linebreak_in_op = op.text.find("\n");
                if (start > linebreak_in_op)
                    op_x = 0;
                // start position is somewhere in the current op
                const string before = full_text.substr(begin_idx + lacc, start);
                sx = m_font->string_width(before.c_str());
                sy = m_font->string_height(before.c_str(), false)
                                                            - dev_line_height;
                started = true;
            }
            if (end <= oplen) // assume end is unsigned and so >=0
            {
                const string to_end = full_text.substr(begin_idx + lacc, end);
                ex = m_font->string_width(to_end.c_str());
                ey = m_font->string_height(to_end.c_str(), false);
                ended = true;
            }

            if (started || ended || inside)
            {
                m_hl_buf.clear();
                // TODO: as far as I can tell, the above code never produces
                // multi-line spans for this to iterate over...
                for (size_t y = oy + op_y + line + sy;
                            y < oy + op_y + line + ey;
                            y += dev_line_height)
                {
                    if (block_lines.count(y)) // kind of brittle...
                        continue;
                    if (hl_line)
                    {
                        block_lines.insert(y);
                        m_hl_buf.add(region.x,
                            display_density.device_to_logical(y),
                            region.x + region.width,
                            display_density.device_to_logical(y
                                                            + dev_line_height),
                            VColour(255, 255, 0, 50));
                    }
                    else
                    {
                        m_hl_buf.add(ox + op_x + sx,
                            display_density.device_to_logical(y),
                            ox + op_x + ex,
                            display_density.device_to_logical(y
                                                            + dev_line_height),
                            VColour(255, 255, 0, 50));
                    }
                }
                m_hl_buf.draw();
            }
            inside = !ended && (inside || started);

            if (ended)
            {
                highlights.erase(highlights.begin() + 0);
                i--;
            }
            else
            {
                lacc += oplen;
                line += m_font->string_height(op.text.c_str(), false)
                                                            - dev_line_height;
            }
        }
    }

    // XXX: should be moved into a new function render_formatted_string()
    // in FTFontWrapper, that, like render_textblock(), would automatically
    // handle swapping atlas glyphs as necessary.
    FontBuffer m_font_buf(m_font);
    m_font_buf.add(slice, m_region.x, m_region.y +
            display_density.device_to_logical(dev_line_height * line_off));
    m_font_buf.draw();
#else
    const auto& lines = m_wrapped_lines;
    vector<size_t> highlights;
    int begin_idx = 0;

    clear_text_region(m_region, m_bg_colour);

    if (!hl_pat.empty())
    {
        for (int i = 0; i < region.y-m_region.y; i++)
            begin_idx += m_wrapped_lines[i].tostring().size()+1;
        int end_idx = begin_idx;
        for (int i = region.y-m_region.y; i < region.y-m_region.y+region.height; i++)
            end_idx += m_wrapped_lines[i].tostring().size()+1;
        highlights = _find_highlights(m_text.tostring(), hl_pat, begin_idx, end_idx);
    }

    unsigned int hl_idx = 0;
    for (size_t i = 0; i < min(lines.size(), (size_t)region.height); i++)
    {
        cgotoxy(region.x+1, region.y+1+i);
        formatted_string line = lines[i+region.y-m_region.y];
        int end_idx = begin_idx + line.tostring().size();

        // convert highlights on this line to a list of line cuts
        vector<size_t> cuts = {0};
        for (; hl_idx < highlights.size() && (int)highlights[hl_idx] < end_idx; hl_idx++)
        {
            ASSERT(highlights[hl_idx]+hl_pat.size() >= (size_t)begin_idx);
            int la = max((int)highlights[hl_idx] - begin_idx, 0);
            int lb = min(highlights[hl_idx]+hl_pat.size() - begin_idx, (size_t)end_idx - begin_idx);
            ASSERT(la < lb);
            cuts.push_back(la);
            cuts.push_back(lb);
        }
        cuts.push_back(end_idx - begin_idx);

        // keep the last highlight if it extend into the next line
        if (hl_idx && highlights[hl_idx-1]+hl_pat.size() > (size_t)end_idx)
            hl_idx--;

        // cut the line, and highlight alternate segments
        formatted_string out;
        for (size_t j = 0; j+1 < cuts.size(); j++)
        {
            formatted_string slice = line.substr_bytes(cuts[j], cuts[j+1]-cuts[j]);
            if (j%2)
            {
                out.textcolour(WHITE);
                out.cprintf("%s", slice.tostring().c_str());
            }
            else
                out += slice;
        }
        out.chop(region.width).display(0);

        begin_idx = end_idx + 1; // +1 is for the newline
    }
#endif
}

SizeReq Text::_get_preferred_size(Direction dim, int prosp_width)
{
#ifdef USE_TILE_LOCAL
    if (!dim)
    {
        int w = m_font->string_width(m_text);
        // XXX: should be width of '..', unless string itself is shorter than '..'
        static constexpr int min_ellipsized_width = 0;
        static constexpr int min_wrapped_width = 0; // XXX: should be width of longest word
        return { ellipsize ? min_ellipsized_width : wrap_text ? min_wrapped_width : w, w };
    }
    else
    {
        wrap_text_to_size(prosp_width, 0);
        int height = m_font->string_height(m_text_wrapped);
        return { ellipsize ? (int)m_font->char_height() : height, height };
    }
#else
    if (!dim)
    {
        int w = 0, line_w = 0;
        for (auto const& ch : m_text.tostring())
        {
            w = ch == '\n' ? max(w, line_w) : w;
            line_w = ch == '\n' ? 0 : line_w+1;
        }
        w = max(w, line_w);

        // XXX: should be width of '..', unless string itself is shorter than '..'
        static constexpr int min_ellipsized_width = 0;
        static constexpr int min_wrapped_width = 0; // XXX: should be char width of longest word in text
        return { ellipsize ? min_ellipsized_width : wrap_text ? min_wrapped_width : w, w };
    }
    else
    {
        wrap_text_to_size(prosp_width, 0);
        int height = m_wrapped_lines.size();
        return { ellipsize ? 1 : height, height };
    }
#endif
}

void Text::_allocate_region()
{
    wrap_text_to_size(m_region.width, m_region.height);
}

#ifndef USE_TILE_LOCAL
void Text::set_bg_colour(COLOURS colour)
{
    m_bg_colour = colour;
    _expose();
}
#endif

void Image::set_tile(tile_def tile)
{
#ifdef USE_TILE
    m_tile = tile;
#ifdef USE_TILE_LOCAL
    if (in_headless_mode())
        return;
    const tile_info &ti = tiles.get_image_manager()->tile_def_info(m_tile);
    m_tw = ti.width;
    m_th = ti.height;
    _invalidate_sizereq();
#endif
#else
    UNUSED(tile);
#endif
}

void Image::_render()
{
#ifdef USE_TILE_LOCAL
    scissor_stack.push(m_region);
    TileBuffer tb;
    tb.set_tex(&tiles.get_image_manager()->get_texture(
                                            get_tile_texture(m_tile.tile)));

    for (int y = m_region.y; y < m_region.y+m_region.height; y+=m_th)
        for (int x = m_region.x; x < m_region.x+m_region.width; x+=m_tw)
            tb.add(m_tile.tile, x, y, 0, 0, false, m_th, 1.0, 1.0);

    tb.draw();
    tb.clear();
    scissor_stack.pop();
#endif
}

SizeReq Image::_get_preferred_size(Direction dim, int /*prosp_width*/)
{
#ifdef USE_TILE_LOCAL
    return {
        // expand takes precedence over shrink for historical reasons
        dim ? (shrink_v ? 0 : m_th) : (shrink_h ? 0 : m_tw),
        dim ? (shrink_v ? 0 : m_th) : (shrink_h ? 0 : m_tw)
    };
#else
    UNUSED(dim);
    return { 0, 0 };
#endif
}

void Stack::add_child(shared_ptr<Widget> child)
{
    child->_set_parent(this);
    m_children.push_back(std::move(child));
    _invalidate_sizereq();
    _queue_allocation();
}

void Stack::pop_child()
{
    if (!m_children.size())
        return;
    m_children.pop_back();
    _invalidate_sizereq();
    _queue_allocation();
}

shared_ptr<Widget> Stack::get_child_at_offset(int x, int y)
{
    if (m_children.size() == 0)
        return nullptr;
    bool inside = m_children.back()->get_region().contains_point(x, y);
    return inside ? m_children.back() : nullptr;
}

void Stack::_render()
{
    for (auto const& child : m_children)
        child->render();
}

SizeReq Stack::_get_preferred_size(Direction dim, int prosp_width)
{
    SizeReq r = { 0, 0 };
    for (auto const& child : m_children)
    {
        SizeReq c = child->get_preferred_size(dim, prosp_width);
        r.min = max(r.min, c.min);
        r.nat = max(r.nat, c.nat);
    }
    return r;
}

void Stack::_allocate_region()
{
    for (auto const& child : m_children)
    {
        Region cr = m_region;
        SizeReq pw = child->get_preferred_size(Widget::HORZ, -1);
        cr.width = min(max(pw.min, m_region.width), pw.nat);
        SizeReq ph = child->get_preferred_size(Widget::VERT, cr.width);
        cr.height = min(max(ph.min, m_region.height), ph.nat);
        child->allocate_region(cr);
    }
}

void Switcher::add_child(shared_ptr<Widget> child)
{
    // TODO XXX: if there's a focused widget
    // - it must be in the current top child
    // - unfocus it before we
    child->_set_parent(this);
    m_children.push_back(std::move(child));
    _invalidate_sizereq();
    _queue_allocation();
}

int& Switcher::current()
{
    // TODO XXX: we need to update the focused widget
    // so we need an API change
    _expose();
    return m_current;
}

shared_ptr<Widget> Switcher::current_widget()
{
    if (m_children.size() == 0)
        return nullptr;
    m_current = max(0, min(m_current, (int)m_children.size()-1));
    return m_children[m_current];
}

void Switcher::_render()
{
    if (m_children.size() == 0)
        return;
    m_current = max(0, min(m_current, (int)m_children.size()-1));
    m_children[m_current]->render();
}

SizeReq Switcher::_get_preferred_size(Direction dim, int prosp_width)
{
    SizeReq r = { 0, 0 };
    for (auto const& child : m_children)
    {
        SizeReq c = child->get_preferred_size(dim, prosp_width);
        r.min = max(r.min, c.min);
        r.nat = max(r.nat, c.nat);
    }
    return r;
}

void Switcher::_allocate_region()
{
    for (auto const& child : m_children)
    {
        Region cr = m_region;
        SizeReq pw = child->get_preferred_size(Widget::HORZ, -1);
        cr.width = min(max(pw.min, m_region.width), pw.nat);
        SizeReq ph = child->get_preferred_size(Widget::VERT, cr.width);
        cr.height = min(max(ph.min, m_region.height), ph.nat);
        int xo, yo;
        switch (align_x)
        {
            case Widget::START:   xo = 0; break;
            case Widget::CENTER:  xo = (m_region.width - cr.width)/2; break;
            case Widget::END:     xo = m_region.width - cr.width; break;
            case Widget::STRETCH: xo = 0; break;
            default: ASSERT(0);
        }
        switch (align_y)
        {
            case Widget::START:   yo = 0; break;
            case Widget::CENTER:  yo = (m_region.height - cr.height)/2; break;
            case Widget::END:     yo = m_region.height - cr.height; break;
            case Widget::STRETCH: yo = 0; break;
            default: ASSERT(0);
        }
        cr.width += xo;
        cr.height += yo;
        if (align_x == Widget::STRETCH)
            cr.width = m_region.width;
        if (align_y == Widget::STRETCH)
            cr.height = m_region.height;
        child->allocate_region(cr);
    }
}

shared_ptr<Widget> Switcher::get_child_at_offset(int x, int y)
{
    if (m_children.size() == 0)
        return nullptr;

    int c = max(0, min(m_current, (int)m_children.size()));
    bool inside = m_children[c]->get_region().contains_point(x, y);
    return inside ? m_children[c] : nullptr;
}

shared_ptr<Widget> Grid::get_child_at_offset(int x, int y)
{
    int lx = x - m_region.x;
    int ly = y - m_region.y;
    int row = -1, col = -1;
    for (int i = 0; i < (int)m_col_info.size(); i++)
    {
        const auto& tr = m_col_info[i];
        if (lx >= tr.offset && lx < tr.offset + tr.size)
        {
            col = i;
            break;
        }
    }
    for (int i = 0; i < (int)m_row_info.size(); i++)
    {
        const auto& tr = m_row_info[i];
        if (ly >= tr.offset && ly < tr.offset + tr.size)
        {
            row = i;
            break;
        }
    }
    if (row == -1 || col == -1)
        return nullptr;
    for (auto& child : m_child_info)
    {
        if (child.pos.x <= col && col < child.pos.x + child.span.width)
        if (child.pos.y <= row && row < child.pos.y + child.span.height)
        if (child.widget->get_region().contains_point(x, y))
            return child.widget;
    }
    return nullptr;
}

void Grid::add_child(shared_ptr<Widget> child, int x, int y, int w, int h)
{
    child->_set_parent(this);
    child_info ch = { {x, y}, {w, h}, std::move(child) };
    m_child_info.push_back(ch);
    m_track_info_dirty = true;
    _invalidate_sizereq();
}

void Grid::init_track_info()
{
    if (!m_track_info_dirty)
        return;
    m_track_info_dirty = false;

    // calculate the number of rows and columns
    int n_rows = 0, n_cols = 0;
    for (auto info : m_child_info)
    {
        n_rows = max(n_rows, info.pos.y+info.span.height);
        n_cols = max(n_cols, info.pos.x+info.span.width);
    }
    m_row_info.resize(n_rows);
    m_col_info.resize(n_cols);

    sort(m_child_info.begin(), m_child_info.end(),
            [](const child_info& a, const child_info& b) {
        return a.pos.y < b.pos.y;
    });
}

void Grid::_render()
{
    // Find the visible rows
    const auto scissor = scissor_stack.top();
    int row_min = 0, row_max = m_row_info.size()-1, i = 0;
    for (; i < (int)m_row_info.size(); i++)
        if (m_row_info[i].offset+m_row_info[i].size+m_region.y >= scissor.y)
        {
            row_min = i;
            break;
        }
    for (; i < (int)m_row_info.size(); i++)
        if (m_row_info[i].offset+m_region.y >= scissor.ey())
        {
            row_max = i-1;
            break;
        }

    for (auto const& child : m_child_info)
    {
        if (child.pos.y < row_min)
            continue;
        if (child.pos.y > row_max)
            break;
        child.widget->render();
    }
}

void Grid::compute_track_sizereqs(Direction dim)
{
    auto& track = dim ? m_row_info : m_col_info;
#define DIV_ROUND_UP(n, d) (((n)+(d)-1)/(d))

    for (auto& t : track)
        t.sr = {0, 0};
    for (size_t i = 0; i < m_child_info.size(); i++)
    {
        auto& cp = m_child_info[i].pos;
        auto& cs = m_child_info[i].span;
        // if merging horizontally, need to find (possibly multi-col) width
        int prosp_width = dim ? get_tracks_region(cp.x, cp.y, cs.width, cs.height).width : -1;

        const SizeReq c = m_child_info[i].widget->get_preferred_size(dim, prosp_width);
        // NOTE: items spanning multiple rows/cols don't contribute!
        if (cs.width == 1 && cs.height == 1)
        {
            auto& s = track[dim ? cp.y : cp.x].sr;
            s.min = max(s.min, c.min);
            s.nat = max(s.nat, c.nat);
        }
    }
}

void Grid::set_track_offsets(vector<track_info>& tracks)
{
    int acc = 0;
    for (auto& track : tracks)
    {
        track.offset = acc;
        acc += track.size;
    }
}

SizeReq Grid::_get_preferred_size(Direction dim, int prosp_width)
{
    init_track_info();

    // get preferred column widths
    compute_track_sizereqs(Widget::HORZ);

    // total width min and nat
    SizeReq w_sr = { 0, 0 };
    for (auto const& col : m_col_info)
    {
        w_sr.min += col.sr.min;
        w_sr.nat += col.sr.nat;
    }

    if (!dim)
        return w_sr;

    layout_track(Widget::HORZ, w_sr, prosp_width);
    set_track_offsets(m_col_info);

    // get preferred row heights for those widths
    compute_track_sizereqs(Widget::VERT);

    // total height min and nat
    SizeReq h_sr = { 0, 0 };
    for (auto const& row : m_row_info)
    {
        h_sr.min += row.sr.min;
        h_sr.nat += row.sr.nat;
    }

    return h_sr;
}

void Grid::layout_track(Direction dim, SizeReq sr, int size)
{
    auto& infos = dim ? m_row_info : m_col_info;

    int extra = size - sr.min;
    ASSERT(extra >= 0);

    for (size_t i = 0; i < infos.size(); ++i)
        infos[i].size = infos[i].sr.min;

    const bool stretch = dim ? stretch_v : stretch_h;
    bool stretching = false;

    while (true)
    {
        int sum_flex_grow = 0, sum_taken = 0;
        for (const auto& info : infos)
            sum_flex_grow += info.size < info.sr.nat ? info.flex_grow : 0;
        if (!sum_flex_grow)
        {
            if (!stretch)
                break;
            stretching = true;
            for (const auto& info : infos)
                sum_flex_grow += info.flex_grow;
            if (!sum_flex_grow)
                break;
        }

        for (size_t i = 0; i < infos.size(); ++i)
        {
            float efg = (infos[i].size < infos[i].sr.nat || stretching)
                ? infos[i].flex_grow : 0;
            int tr_extra = extra * efg / sum_flex_grow;
            ASSERT(stretching || infos[i].size <= infos[i].sr.nat);
            int taken = stretching ? tr_extra
                : min(tr_extra, infos[i].sr.nat - infos[i].size);
            infos[i].size += taken;
            sum_taken += taken;
        }
        if (!sum_taken)
            break;
        extra = extra - sum_taken;
    }
}

void Grid::_allocate_region()
{
    // Use of _-prefixed member function is necessary here
    SizeReq h_sr = _get_preferred_size(Widget::VERT, m_region.width);

    layout_track(Widget::VERT, h_sr, m_region.height);
    set_track_offsets(m_row_info);

    for (size_t i = 0; i < m_child_info.size(); i++)
    {
        auto& cp = m_child_info[i].pos;
        auto& cs = m_child_info[i].span;
        Region cell_reg = get_tracks_region(cp.x, cp.y, cs.width, cs.height);
        cell_reg.x += m_region.x;
        cell_reg.y += m_region.y;
        m_child_info[i].widget->allocate_region(cell_reg);
    }
}

void Scroller::set_scroll(int y)
{
    if (m_scroll == y)
        return;
    m_scroll = y;
    _queue_allocation();
#ifdef USE_TILE_WEB
    tiles.json_open_object();
    tiles.json_write_string("msg", "ui-scroller-scroll");
    // XXX: always false, since we do not yet synchronize
    // webtiles client-side scrolls
    tiles.json_write_bool("from_webtiles", false);
    tiles.json_write_int("scroll", y);
    tiles.json_close_object();
    tiles.finish_message();
#endif
}

void Scroller::_render()
{
    if (m_child)
    {
        scissor_stack.push(m_region);
        m_child->render();
#ifdef USE_TILE_LOCAL
        m_shade_buf.draw();
#endif
        scissor_stack.pop();
#ifdef USE_TILE_LOCAL
        m_scrollbar_buf.draw();
#endif
    }
}

SizeReq Scroller::_get_preferred_size(Direction dim, int prosp_width)
{
    if (!m_child)
        return { 0, 0 };

    SizeReq sr = m_child->get_preferred_size(dim, prosp_width);
    if (dim)
        sr.min = 0; // can shrink to zero height
    return sr;
}

void Scroller::_allocate_region()
{
    SizeReq sr = m_child->get_preferred_size(Widget::VERT, m_region.width);
    m_scroll = max(0, min(m_scroll, sr.nat-m_region.height));
    Region ch_reg = {m_region.x, m_region.y-m_scroll, m_region.width, sr.nat};
    m_child->allocate_region(ch_reg);

#ifdef USE_TILE_LOCAL
    int shade_height = UI_SCROLLER_SHADE_SIZE, ds = 4;
    int shade_top = min({m_scroll/ds, shade_height, m_region.height/2});
    int shade_bot = min({(sr.nat-m_region.height-m_scroll)/ds, shade_height, m_region.height/2});
    const VColour col_a(4, 2, 4, 0), col_b(4, 2, 4, 150);

    m_shade_buf.clear();
    m_scrollbar_buf.clear();
    {
        GLWPrim rect(m_region.x, m_region.y+shade_top-shade_height,
                m_region.x+m_region.width, m_region.y+shade_top);
        rect.set_col(col_b, col_a);
        m_shade_buf.add_primitive(rect);
    }
    {
        GLWPrim rect(m_region.x, m_region.y+m_region.height-shade_bot,
                m_region.x+m_region.width, m_region.y+m_region.height-shade_bot+shade_height);
        rect.set_col(col_a, col_b);
        m_shade_buf.add_primitive(rect);
    }
    if (ch_reg.height > m_region.height && m_scrolbar_visible)
    {
        const int x = m_region.x+m_region.width;
        const float h_percent = m_region.height / (float)ch_reg.height;
        const int h = m_region.height*min(max(0.05f, h_percent), 1.0f);
        const float scroll_percent = m_scroll/(float)(ch_reg.height-m_region.height);
        const int y = m_region.y + (m_region.height-h)*scroll_percent;
        GLWPrim bg_rect(x+10, m_region.y, x+12, m_region.y+m_region.height);
        bg_rect.set_col(VColour(41, 41, 41));
        m_scrollbar_buf.add_primitive(bg_rect);
        GLWPrim fg_rect(x+10, y, x+12, y+h);
        fg_rect.set_col(VColour(125, 98, 60));
        m_scrollbar_buf.add_primitive(fg_rect);
    }
#endif
}

bool Scroller::on_event(const Event& event)
{
    if (Bin::on_event(event))
        return true;
#ifdef USE_TILE_LOCAL
    const int line_delta = 20;
#else
    const int line_delta = 1;
#endif
    int delta = 0;
    if (event.type() == Event::Type::KeyDown)
    {
        const auto key = numpad_to_regular(
                            static_cast<const KeyEvent&>(event).key(), true);
        // TODO: use CMD_MENU bindings here?
        switch (key)
        {
            case ' ': case '+': case CK_PGDN: case '>': case '\'':
                delta = m_region.height;
                break;

            case '-': case CK_PGUP: case '<': case ';':
                delta = -m_region.height;
                break;

            case CK_UP:
                delta = -line_delta;
                break;

            case CK_DOWN:
            case CK_ENTER:
                delta = line_delta;
                break;

            case CK_HOME:
                set_scroll(0);
                return true;

            case CK_END:
                set_scroll(INT_MAX);
                return true;
        }
    }
    else if (event.type() == Event::Type::MouseWheel)
    {
        auto mouse_event = static_cast<const MouseEvent&>(event);
        delta = -1 * mouse_event.wheel_dy() * line_delta;
    }
    else if (event.type() == Event::Type::MouseDown
             && static_cast<const MouseEvent&>(event).button() == MouseEvent::Button::Left)
        delta = line_delta;
    if (delta != 0)
    {
        set_scroll(m_scroll+delta);
        return true;
    }
    return false;
}

Layout::Layout(shared_ptr<Widget> child)
{
#ifdef USE_TILE_LOCAL
    if (tiles.is_using_small_layout())
        m_depth = 0;
    else
        m_depth = ui_root.num_children();
#endif
    child->_set_parent(this);
    m_child = std::move(child);
    expand_h = expand_v = true;
}

void Layout::_render()
{
    m_child->render();
}

SizeReq Layout::_get_preferred_size(Direction dim, int prosp_width)
{
    return m_child->get_preferred_size(dim, prosp_width);
}

void Layout::_allocate_region()
{
    m_child->allocate_region(m_region);
}

void Popup::_render()
{
#ifdef USE_TILE_LOCAL
    m_buf.draw();
#endif
    m_child->render();
}

SizeReq Popup::_get_preferred_size(Direction dim, int prosp_width)
{
#ifdef USE_TILE_LOCAL
    // can be called with a prosp_width that is narrower than the child's
    // minimum width, since our returned SizeReq has a minimum of 0
    if (dim == VERT)
    {
        SizeReq hsr = m_child->get_preferred_size(HORZ, -1);
        prosp_width = max(prosp_width, hsr.min);
    }
#endif
    SizeReq sr = m_child->get_preferred_size(dim, prosp_width);
#ifdef USE_TILE_LOCAL
    const int pad = base_margin() + m_padding;
    return {
        0, sr.nat + 2*pad + (dim ? m_depth*m_depth_indent*(!m_centred) : 0)
    };
#else
    return { sr.min, sr.nat };
#endif
}

void Popup::_allocate_region()
{
    Region region = m_region;
#ifdef USE_TILE_LOCAL
    m_buf.clear();
    m_buf.add(m_region.x, m_region.y,
            m_region.x + m_region.width, m_region.y + m_region.height,
            VColour(0, 0, 0, 150));
    const int pad = base_margin() + m_padding;
    region.width -= 2*pad;
    region.height -= 2*pad + m_depth*m_depth_indent*(!m_centred);

    SizeReq hsr = m_child->get_preferred_size(HORZ, -1);
    region.width = max(hsr.min, min(region.width, hsr.nat));
    SizeReq vsr = m_child->get_preferred_size(VERT, region.width);
    region.height = max(vsr.min, min(region.height, vsr.nat));

    region.x += pad + (m_region.width-2*pad-region.width)/2;
    region.y += pad + (m_centred ? (m_region.height-2*pad-region.height)/2
            : m_depth*m_depth_indent);

    m_buf.add(region.x - m_padding, region.y - m_padding,
            region.x + region.width + m_padding,
            region.y + region.height + m_padding,
            VColour(125, 98, 60));
    m_buf.add(region.x - m_padding + 2, region.y - m_padding + 2,
            region.x + region.width + m_padding - 2,
            region.y + region.height + m_padding - 2,
            VColour(0, 0, 0));
    m_buf.add(region.x - m_padding + 3, region.y - m_padding + 3,
            region.x + region.width + m_padding - 3,
            region.y + region.height + m_padding - 3,
            VColour(4, 2, 4));
#else
    SizeReq hsr = m_child->get_preferred_size(HORZ, -1);
    region.width = max(hsr.min, min(region.width, hsr.nat));
    SizeReq vsr = m_child->get_preferred_size(VERT, region.width);
    region.height = max(vsr.min, min(region.height, vsr.nat));
#endif
    m_child->allocate_region(region);
}

Size Popup::get_max_child_size()
{
    Size max_child_size = Size(m_region.width, m_region.height);
#ifdef USE_TILE_LOCAL
    const int pad = base_margin() + m_padding;
    max_child_size.width = (max_child_size.width - 2*pad) & ~0x1;
    max_child_size.height = (max_child_size.height - 2*pad - m_depth*m_depth_indent) & ~0x1;
#endif
    return max_child_size;
}

#ifdef USE_TILE_LOCAL
int Popup::base_margin()
{
    if (tiles.is_using_small_layout())
        return 0;
    const int screen_small = 800, screen_large = 1000;
    const int margin_small = 10, margin_large = 50;
    const int clipped = max(screen_small, min(screen_large, m_region.height));
    return margin_small + (clipped-screen_small)
            *(margin_large-margin_small)/(screen_large-screen_small);
}
#endif

void Checkbox::_render()
{
    if (m_child)
        m_child->render();

    const bool has_focus = ui::get_focused_widget() == this;

#ifdef USE_TILE_LOCAL
    tileidx_t tile = TILEG_CHECKBOX;
    if (m_checked)
        tile += 1;
    if (m_hovered || has_focus)
        tile += 2;

    const int x = m_region.x, y = m_region.y;
    TileBuffer tb;
    tb.set_tex(&tiles.get_image_manager()->get_texture(TEX_GUI));
    tb.add(tile, x, y, 0, 0, false, check_h, 1.0, 1.0);
    tb.draw();
#else
    cgotoxy(m_region.x+1, m_region.y+1, GOTO_CRT);
    textbackground(has_focus ? default_hover_colour() : BLACK);
    cprintf("[ ]");
    if (m_checked)
    {
        cgotoxy(m_region.x+2, m_region.y+1, GOTO_CRT);
        textcolour(has_focus ? BLACK : WHITE);
        cprintf("X");
    }
#endif
}

const int Checkbox::check_w;
const int Checkbox::check_h;

SizeReq Checkbox::_get_preferred_size(Direction dim, int prosp_width)
{
    SizeReq child_sr = { 0, 0 };
    if (m_child)
        child_sr = m_child->get_preferred_size(dim, prosp_width);

    if (dim == HORZ)
        return { child_sr.min + check_w, child_sr.nat + check_w };
    else
        return { max(child_sr.min, check_h), max(child_sr.nat, check_h) };
}

void Checkbox::_allocate_region()
{
    if (m_child)
    {
        auto child_region = m_region;
        child_region.x += check_w;
        child_region.width -= check_w;
        auto child_sr = m_child->get_preferred_size(VERT, child_region.width);
        child_region.height = min(max(child_sr.min, m_region.height), child_sr.nat);
        child_region.y += (m_region.height - child_region.height)/2;
        m_child->allocate_region(child_region);
    }
}

bool Checkbox::on_event(const Event& event)
{
#ifdef USE_TILE_LOCAL
    if (event.type() == Event::Type::MouseEnter || event.type() == Event::Type::MouseLeave)
    {
        bool new_hovered = event.type() == Event::Type::MouseEnter;
        if (new_hovered != m_hovered)
            _expose();
        m_hovered = new_hovered;
    }
    if (event.type() == Event::Type::MouseDown)
    {
        set_checked(!checked());
        set_focused_widget(this);
        _expose();
        return true;
    }
#endif
    if (event.type() == Event::Type::FocusIn || event.type() == Event::Type::FocusOut)
    {
        _expose();
        return true;
    }
    if (event.type() == Event::Type::KeyDown)
    {
        const auto key = static_cast<const KeyEvent&>(event).key();
        if (key == CK_ENTER || key == ' ')
        {
            set_checked(!checked());
            _expose();
            return true;
        }
    }
    return false;
}

#ifdef USE_TILE_WEB
void Checkbox::sync_save_state()
{
    tiles.json_write_bool("checked", m_checked);
}

void Checkbox::sync_load_state(const JsonNode *json)
{
    if (auto c = json_find_member(json, "checked"))
        if (c->tag == JSON_BOOL)
            set_checked(c->bool_);
}
#endif

TextEntry::TextEntry() : m_line_reader(m_buffer, sizeof(m_buffer))
{
#ifdef USE_TILE_LOCAL
    set_font(tiles.get_crt_font());
#endif
}

void TextEntry::_render()
{
    const bool has_focus = ui::get_focused_widget() == this;

#ifdef USE_TILE_LOCAL
    const int line_height = m_font->char_height();
    const int text_y = m_region.y + (m_region.height - line_height)/2;

    const auto bg = has_focus ? VColour(30, 30, 30, 255)
                              : VColour(29, 27, 21, 255);

    m_buf.clear();
    m_buf.add(m_region.x, m_region.y, m_region.ex(), m_region.ey(), bg);
    m_buf.draw();

    const auto border_bg = has_focus ? VColour(184, 141, 25)
                                     : VColour(125, 98, 60);

    LineBuffer bbuf;
    bbuf.add_square(m_region.x+1, m_region.y+1,
                    m_region.ex(), m_region.ey(), border_bg);
    bbuf.draw();

    const int content_width = m_font->string_width(m_text.c_str());
    const int cursor_x = m_font->string_width(
            m_text.substr(0, m_cursor).c_str());
    constexpr int x_pad = 3;
#else
    const int content_width = strwidth(m_text);
    const int cursor_x = strwidth(m_text.substr(0, m_cursor));
    constexpr int x_pad = 0;
#endif

    const int viewport_width = m_region.width - 2*x_pad;

    // Scroll to keep the cursor in view
    if (cursor_x < m_hscroll)
        m_hscroll = cursor_x;
    else if (cursor_x >= m_hscroll + viewport_width)
        m_hscroll = cursor_x - viewport_width + 1;

    // scroll to keep the textbox full of text, if possible
    m_hscroll = min(m_hscroll, max(0, content_width - viewport_width + 1));

#ifdef USE_TILE_LOCAL
    if (in_headless_mode())
        return;
    // XXX: we need to transform the scissor because the skill menu is rendered
    // using the CRT, with an appropriate transform that positions it into the
    // centre of the screen
    GLW_3VF translate;
    glmanager->get_transform(&translate, nullptr);
    const Region scissor_region = {
        m_region.x - static_cast<int>(translate.x),
        m_region.y - static_cast<int>(translate.y),
        m_region.width, m_region.height,
    };
    scissor_stack.push(scissor_region);

    const int text_x = m_region.x - m_hscroll + x_pad;

    FontBuffer m_font_buf(m_font);
    m_font_buf.add(formatted_string(m_text), text_x, text_y);
    m_font_buf.draw();

    scissor_stack.pop();

    if (has_focus)
    {
        m_buf.clear();
        m_buf.add(text_x + cursor_x, text_y,
                  text_x + cursor_x + 1, text_y + line_height,
                  VColour(255, 255, 255, 255));
        m_buf.draw();
    }
#else
    auto prefix_size = chop_string(m_text, m_hscroll, false).size();
    auto remain = chop_string(m_text.substr(prefix_size), m_region.width, true);

    const auto fg_colour = has_focus ? BLACK : WHITE;
    const auto bg_colour = has_focus ? LIGHTGREY : DARKGREY;

    draw_colour draw(fg_colour, bg_colour);
    cgotoxy(m_region.x+1, m_region.y+1, GOTO_CRT);
    cprintf("%s", remain.c_str());

    if (has_focus)
    {
        cgotoxy(m_region.x+cursor_x-m_hscroll+1, m_region.y+1, GOTO_CRT);
        textcolour(DARKGREY);
        cprintf(" ");
        show_cursor_at(m_region.x+cursor_x-m_hscroll+1, m_region.y+1);
    }
#endif
}

SizeReq TextEntry::_get_preferred_size(Direction dim, int /*prosp_width*/)
{
    if (!dim)
        return { 0, 300 };
    else
    {
#ifdef USE_TILE_LOCAL
        const int line_height = m_font->char_height(false);
        const int height = line_height + 2*padding_size();
        return { height, height };
#else
        return { 1, 1 };
#endif
    }
}

#ifdef USE_TILE_LOCAL
int TextEntry::padding_size()
{
    const int line_height = m_font->char_height(false);
    const float pad_amount = 0.2f;
    return (static_cast<int>(line_height*pad_amount) + 1)/2;
}
#endif

void TextEntry::_allocate_region()
{
}

bool TextEntry::on_event(const Event& event)
{
    switch (event.type())
    {
    case Event::Type::FocusIn:
    case Event::Type::FocusOut:
        set_cursor_enabled(event.type() == Event::Type::FocusIn);
        _expose();
        return true;
    case Event::Type::MouseDown:
        // TODO: unfocus if the mouse is clicked outside
        // TODO: move the cursor to the clicked position
        if (static_cast<const MouseEvent&>(event).button() == MouseEvent::Button::Left)
            ui::set_focused_widget(this);
        return true;
    case Event::Type::KeyDown:
        {
            const auto key = static_cast<const KeyEvent&>(event).key();
#ifdef USE_TILE_LOCAL
            // exit a popup on right click with text entry focus. XX this seems
            // like a bad way to handle it, but I'm not sure what a better way
            // might be.
            if (key == CK_MOUSE_CMD)
                return false;
#endif
            int ret = m_line_reader.process_key_core(key);
            if (ret == CK_ESCAPE || ret == 0)
                ui::set_focused_widget(nullptr);
            m_text = m_line_reader.get_text();
            m_cursor = m_line_reader.get_cursor_position();
            _expose();
            return key != '\t' && key != CK_SHIFT_TAB;
        }
    default:
        return false;
    }
}

#ifdef USE_TILE_LOCAL
void TextEntry::set_font(FontWrapper *font)
{
    ASSERT(font);
    m_font = font;
    _invalidate_sizereq();
}
#endif

TextEntry::LineReader::LineReader(char *buf, size_t sz)
    : buffer(buf), bufsz(sz), history(nullptr), keyfn(nullptr),
      mode(EDIT_MODE_INSERT),
      cur(nullptr), length(0)
{
    *buffer = 0;
    length = 0;
    cur = buffer;

    if (history)
        history->go_end();
}

TextEntry::LineReader::~LineReader()
{
}

string TextEntry::LineReader::get_text() const
{
    return buffer;
}

void TextEntry::LineReader::set_text(string text)
{
    snprintf(buffer, bufsz, "%s", text.c_str());
    length = min(text.size(), bufsz - 1);
    buffer[length] = 0;
    cur = buffer + length;
}

void TextEntry::LineReader::set_input_history(input_history *i)
{
    history = i;
}

void TextEntry::LineReader::set_keyproc(keyproc fn)
{
    keyfn = fn;
}

void TextEntry::LineReader::set_edit_mode(edit_mode m)
{
    mode = m;
}

void TextEntry::LineReader::set_prompt(string p)
{
    prompt = p;
}

edit_mode TextEntry::LineReader::get_edit_mode()
{
    return mode;
}

#ifdef USE_TILE_WEB
void TextEntry::LineReader::set_tag(const string &id)
{
    tag = id;
}
#endif

int TextEntry::LineReader::process_key_core(int ch)
{
    if (keyfn)
    {
        // if you intercept esc, don't forget to provide another way to
        // exit. Processing esc will safely cancel.
        keyfun_action whattodo = (*keyfn)(ch);
        if (whattodo == KEYFUN_CLEAR)
        {
            buffer[length] = 0;
            if (history && length)
                history->new_input(buffer);
            return 0;
        }
        else if (whattodo == KEYFUN_BREAK)
        {
            buffer[length] = 0;
            return ch;
        }
        else if (whattodo == KEYFUN_IGNORE)
            return -1;
        // else case: KEYFUN_PROCESS
    }

    return process_key(ch);
}

void TextEntry::LineReader::backspace()
{
    char *np = prev_glyph(cur, buffer);
    if (!np)
        return;
    char32_t ch;
    utf8towc(&ch, np);
    buffer[length] = 0;
    length -= cur - np;
    char *c = cur;
    cur = np;
    while (*c)
        *np++ = *c++;
    buffer[length] = 0;
}

void TextEntry::LineReader::delete_char()
{
    // TODO: unify with backspace
    if (*cur)
    {
        const char *np = next_glyph(cur);
        ASSERT(np);
        char32_t ch_at_point;
        utf8towc(&ch_at_point, cur);
        const size_t del_bytes = np - cur;
        const size_t follow_bytes = (buffer + length) - np;
        // Copy the NUL too.
        memmove(cur, np, follow_bytes + 1);
        length -= del_bytes;
    }
}

bool TextEntry::LineReader::is_wordchar(char32_t c)
{
    return iswalnum(c) || c == '_' || c == '-';
}

void TextEntry::LineReader::kill_to_begin()
{
    if (cur == buffer)
        return;

    const int rest = length - (cur - buffer);
    memmove(buffer, cur, rest);
    length = rest;
    buffer[length] = 0;
    cur = buffer;
}

void TextEntry::LineReader::kill_to_end()
{
    if (*cur)
    {
        length = cur - buffer;
        *cur = 0;
    }
}

void TextEntry::LineReader::killword()
{
    if (cur == buffer)
        return;

    bool foundwc = false;
    char *word = cur;
    while (1)
    {
        char *np = prev_glyph(word, buffer);
        if (!np)
            break;

        char32_t c;
        utf8towc(&c, np);
        if (is_wordchar(c))
            foundwc = true;
        else if (foundwc)
            break;

        word = np;
    }
    memmove(word, cur, strlen(cur) + 1);
    length -= cur - word;
    cur = word;
}

void TextEntry::LineReader::overwrite_char_at_cursor(int ch)
{
    int len = wclen(ch);
    int w = wcwidth(ch);

    if (w >= 0 && cur - buffer + len < static_cast<int>(bufsz))
    {
        bool empty = !*cur;

        wctoutf8(cur, ch);
        cur += len;
        if (empty)
            length += len;
        buffer[length] = 0;
    }
}

void TextEntry::LineReader::insert_char_at_cursor(int ch)
{
    if (wcwidth(ch) >= 0 && length + wclen(ch) < static_cast<int>(bufsz))
    {
        int len = wclen(ch);
        if (*cur)
        {
            char *c = buffer + length - 1;
            while (c >= cur)
            {
                c[len] = *c;
                c--;
            }
        }
        wctoutf8(cur, ch);
        cur += len;
        length += len;
        buffer[length] = 0;
    }
}

#ifdef USE_TILE_LOCAL
void TextEntry::LineReader::clipboard_paste()
{
    if (wm && wm->has_clipboard())
        for (char ch : wm->get_clipboard())
            process_key(ch);
}
#endif

int TextEntry::LineReader::process_key(int ch)
{
    switch (ch)
    {
    CASE_ESCAPE
        return CK_ESCAPE; // triggers focusout, not close
    case CK_UP:
    case CONTROL('P'):
    case CK_DOWN:
    case CONTROL('N'):
    {
        if (!history)
            break;

        const string *text = (ch == CK_UP || ch == CONTROL('P'))
                             ? history->prev()
                             : history->next();

        if (text)
            set_text(*text);
        break;
    }
    case CK_ENTER:
        buffer[length] = 0;
        if (history && length)
            history->new_input(buffer);
        return 0;

    case CONTROL('K'):
        kill_to_end();
        break;

    case CK_DELETE:
    case CONTROL('D'):
        delete_char();
        break;

    case CK_BKSP:
        backspace();
        break;

    case CONTROL('W'):
        killword();
        break;

    case CONTROL('U'):
        kill_to_begin();
        break;

    case CK_LEFT:
    case CONTROL('B'):
        if (char *np = prev_glyph(cur, buffer))
            cur = np;
        break;
    case CK_RIGHT:
    case CONTROL('F'):
        if (char *np = next_glyph(cur))
            cur = np;
        break;
    case CK_HOME:
    case CONTROL('A'):
        cur = buffer;
        break;
    case CK_END:
    case CONTROL('E'):
        cur = buffer + length;
        break;
    case CONTROL('V'):
#ifdef USE_TILE_LOCAL
        clipboard_paste();
#endif
        break;
    case CK_REDRAW:
        //redraw_screen();
        return -1;
    default:
        if (mode == EDIT_MODE_OVERWRITE)
            overwrite_char_at_cursor(ch);
        else // mode == EDIT_MODE_INSERT
            insert_char_at_cursor(ch);

        break;
    }

    return -1;
}

#ifdef USE_TILE_WEB
void TextEntry::sync_save_state()
{
    tiles.json_write_string("text", m_text);
    tiles.json_write_int("cursor", m_cursor);
}

void TextEntry::sync_load_state(const JsonNode *json)
{
    if (auto text = json_find_member(json, "text"))
        if (text->tag == JSON_STRING)
            set_text(text->string_);

    // TODO: sync cursor state
}
#endif

#ifdef USE_TILE_LOCAL
void Dungeon::_render()
{
    if (in_headless_mode())
        return;
    GLW_3VF t = {(float)m_region.x, (float)m_region.y, 0}, s = {32, 32, 1};
    glmanager->set_transform(t, s);
    m_buf.draw();
    glmanager->reset_transform();
}

SizeReq Dungeon::_get_preferred_size(Direction dim, int /*prosp_width*/)
{
    const int sz = (dim ? height : width)*32;
    return { sz, sz };
}

PlayerDoll::PlayerDoll(dolls_data doll)
{
    m_save_doll = doll;
    const ImageManager *image = tiles.get_image_manager();
    for (int i = 0; i < TEX_MAX; i++)
        m_tile_buf[i].set_tex(&image->get_texture(static_cast<TextureID>(i)));
    _pack_doll();
}

PlayerDoll::~PlayerDoll()
{
    for (int t = 0; t < TEX_MAX; t++)
        m_tile_buf[t].clear();
}

void PlayerDoll::_pack_doll()
{
    m_tiles.clear();
    pack_tilep_set(m_tiles, m_save_doll);
}

void PlayerDoll::_render()
{
    for (int i = 0; i < TEX_MAX; i++)
        m_tile_buf[i].draw();
}

SizeReq PlayerDoll::_get_preferred_size(Direction /*dim*/, int /*prosp_width*/)
{
    return { TILE_Y, TILE_Y };
}

void PlayerDoll::_allocate_region()
{
    for (int t = 0; t < TEX_MAX; t++)
        m_tile_buf[t].clear();
    for (const tile_def &tdef : m_tiles)
    {
        int tile      = tdef.tile;
        TextureID tex = get_tile_texture(tile);
        m_tile_buf[tex].add_unscaled(tile, m_region.x, m_region.y, tdef.ymax);
    }
}
#endif

void UIRoot::push_child(shared_ptr<Widget> ch, KeymapContext km)
{
    Widget* focus;
    if (auto popup = dynamic_cast<Popup*>(ch.get()))
        focus = popup->get_child().get();
    else
        focus = ch.get();

    saved_layout_info.push_back(state);
    state.keymap = km;
    state.default_focus = state.current_focus = focus;
    state.generation_id = next_generation_id++;

    m_root.add_child(std::move(ch));
    m_needs_layout = true;
    m_changed_layout_since_click = true;
    update_focus_order();
#ifdef USE_TILE_WEB
    update_synced_widgets();
#endif
#ifndef USE_TILE_LOCAL
    if (m_root.num_children() == 1)
    {
        clrscr();
        ui_root.resize(get_number_of_cols(), get_number_of_lines());
    }
#endif
}

void UIRoot::pop_child()
{
    top_child()->_emit_layout_pop();
    m_root.pop_child();
    m_needs_layout = true;
    state = saved_layout_info.back();
    saved_layout_info.pop_back();
    m_changed_layout_since_click = true;
    update_focus_order();
#ifdef USE_TILE_WEB
    update_synced_widgets();
#endif
#ifndef USE_TILE_LOCAL
    if (m_root.num_children() == 0)
        clrscr();
#endif
}

void UIRoot::resize(int w, int h)
{
    if (w == m_w && h == m_h)
        return;

    m_w = w;
    m_h = h;
    m_needs_layout = true;

#ifndef USE_TILE_LOCAL
    cgotoxy(1, 1, GOTO_CRT);
    clrscr();
#endif
}

void UIRoot::layout()
{
    while (m_needs_layout)
    {
        m_needs_layout = false;

        // Find preferred size with height-for-width: we never allocate less than
        // the minimum size, but may allocate more than the natural size.
        SizeReq sr_horz = m_root.get_preferred_size(Widget::HORZ, -1);
        int width = max(sr_horz.min, m_w);
        SizeReq sr_vert = m_root.get_preferred_size(Widget::VERT, width);
        int height = max(sr_vert.min, m_h);

#ifdef USE_TILE_LOCAL
        m_region = {0, 0, width, height};
#else
        m_region = {0, 0, m_w, m_h};
#endif
        try
        {
            m_root.allocate_region({0, 0, width, height});
        }
        catch (const RestartAllocation&)
        {
        }

#ifdef USE_TILE_LOCAL
        update_hover_path();
#endif
    }
}

#ifdef USE_TILE_LOCAL
bool should_render_current_regions = true;
#endif

void UIRoot::render()
{
#ifndef USE_TILE_LOCAL
    if (crawl_state.smallterm)
    {
        smallterm_warning();
        return;
    }
#endif

    if (!needs_paint || in_headless_mode())
        return;

#ifdef USE_TILE_LOCAL
    glmanager->reset_view_for_redraw();
    tiles.maybe_redraw_screen();
    if (should_render_current_regions)
        tiles.render_current_regions();
    glmanager->reset_transform();
#else
    // On console, clear and redraw only the dirty region of the screen
    m_dirty_region = m_dirty_region.aabb_intersect(m_region);
    textcolour(LIGHTGREY);
    textbackground(BLACK);
    clear_text_region(m_dirty_region, BLACK);
#endif

    scissor_stack.push(m_region);
#ifdef USE_TILE_LOCAL
    int cutoff = cutoff_stack.empty() ? 0 : cutoff_stack.back();
    ASSERT(cutoff <= static_cast<int>(m_root.num_children()));
    for (int i = cutoff; i < static_cast<int>(m_root.num_children()); i++)
        m_root.get_child(i)->render();
#ifdef DEBUG
    debug_render();
#endif
#else
    // Render only the top of the UI stack on console
    if (m_root.num_children() > 0)
        m_root.get_child(m_root.num_children()-1)->render();
    else
    {
        redraw_screen(false);
        update_screen();
    }

    if (!cursor_pos.origin())
    {
        cursorxy(cursor_pos.x, cursor_pos.y);
        cursor_pos.reset();
    }
#endif
    scissor_stack.pop();

    needs_paint = false;
    needs_swap = true;
    m_dirty_region = {0, 0, 0, 0};
}

void UIRoot::swap_buffers()
{
    if (!needs_swap)
        return;
    needs_swap = false;
#ifdef USE_TILE_LOCAL
    if (wm)
        wm->swap_buffers();
#else
    update_screen();
#endif
}

#ifdef DEBUG
void UIRoot::debug_render()
{
#ifdef USE_TILE_LOCAL
    if (debug_draw)
    {
        LineBuffer lb;
        ShapeBuffer sb;
        size_t i = 0;
        for (const auto& w : hover_path)
        {
            const auto r = w->get_region();
            i++;
            VColour lc;
            lc = i == hover_path.size() ?
                VColour(255, 100, 0, 100) : VColour(0, 50 + i*40, 0, 100);
            lb.add_square(r.x+1, r.y+1, r.ex(), r.ey(), lc);
        }
        if (!hover_path.empty())
        {
            const auto& hovered_widget = hover_path.back();
            Region r = hovered_widget->get_region();
            const Margin m = hovered_widget->get_margin();

            VColour lc = VColour(0, 0, 100, 100);
            sb.add(r.x, r.y-m.top, r.ex(), r.y, lc);
            sb.add(r.ex(), r.y, r.ex()+m.right, r.ey(), lc);
            sb.add(r.x, r.ey(), r.ex(), r.ey()+m.bottom, lc);
            sb.add(r.x-m.left, r.y, r.x, r.ey(), lc);
        }
        if (auto w = get_focused_widget())
        {
            Region r = w->get_region();
            lb.add_square(r.x+1, r.y+1, r.ex(), r.ey(), VColour(128, 31, 239));
        }
        lb.draw();
        sb.draw();
    }
#endif
}
#endif

void UIRoot::update_focus_order()
{
    focus_order.clear();

    function<void(Widget*)> recurse = [&](Widget* widget) {
        if (widget->can_take_focus())
            focus_order.emplace_back(widget);
        widget->for_each_child_including_internal([&](shared_ptr<Widget>& ch) {
            recurse(ch.get());
        });
    };

    int layer_idx = m_root.num_children()-1;
    if (layer_idx >= 0)
    {
        auto layer_root = m_root.get_child(layer_idx).get();
        recurse(layer_root);
    }
}

void UIRoot::focus_next()
{
    if (focus_order.empty())
        return;

    const auto default_focus = state.default_focus;
    const auto current_focus = state.current_focus;

    if (!current_focus || current_focus == default_focus)
    {
        set_focused_widget(focus_order.front());
        return;
    }

    auto it = find(focus_order.begin(), focus_order.end(), current_focus);
    ASSERT(it != focus_order.end());

    do {
        if (*it == focus_order.back())
            it = focus_order.begin();
        else
            ++it;
    } while (*it != current_focus && !(*it)->focusable());

    set_focused_widget(*it);
}

void UIRoot::focus_prev()
{
    if (focus_order.empty())
        return;

    const auto default_focus = state.default_focus;
    const auto current_focus = state.current_focus;

    if (!current_focus || current_focus == default_focus)
    {
        set_focused_widget(focus_order.back());
        return;
    }

    auto it = find(focus_order.begin(), focus_order.end(), current_focus);
    ASSERT(it != focus_order.end());

    do {
        if (*it == focus_order.front())
            it = focus_order.end()-1;
        else
            --it;
    } while (*it != current_focus && !(*it)->focusable());

    set_focused_widget(*it);
}

static function<bool(const wm_event&)> event_filter;

#ifdef USE_TILE_LOCAL
void UIRoot::update_hover_path()
{
    int mx = 0;
    int my = 0;
    if (wm)
        wm->get_mouse_state(&mx, &my);

    /* Find current hover path */
    vector<Widget*> new_hover_path;
    shared_ptr<Widget> current = m_root.get_child_at_offset(mx, my);
    while (current)
    {
        new_hover_path.emplace_back(current.get());
        current = current->get_child_at_offset(mx, my);
    }

    size_t new_hover_path_size = new_hover_path.size();
    size_t sz = max(hover_path.size(), new_hover_path.size());
    hover_path.resize(sz, nullptr);
    new_hover_path.resize(sz, nullptr);

    send_mouse_enter_leave_events(hover_path, new_hover_path);

    hover_path = std::move(new_hover_path);
    hover_path.resize(new_hover_path_size);
}

void UIRoot::send_mouse_enter_leave_events(
        const vector<Widget*>& old_hover_path,
        const vector<Widget*>& new_hover_path)
{
    ASSERT(old_hover_path.size() == new_hover_path.size());

    // event_filter takes a wm_event, and we don't have one, so don't bother to
    // call it; this is fine, since this is a private API and it only checks for
    // keydown events.
    if (event_filter)
        return;

    const size_t size = old_hover_path.size();

    size_t diff;
    for (diff = 0; diff < size; diff++)
        if (new_hover_path[diff] != old_hover_path[diff])
            break;

    if (diff == size)
        return;

    if (old_hover_path[diff])
    {
        const wm_mouse_event dummy = {};
        MouseEvent ev(Event::Type::MouseLeave, dummy);
        for (size_t i = size; i > diff; --i)
            if (old_hover_path[i-1])
                old_hover_path[i-1]->on_event(ev);
    }

    if (new_hover_path[diff])
    {
        const wm_mouse_event dummy = {};
        MouseEvent ev(Event::Type::MouseEnter, dummy);
        for (size_t i = diff; i < size; ++i)
            if (new_hover_path[i])
                new_hover_path[i]->on_event(ev);
    }
}

void UIRoot::update_hover_path_for_widget(Widget *widget)
{
    // truncate the hover path if the widget was previously hovered,
    // but don't deliver any mouseleave events.
    if (!widget->_get_parent())
    {
        for (size_t i = 0; i < hover_path.size(); i++)
            if (hover_path[i] == widget)
            {
                hover_path.resize(i);
                break;
            }
        return;
    }

    auto top = top_layout();
    if (top && top->is_ancestor_of(widget->get_shared()))
        update_hover_path();
}
#endif

static Event::Type convert_event_type(const wm_event& event)
{
    switch (event.type)
    {
        case WME_MOUSEBUTTONDOWN: return Event::Type::MouseDown;
        case WME_MOUSEBUTTONUP: return Event::Type::MouseUp;
        case WME_MOUSEMOTION: return Event::Type::MouseMove;
        case WME_MOUSEWHEEL: return Event::Type::MouseWheel;
        case WME_MOUSEENTER: return Event::Type::MouseEnter;
        case WME_MOUSELEAVE: return Event::Type::MouseLeave;
        case WME_KEYDOWN: return Event::Type::KeyDown;
        case WME_KEYUP: return Event::Type::KeyUp;
        default: abort();
    }
}

bool UIRoot::on_event(wm_event& event)
{
    if (event_filter && event_filter(event))
        return true;

#ifdef DEBUG
    if (debug_on_event(event))
        return true;
#endif

    switch (event.type)
    {
        case WME_MOUSEBUTTONDOWN:
        case WME_MOUSEBUTTONUP:
        case WME_MOUSEMOTION:
        case WME_MOUSEWHEEL:
        {
#ifdef USE_TILE_LOCAL
            auto mouse_event = MouseEvent(convert_event_type(event),
                    event.mouse_event);
            return deliver_event(mouse_event);
#endif
            break;
        }
        case WME_KEYDOWN:
        case WME_KEYUP:
        {
            auto key_event = KeyEvent(convert_event_type(event), event.key);
            return deliver_event(key_event);
        }
        case WME_CUSTOMEVENT:
#ifdef USE_TILE_LOCAL
        {
            auto callback = reinterpret_cast<wm_timer_callback>(event.custom.data1);
            callback(0, nullptr);
            break;
        }
#else
            break;
#endif
        // TODO: maybe stop windowmanager-sdl from returning these?
        case WME_NOEVENT:
            break;
        default:
            die("unreachable, type %d", event.type);
    }

    return false;
}

bool UIRoot::deliver_event(Event& event)
{
    switch (event.type())
    {
    case Event::Type::MouseDown:
    case Event::Type::MouseUp:
#ifdef USE_TILE_LOCAL
        if (event.type() == Event::Type::MouseDown)
            m_changed_layout_since_click = false;
        else if (event.type() == Event::Type::MouseUp)
            if (m_changed_layout_since_click)
                break;
#endif
        // fall through
    case Event::Type::MouseMove:
    case Event::Type::MouseWheel:
    {
#ifdef USE_TILE_LOCAL
        if (!hover_path.empty())
        {
            event.set_target(hover_path.back()->get_shared());
            for (auto w = hover_path.back(); w; w = w->_get_parent())
                if (w->on_event(event))
                    return true;
        }
#endif
        return false;
    }

    case Event::Type::KeyDown:
    case Event::Type::KeyUp:
    {
        const auto top = top_child();
        if (!top)
            return false;

        const auto key = static_cast<const KeyEvent&>(event).key();
        event.set_target(get_focused_widget()->get_shared());

        // give hotkey handlers a chance to intercept this key; they are only
        // called if on a widget within the layout.
        if (event.type() == Event::Type::KeyDown)
        {
            // TODO: only emit if widget is visible
            bool hotkey_handled = Widget::slots.hotkey.emit_if([&](Widget* w){
                return top->is_ancestor_of(w->get_shared());
            }, event);
            if (hotkey_handled)
                return true;
            // TODO: eat the corresponding KeyUp event
        }

        if (auto w = get_focused_widget())
        {
            if (w->on_event(event))
                return true;

            if (w != state.default_focus && event.type() == Event::Type::KeyDown)
            {
                if (key_is_escape(key))
                {
                    set_focused_widget(nullptr);
                    return true;
                }
                if (key == '\t')
                {
                    focus_next();
                    return true;
                }
                if (key == CK_SHIFT_TAB)
                {
                    focus_prev();
                    return true;
                }
            }

            for (w = w->_get_parent(); w; w = w->_get_parent())
                if (w->on_event(event))
                    return true;
        }

        if (event.type() == Event::Type::KeyDown)
        {
            if (key == '\t')
            {
                focus_next();
                return true;
            }
            if (key == CK_SHIFT_TAB)
            {
                focus_prev();
                return true;
            }
        }
    }

    case Event::Type::FocusIn:
    case Event::Type::FocusOut:
        return event.target()->on_event(event);

    default:
        for (auto w = event.target().get(); w; w = w->_get_parent())
            if (w->on_event(event))
                return true;
        return false;
    }
    return false;
}

#ifdef DEBUG
bool UIRoot::debug_on_event(const wm_event& event)
{
    if (event.type == WME_KEYDOWN && event.key.keysym.sym == CK_INSERT)
    {
        ui_root.debug_draw = !ui_root.debug_draw;
        ui_root.queue_layout();
        ui_root.expose_region({0, 0, INT_MAX, INT_MAX});
        return true;
    }

    switch (event.type)
    {
        case WME_MOUSEBUTTONDOWN:
        case WME_MOUSEBUTTONUP:
        case WME_MOUSEMOTION:
            if (ui_root.debug_draw)
            {
                ui_root.queue_layout();
                ui_root.expose_region({0, 0, INT_MAX, INT_MAX});
            }
        default:
            break;
    }

    return false;
}
#endif

#ifdef USE_TILE_WEB
void UIRoot::update_synced_widgets()
{
    synced_widgets.clear();

    function<void(Widget*)> recurse = [&](Widget* widget) {
        const auto id = widget->sync_id();
        if (!id.empty())
        {
            ASSERT(synced_widgets.count(id) == 0);
            synced_widgets[id] = widget;
        }
        widget->for_each_child_including_internal([&](shared_ptr<Widget>& ch) {
            recurse(ch.get());
        });
    };

    if (const auto top = top_child())
        recurse(top.get());
}

void UIRoot::sync_state()
{
    for (const auto& it : synced_widgets)
        it.second->sync_state_changed();
}

void UIRoot::recv_ui_state_change(const JsonNode *json)
{
    const auto generation_id = json_find_member(json, "generation_id");
    if (!generation_id || generation_id->tag != JSON_NUMBER
        || generation_id->number_ != state.generation_id)
    {
        return;
    }

    const auto has_focus = json_find_member(json, "has_focus");
    if (has_focus && (has_focus->tag != JSON_BOOL || !has_focus->bool_))
        return;

    const auto widget_id = json_find_member(json, "widget_id");
    if (!widget_id)
        return;
    if (!(widget_id->tag == JSON_STRING || widget_id->tag == JSON_NULL))
        return;
    const auto widget = widget_id->tag == JSON_STRING ?
        synced_widgets.at(widget_id->string_) : nullptr;

    unwind_bool recv(receiving_ui_state, true);
    if (has_focus)
        ui::set_focused_widget(widget);
    else if (widget)
        widget->sync_load_state(json);
}
#endif

#ifndef USE_TILE_LOCAL
static void clear_text_region(Region region, COLOURS bg)
{
    region = region.aabb_intersect(scissor_stack.top());
    if (region.width <= 0 || region.height <= 0)
        return;
    textcolour(LIGHTGREY);
    textbackground(bg);
    for (int y=region.y; y < region.y+region.height; y++)
    {
        cgotoxy(region.x+1, y+1);
        cprintf("%*s", region.width, "");
    }
}
#endif

#ifdef USE_TILE
void push_cutoff()
{
    int cutoff = static_cast<int>(ui_root.num_children());
    ui_root.cutoff_stack.push_back(cutoff);
#ifdef USE_TILE_WEB
    tiles.push_ui_cutoff();
#endif
}

void pop_cutoff()
{
    ui_root.cutoff_stack.pop_back();
#ifdef USE_TILE_WEB
    tiles.pop_ui_cutoff();
#endif
}
#endif

void push_layout(shared_ptr<Widget> root, KeymapContext km)
{
    ui_root.push_child(std::move(root), km);
#ifdef USE_TILE_WEB
    ui_root.sync_state();
#endif
}

void pop_layout()
{
    ui_root.pop_child();
#ifdef USE_TILE_LOCAL
    ui_root.update_hover_path();
#else
    if (!has_layout())
        redraw_screen(false);
#endif
}

shared_ptr<Widget> top_layout()
{
    return ui_root.top_child();
}

void resize(int w, int h)
{
    ui_root.resize(w, h);
}

static void remap_key(wm_event &event)
{
    keyseq keys = {event.key.keysym.sym};
    macro_buf_add_with_keymap(keys, ui_root.state.keymap);
    event.key.keysym.sym = macro_buf_get();
    ASSERT(event.key.keysym.sym != -1);
}

void force_render()
{
    ui_root.layout();
    ui_root.needs_paint = true;
    ui_root.render();
    ui_root.swap_buffers();
}

void render()
{
    ui_root.layout();
    ui_root.render();
    ui_root.swap_buffers();
}

void pump_events(int wait_event_timeout)
{
    int macro_key = macro_buf_get();

#ifdef USE_TILE_LOCAL
    // Don't render while there are unhandled mousewheel events,
    // since these can come in faster than crawl can redraw.
    // unlike mousemotion events, we don't drop all but the last event
    // ...but if there are macro keys, we do need to layout (for menu UI)
    if (!wm || !wm->next_event_is(WME_MOUSEWHEEL) || macro_key != -1)
#endif
    {
        ui_root.layout();
#ifdef USE_TILE_WEB
        // On webtiles, we can't skip rendering while there are macro keys: a
        // crt screen may be opened and without a render() call, its text won't
        // won't be sent to the client(s). E.g: macro => iai
        ui_root.render();
        if (macro_key == -1)
            ui_root.swap_buffers();
#else
        if (macro_key == -1)
        {
            ui_root.render();
            ui_root.swap_buffers();
        }
#endif
    }

#ifdef USE_TILE_LOCAL
    // These WME_* events are also handled, at different times, by a
    // similar bit of code in tilesdl.cc. Roughly, that handling is used
    // during the main game display, and the this loop is used in the
    // main menu and when there are ui elements on top.
    // TODO: consolidate as much as possible
    wm_event event = {0};
    while (true)
    {
        if (macro_key != -1)
        {
            event.type = WME_KEYDOWN;
            event.key.keysym.sym = macro_key;
            break;
        }

        if (!wm || !wm->wait_event(&event, wait_event_timeout))
        {
            if (wait_event_timeout == INT_MAX)
                continue;
            else
                return;
        }

        // For consecutive mouse events, ignore all but the last,
        // since these can come in faster than crawl can redraw.
        if (event.type == WME_MOUSEMOTION && wm && wm->next_event_is(WME_MOUSEMOTION))
            continue;
        if (event.type == WME_KEYDOWN
            && (event.key.keysym.sym == 0 || event.key.keysym.sym == CK_NO_KEY))
        {
            continue;
        }

        // translate any key events with the current keymap
        if (event.type == WME_KEYDOWN)
            remap_key(event);
        break;
    }

    switch (event.type)
    {
        case WME_ACTIVEEVENT:
            // When game gains focus back then set mod state clean
            // to get rid of stupid Windows/SDL bug with Alt-Tab.
            if (event.active.gain != 0)
            {
                if (wm)
                    wm->set_mod_state(TILES_MOD_NONE);
                ui_root.needs_paint = true;
            }
            break;

        case WME_QUIT:
            crawl_state.seen_hups++;
            break;

        case WME_RESIZE:
        {
            // triggers ui::resize:
            tiles.resize_event(event.resize.w, event.resize.h);
            ui_root.needs_paint = true;
            break;
        }

        case WME_MOVE:
            if (wm && tiles.update_dpi())
                ui_root.resize(wm->screen_width(), wm->screen_height());
            ui_root.needs_paint = true;
            break;

        case WME_EXPOSE:
            ui_root.needs_paint = true;
            break;

        case WME_MOUSEMOTION:
            // FIXME: move update_hover_path() into event delivery
            ui_root.update_hover_path();
            ui_root.on_event(event);

        default:
            if (!ui_root.on_event(event) && event.type == WME_MOUSEBUTTONDOWN)
            {
                // If a mouse event wasn't handled, send it through again as a
                // fake key event, for compatibility
                int key;
                if (event.mouse_event.button == wm_mouse_event::LEFT)
                    key = CK_MOUSE_CLICK;
                else if (event.mouse_event.button == wm_mouse_event::RIGHT)
                    key = CK_MOUSE_CMD;
                else break;

                wm_event ev = {0};
                ev.type = WME_KEYDOWN;
                ev.key.keysym.sym = key;
                ui_root.on_event(ev);
            }
            break;
    }
#else
    if (wait_event_timeout <= 0) // resizing probably breaks this case
        return;

    int k;
    {
        unwind_bool safe_resize(crawl_state.waiting_for_ui, true);
        k = macro_key != -1 ? macro_key : getch_ck();
    }

    wm_event ev = {0};
    ev.type = WME_KEYDOWN;
    ev.key.keysym.sym = k;
    if (macro_key == -1)
        remap_key(ev);
    ui_root.on_event(ev);
#endif
}

void run_layout(shared_ptr<Widget> root, const bool& done,
        shared_ptr<Widget> initial_focus)
{
    push_layout(root);
    set_focused_widget(initial_focus.get());
    while (!done && !crawl_state.seen_hups)
        pump_events();
    pop_layout();
}

bool has_layout()
{
    return ui_root.num_children() > 0;
}

NORETURN void restart_layout()
{
    throw UIRoot::RestartAllocation();
}

int getch(KeymapContext km)
{
    // getch() can be called when there are no widget layouts, i.e.
    // older layout/rendering code is being used. these parts of code don't
    // set a dirty region, so we should do that now. One example of this
    // is mprf() called from yesno()
    ui_root.needs_paint = true;

    int key;
    bool done = false;
    event_filter = [&](wm_event event) {
        // swallow all events except key presses here; we don't want any other
        // parts of the UI to react to anything while we're blocking for a key
        // press. An example: clicking shopping menu items when asked whether
        // to purchase already-selected items should not do anything.
        if (event.type != WME_KEYDOWN)
            return true;
        key = event.key.keysym.sym;
        done = true;
        return true;
    };
    unwind_var<KeymapContext> temp_keymap(ui_root.state.keymap, km);
    while (!done && !crawl_state.seen_hups)
        pump_events();
    event_filter = nullptr;
    return key;
}

void delay(unsigned int ms)
{
    if (crawl_state.disables[DIS_DELAY])
        ms = 0;

    auto start = std::chrono::high_resolution_clock::now();
#ifdef USE_TILE_LOCAL
    int wait_event_timeout = ms;
    do
    {
        ui_root.expose_region({0, 0, INT_MAX, INT_MAX});
        pump_events(wait_event_timeout);
        auto now = std::chrono::high_resolution_clock::now();
        wait_event_timeout =
            std::chrono::duration_cast<std::chrono::milliseconds>(now - start)
            .count();
    }
    while ((unsigned)wait_event_timeout < ms && !crawl_state.seen_hups);
#else
    constexpr int poll_interval = 10;
    while (!crawl_state.seen_hups)
    {
        auto now = std::chrono::high_resolution_clock::now();
        const int remaining = ms -
            std::chrono::duration_cast<std::chrono::milliseconds>(now - start)
            .count();
        if (remaining < 0)
            break;
        usleep(max(0, min(poll_interval, remaining)));
        if (kbhit())
            pump_events();
    }
#endif
    if (crawl_state.seen_hups)
    {
        macro_buf_add(CK_ESCAPE, true); // Let the caller respond to seen_hups.
        pump_events();
    }
}

/**
 * Is it possible to use UI calls, e.g. push_layout? The answer can be different
 * on different build targets; it is earlier on console than on local tiles.
 */
bool is_available()
{
#ifdef USE_TILE_LOCAL
    // basically whether TilesFramework::initialise() has been called. This
    // isn't precisely right, so (TODO) some more work needs to be
    // done to figure out exactly what is needed to use the UI api minimally
    // without crashing.
    return wm && tiles.fonts_initialized();
#else
    return crawl_state.io_inited;
#endif
}

/**
 * Show the terminal cursor at the given position on the next redraw.
 * The cursor is only shown if the cursor is enabled. 1-indexed.
 */
void show_cursor_at(coord_def pos)
{
    ui_root.cursor_pos = pos;
}

void show_cursor_at(int x, int y)
{
    show_cursor_at(coord_def(x, y));
}

/**
 * A basic progress bar popup. This is meant to be invoked in an RAII style;
 * the caller is responsible for regularly calling `advance_progress` in order
 * to actually trigger UI redraws.
 */
progress_popup::progress_popup(string title, int width)
    : position(0), bar_width(width), no_more(crawl_state.show_more_prompt, false)
{
    auto container = make_shared<Box>(Widget::VERT);
    container->set_cross_alignment(Widget::CENTER);
#ifndef USE_TILE_LOCAL
    // Center the popup in console.
    // if webtiles browser ever uses this property, then this will probably
    // look bad there and need another solution. But right now, webtiles ignores
    // expand_h.
    container->expand_h = true;
#endif
    formatted_string bar_string = get_progress_string(bar_width);
    progress_bar = make_shared<Text>(bar_string);
    auto title_text = make_shared<Text>(title);
    status_text = make_shared<Text>("");
    container->add_child(title_text);
    container->add_child(progress_bar);
    container->add_child(status_text);
    contents = make_shared<Popup>(container);

#ifdef USE_TILE_WEB
    tiles.json_open_object();
    tiles.json_write_string("title", title);
    tiles.json_write_string("bar_text", bar_string.to_colour_string());
    tiles.json_write_string("status", "");
    tiles.push_ui_layout("progress-bar", 1);
    tiles.flush_messages();
    contents->on_layout_pop([](){ tiles.pop_ui_layout(); });
#endif

    push_layout(std::move(contents));
    pump_events(0);
}

progress_popup::~progress_popup()
{
    pop_layout();

#ifdef USE_TILE_WEB
    tiles.flush_messages();
#endif
}

void progress_popup::force_redraw()
{
#ifdef USE_TILE_WEB
    tiles.json_open_object();
    tiles.json_write_string("status", status_text->get_text());
    tiles.json_write_string("bar_text",
        progress_bar->get_text().to_colour_string());
    tiles.ui_state_change("progress-bar", 0);
    // need to manually flush messages in case the caller doesn't pause for
    // input.
    tiles.flush_messages();
#endif
    pump_events(0);
}

void progress_popup::set_status_text(string status)
{
    status_text->set_text(status);
    force_redraw();
}

void progress_popup::advance_progress()
{
    position++;
    formatted_string new_bar = get_progress_string(bar_width);
    progress_bar->set_text(new_bar);
    force_redraw();
}

formatted_string progress_popup::get_progress_string(unsigned int len)
{
    string bar = string(len, ' ');
    if (len < 3)
        return formatted_string(bar);
    const unsigned int center_pos = len + position % len;
    const bool up = center_pos % 2 == 0;
    const string marker = up ? "/o/" : "\\o\\";
    bar[(center_pos - 1) % len] = marker[0];
    bar[center_pos % len] = marker[1];
    bar[(center_pos + 1) % len] = marker[2];
    bar = string("<lightmagenta>") + bar + "</lightmagenta>";
    return formatted_string::parse_string(bar);
}

void set_focused_widget(Widget* w)
{
    static bool sent_focusout;
    static Widget* new_focus;

    const auto top = top_layout();

    if (!top)
        return;

    if (w && !top->is_ancestor_of(w->get_shared()))
        return;

    if (!w)
        w = ui_root.state.default_focus;

    auto current_focus = ui_root.state.current_focus;

    if (w == current_focus)
        return;

#ifdef USE_TILE_WEB
    tiles.json_open_object();
    tiles.json_write_string("msg", "ui-state-sync");
    tiles.json_write_bool("has_focus", true);
    tiles.json_write_string("widget_id", w->sync_id());  // "" means default
    tiles.json_write_bool("from_webtiles", ui_root.receiving_ui_state);
    tiles.json_write_int("generation_id", ui_root.state.generation_id);
    tiles.json_close_object();
    tiles.finish_message();
#endif

    new_focus = w;

    if (current_focus && !sent_focusout)
    {
        sent_focusout = true;
        auto ev = FocusEvent(Event::Type::FocusOut);
        ev.set_target(current_focus->get_shared());
        ui_root.deliver_event(ev);
    }

    if (new_focus != w)
        return;

    ui_root.state.current_focus = new_focus;

    sent_focusout = false;

    if (new_focus)
    {
        auto ev = FocusEvent(Event::Type::FocusIn);
        ev.set_target(new_focus->get_shared());
        ui_root.deliver_event(ev);
    }
}

Widget* get_focused_widget()
{
    return ui_root.state.current_focus;
}

#ifdef USE_TILE_WEB
void recv_ui_state_change(const JsonNode *state)
{
    ui_root.recv_ui_state_change(state);
}

void sync_ui_state()
{
    ui_root.sync_state();
}

int layout_generation_id()
{
    return ui_root.next_generation_id;
}
#endif

bool raise_event(Event& event)
{
    return ui_root.deliver_event(event);
}

#ifdef USE_TILE_LOCAL
wm_mouse_event to_wm_event(const MouseEvent &ev)
{
    wm_mouse_event mev;
    mev.event = ev.type() == Event::Type::MouseMove ? wm_mouse_event::MOVE :
                ev.type() == Event::Type::MouseDown ? wm_mouse_event::PRESS :
                ev.type() == Event::Type::MouseUp ? wm_mouse_event::RELEASE :
                wm_mouse_event::WHEEL;
    mev.button = static_cast<wm_mouse_event::mouse_event_button>(ev.button());
    int x = 0;
    int y = 0;
    if (wm)
    {
        mev.mod = wm->get_mod_state();
        mev.held = wm->get_mouse_state(&x, &y);
    }
    else
        mev.mod = mev.held = 0;
    mev.px = x;
    mev.py = y;
    return mev;
}
#endif

}
