diff --git a/data/json/ui/sidebar.json b/data/json/ui/sidebar.json
index d3bc1fe126ed4..c08b0a1950c7c 100644
--- a/data/json/ui/sidebar.json
+++ b/data/json/ui/sidebar.json
@@ -299,7 +299,7 @@
   {
     "id": "stamina_graph",
     "type": "widget",
-    "label": "Stam",
+    "label": "Stamina",
     "var": "stamina",
     "style": "graph",
     "width": 10,
@@ -425,6 +425,7 @@
     "id": "hitpoint_graphs_top_layout",
     "type": "widget",
     "style": "layout",
+    "label": "HP Top",
     "arrange": "columns",
     "widgets": [ "hp_left_arm_graph", "hp_head_graph", "hp_right_arm_graph" ]
   },
@@ -432,25 +433,34 @@
     "id": "hitpoint_graphs_bottom_layout",
     "type": "widget",
     "style": "layout",
+    "label": "HP Bottom",
     "arrange": "columns",
     "widgets": [ "hp_left_leg_graph", "hp_torso_graph", "hp_right_leg_graph" ]
   },
   {
-    "id": "hitpoints_head_torso",
+    "id": "hitpoints_all_graphs_layout",
+    "type": "widget",
+    "style": "layout",
+    "label": "Hit Points",
+    "arrange": "rows",
+    "widgets": [ "hitpoints_top_layout", "hitpoints_bottom_layout" ]
+  },
+  {
+    "id": "hitpoints_head_torso_layout",
     "type": "widget",
     "style": "layout",
     "arrange": "columns",
     "widgets": [ "hp_head_graph", "hp_torso_graph" ]
   },
   {
-    "id": "hitpoints_arms",
+    "id": "hitpoints_arms_layout",
     "type": "widget",
     "style": "layout",
     "arrange": "columns",
     "widgets": [ "hp_left_arm_graph", "hp_right_arm_graph" ]
   },
   {
-    "id": "hitpoints_legs",
+    "id": "hitpoints_legs_layout",
     "type": "widget",
     "style": "layout",
     "arrange": "columns",
@@ -472,6 +482,14 @@
     "arrange": "columns",
     "widgets": [ "hp_left_leg_num", "hp_torso_num", "hp_right_leg_num" ]
   },
+  {
+    "id": "hitpoints_all_narrow_graphs_layout",
+    "type": "widget",
+    "style": "layout",
+    "label": "Hit Points",
+    "arrange": "rows",
+    "widgets": [ "hitpoints_head_torso_layout", "hitpoints_arms_layout", "hitpoints_legs_layout" ]
+  },
   {
     "id": "encumbrance_top_layout",
     "type": "widget",
@@ -504,6 +522,7 @@
     "id": "stamina_fatigue_layout",
     "type": "widget",
     "style": "layout",
+    "label": "Stamina/Fatigue",
     "arrange": "columns",
     "widgets": [ "stamina_graph", "fatigue_graph" ]
   },
@@ -521,45 +540,83 @@
     "arrange": "columns",
     "widgets": [ "wetness_left_leg_num", "wetness_torso_num", "wetness_right_leg_num" ]
   },
+  {
+    "id": "speed_focus_layout",
+    "type": "widget",
+    "style": "layout",
+    "label": "Speed/Focus",
+    "arrange": "columns",
+    "widgets": [ "speed_num", "focus_num" ]
+  },
+  {
+    "id": "weapon_style_layout",
+    "type": "widget",
+    "style": "layout",
+    "label": "Weapon/Style",
+    "arrange": "rows",
+    "widgets": [ "wielding_desc", "style_desc" ]
+  },
+  {
+    "id": "needs_desc_layout",
+    "type": "widget",
+    "style": "layout",
+    "label": "Needs",
+    "arrange": "rows",
+    "widgets": [ "hunger_desc", "thirst_desc", "fatigue_desc", "pain_desc", "body_temp_desc" ]
+  },
   {
     "id": "stamina_speed_layout",
     "type": "widget",
     "style": "layout",
+    "label": "Stamina/Speed",
     "arrange": "columns",
     "widgets": [ "stamina_graph_classic", "speed_num" ]
   },
   {
-    "id": "focus_move_layout",
+    "id": "stamina_activity_weary_layout",
+    "type": "widget",
+    "style": "layout",
+    "label": "Stamina/Weariness",
+    "arrange": "rows",
+    "widgets": [ "stamina_graph", "activity_desc", "weariness_desc", "weary_malus_desc" ]
+  },
+  {
+    "id": "mood_focus_layout",
     "type": "widget",
     "style": "layout",
+    "label": "Mood/Focus",
     "arrange": "columns",
-    "widgets": [ "focus_num", "move_num" ]
+    "widgets": [ "mood_desc", "focus_num" ]
   },
   {
     "id": "stamina_fatigue_focus_layout",
     "type": "widget",
     "style": "layout",
+    "label": "Stamina/Fatigue/Focus",
     "arrange": "columns",
     "widgets": [ "stamina_graph_classic", "fatigue_graph", "focus_num" ]
   },
   {
-    "id": "speed_move_layout",
+    "id": "move_speed_layout",
     "type": "widget",
     "style": "layout",
+    "label": "Move/Speed",
     "arrange": "columns",
-    "widgets": [ "speed_num", "move_num" ]
+    "widgets": [ "move_num", "speed_num" ]
   },
   {
-    "id": "sound_speed_move_layout",
+    "id": "safe_sound_layout",
     "type": "widget",
     "style": "layout",
+    "label": "Safe/Sound",
     "arrange": "columns",
-    "widgets": [ "sound_num", "speed_num", "move_num" ]
+    "widgets": [ "safe_mode_desc", "sound_num" ]
   },
   {
     "id": "sound_fatigue_focus_layout",
     "type": "widget",
     "style": "layout",
+    "label": "Sound/Fatigue/Focus",
     "arrange": "columns",
     "widgets": [ "sound_num", "fatigue_graph", "focus_num" ]
   },
@@ -567,6 +624,7 @@
     "id": "stamina_speed_move_layout",
     "type": "widget",
     "style": "layout",
+    "label": "Stamina/Speed/Move",
     "arrange": "columns",
     "widgets": [ "stamina_graph_classic", "speed_num", "move_num" ]
   },
@@ -574,6 +632,7 @@
     "id": "sound_focus_layout",
     "type": "widget",
     "style": "layout",
+    "label": "Sound/Focus",
     "arrange": "columns",
     "widgets": [ "sound_num", "focus_num" ]
   },
@@ -581,6 +640,7 @@
     "id": "stats_layout",
     "type": "widget",
     "style": "layout",
+    "label": "Stats",
     "arrange": "columns",
     "widgets": [ "str_num", "dex_num", "int_num", "per_num" ]
   },
@@ -588,6 +648,7 @@
     "id": "str_dex_layout",
     "type": "widget",
     "style": "layout",
+    "label": "Str/Dex",
     "arrange": "columns",
     "widgets": [ "str_num", "dex_num" ]
   },
@@ -595,9 +656,18 @@
     "id": "int_per_layout",
     "type": "widget",
     "style": "layout",
+    "label": "Int/Per",
     "arrange": "columns",
     "widgets": [ "int_num", "per_num" ]
   },
+  {
+    "id": "stats_narrow_layout",
+    "type": "widget",
+    "style": "layout",
+    "label": "Stats",
+    "arrange": "rows",
+    "widgets": [ "mood_focus_layout", "move_speed_layout", "str_dex_layout", "int_per_layout" ]
+  },
   {
     "id": "activity_desc",
     "type": "widget",
@@ -609,7 +679,7 @@
   {
     "id": "body_temp_desc",
     "type": "widget",
-    "label": "Body Heat",
+    "label": "Heat",
     "style": "text",
     "var": "body_temp_text",
     "//": "Uses display::temp_text_color"
@@ -740,7 +810,7 @@
   {
     "id": "power_desc",
     "type": "widget",
-    "label": "Power",
+    "label": "Bionic Power",
     "style": "text",
     "var": "power_text"
   },
@@ -761,36 +831,43 @@
   {
     "id": "safe_mode_desc",
     "type": "widget",
-    "label": "Safe Mode",
+    "label": "Safe",
     "style": "text",
     "var": "safe_mode_text"
   },
   {
-    "id": "root_layout_wide",
+    "id": "light_moon_wind_temp_layout",
     "type": "widget",
+    "label": "Environment",
     "style": "layout",
     "arrange": "rows",
-    "widgets": [
-      "hitpoint_graphs_top_layout",
-      "hitpoint_graphs_bottom_layout",
-      "sound_fatigue_focus_layout",
-      "stamina_speed_move_layout",
-      "stats_layout"
-    ]
+    "widgets": [ "lighting_desc", "moon_phase_desc", "wind_desc", "env_temp_desc" ]
   },
   {
-    "id": "root_layout_narrow",
+    "id": "place_date_time_layout",
     "type": "widget",
+    "label": "Place/Date/Time",
     "style": "layout",
     "arrange": "rows",
+    "widgets": [ "place_desc", "date_desc", "time_desc" ]
+  },
+  {
+    "id": "custom_sidebar",
+    "type": "widget",
+    "style": "sidebar",
+    "label": "custom",
+    "width": 36,
     "widgets": [
-      "hitpoints_head_torso",
-      "hitpoints_arms",
-      "hitpoints_legs",
-      "stamina_speed_layout",
-      "focus_move_layout",
-      "str_dex_layout",
-      "int_per_layout"
+      "hitpoints_all_narrow_graphs_layout",
+      "stats_narrow_layout",
+      "stamina_activity_weary_layout",
+      "needs_desc_layout",
+      "safe_sound_layout",
+      "power_desc",
+      "rad_badge_desc",
+      "weapon_style_layout",
+      "place_date_time_layout",
+      "light_moon_wind_temp_layout"
     ]
   }
 ]
diff --git a/data/mods/TEST_DATA/widgets.json b/data/mods/TEST_DATA/widgets.json
index 9092a8105f649..e10ed0e315ea9 100644
--- a/data/mods/TEST_DATA/widgets.json
+++ b/data/mods/TEST_DATA/widgets.json
@@ -198,8 +198,30 @@
     "id": "test_stat_panel",
     "type": "widget",
     "style": "layout",
+    "arrange": "columns",
     "widgets": [ "test_str_num", "test_dex_num", "test_int_num", "test_per_num" ]
   },
+  {
+    "id": "test_2_column_layout",
+    "type": "widget",
+    "style": "layout",
+    "arrange": "columns",
+    "widgets": [ "test_move_num", "test_speed_num" ]
+  },
+  {
+    "id": "test_3_column_layout",
+    "type": "widget",
+    "style": "layout",
+    "arrange": "columns",
+    "widgets": [ "test_move_num", "test_speed_num", "test_focus_num" ]
+  },
+  {
+    "id": "test_4_column_layout",
+    "type": "widget",
+    "style": "layout",
+    "arrange": "columns",
+    "widgets": [ "test_move_num", "test_speed_num", "test_focus_num", "test_mana_num" ]
+  },
   {
     "id": "test_text_widget",
     "type": "widget",
diff --git a/doc/SIDEBAR_MOD.md b/doc/SIDEBAR_MOD.md
index ae01a11401f27..68849333ccaf8 100644
--- a/doc/SIDEBAR_MOD.md
+++ b/doc/SIDEBAR_MOD.md
@@ -1,29 +1,39 @@
 # Sidebar Modification
 
 - [Overview](#overview)
-- [About widgets](#about-widgets)
-- [Widget variables](#widget-variables)
-- [Number widget](#number-widget)
-- [Graph widget](#graph-widget)
-  - [fill](#fill)
-  - [var_max](#var-max)
-- [Layout widget](#layout-widget)
-  - [Root layouts](#root-layouts)
-
+- [Widgets](#widgets)
+- [Sidebar widgets](#sidebar-widgets)
+- [Layout widgets](#layout-widgets)
+- [Variable widgets](#variable-widgets)
+  - [Number widget](#number-widget)
+  - [Graph widget](#graph-widget)
+    - [fill](#fill)
+    - [var_max](#var-max)
+  - [Colors](#colors)
 
 ## Overview
 
 Some parts of the main CDDA sidebar are now moddable, meaning they are data-driven and can be
 customized simply by editing JSON files, without recompiling the game.
 
-You can add the custom sidebar via the Sidebar Options menu `}` by enabling the "Custom" section.
+You can add a custom sidebar section to your regular sidebar via the Sidebar Options menu `}`
+by enabling the "Custom" section from the left-hand column for any of the regular sidebar layouts
+(classic, labels, narrow etc.)
+
+You can also switch to an almost completely custom sidebar, by selecting "custom" from the
+right-hand column of the sidebar options menu. This layout is built from the "custom_sidebar" widget
+defined in `data/json/ui/sidebar.json`, with sections you can toggle or rearrange in-game according
+to your preference.
 
+In both cases, you can further customize your sidebar widgets by modifying (or modding) the JSON
+that describes them. This document explains how they work.
 
-## About widgets
 
-Sidebar UI elements are defined in objects called widgets. A widget can display a variety of player
-character attributes in numeric form, or as a bar graph of arbitrary width. A widget can also make a
-layout of other widgets.
+## Widgets
+
+All "custom" sidebar UI elements are defined in objects called widgets. A widget can display a
+variety of player character attributes in numeric form, or as a bar graph of arbitrary width. A
+widget can also make a layout of other widgets.
 
 Widget instances are defined by JSON data, with the main game sidebar widgets and layouts being in
 `data/json/ui/sidebar.json`. You may customize yours by editing this file, or by loading a mod that
@@ -43,21 +53,113 @@ For example, here is a widget to display the player character's "Focus" attribut
 
 All widgets must have a unique "id", and "type": "widget".
 
-Widgets have the following "style" options:
+Each widget has a "style" field that may be:
 
-- `number`: Display value as a plain integer number
+- `number`: Show value as a plain integer number
 - `graph`: Show a bar graph of the value with colored text characters
 - `text`: Show text from a `*_text` variable
-- `layout`: Special style; this widget will be a layout container for other widgets
+- `layout`: Layout container for arranging other widgets in rows or columns
+- `sidebar`: Special top-level widget for defining custom sidebars
+
+Let's start at the top, with the "sidebar" widget, composed of several "layout" widgets.
+
+
+## Sidebar widget
+
+The highest-level widget is the "sidebar", which represents the entire display region on the right
+(or left) edge of the screen. It includes a "width" in characters, a "label" displayed in the
+sidebar options menu, and a list of "widgets", shown as sections that may be rearranged or
+toggled from the sidebar options menu.
+
+These sub-widgets are typically [layout widgets](#layout-widgets), with other widgets arranged
+inside them, but they could also be plain [variable widgets](#variable-widgets), used for showing
+character attributes or other information.
+
+Here is how a simple sidebar definition might look in JSON:
+
+```json
+{
+  "id": "my_sidebar",
+  "style": "sidebar",
+  "width": 40,
+  "widgets": [
+    "sound_focus_move_layout",
+    "stats_layout"
+  ]
+}
+```
 
-Non-layout widgets must define a "var" field, with the name of a predefined widget variable.
+Each widget in the "sidebar" will be associated with a `panel_layout` instance in the code, which is
+what allows them to be toggled and rearranged like the classic sidebar sections.
 
+You may define any number of "sidebar" widgets, each with their own width, label, and collection of
+sub-widgets and layouts.
 
-## Widget variables
+Sidebar widgets aside, there are two major types of widget: [variable widgets](#variable-widgets),
+showing some piece of information (with a label); and [layout widgets](#layout-widgets), used for
+arranging other widgets in rows or columns.
+
+We will look at layout widgets first, since they are easier to explain.
+
+
+## Layout widgets
+
+Use widgets with "style": "layout" to arrange child widgets in sidebar panels, giving widget ids in
+the "widgets" list field.
+
+The arrangement of child widgets is defined by the "arrange" field, which may be "columns" (default)
+to array widgets horizontally, or "rows" to arrange them vertically, one widget per row.  Widgets in
+the same row will have their horizontal space split as equally as possible.
+
+```json
+[
+  {
+    "id": "sound_focus_move_layout",
+    "type": "widget",
+    "style": "layout",
+    "arrange": "columns",
+    "widgets": [ "sound_num", "focus_num", "move_num" ]
+  },
+  {
+    "id": "stats_layout",
+    "type": "widget",
+    "style": "layout",
+    "arrange": "columns",
+    "widgets": [ "str_num", "dex_num", "int_num", "per_num" ]
+  },
+  {
+    "id": "sound_focus_move_stats_layout",
+    "type": "widget",
+    "style": "layout",
+    "arrange": "rows",
+    "widgets": [
+      "sound_focus_move_layout",
+      "stats_layout"
+    ]
+  }
+]
+```
 
-The "var" field of a widget tells what variable data gives the widget its value. Valid var names
-are given by the `widget_var` enum defined in `widget.h`. In the widget's `show` method, these var
-enums determine which avatar method(s) to get their values from.
+The above might yield:
+
+```
+Sound: 8  Focus: 105  Move: 120
+Str: 8  Dex: 9  Int: 7  Per: 11
+```
+
+Where do all these numeric widgets and their values come from? These are variable widgets, discussed
+next.
+
+
+## Variable widgets
+
+Variable widgets define a "var" field, with the name of a predefined widget variable. This tells the
+widget what information it should show. Most of the time, these are attributes of the player
+character, but they can also be attributes of the world, environment, or vehicle where they are.
+
+The "var" field of a display widget tells what variable data gives the widget its value. Valid var
+names are given by the `widget_var` enum defined in `widget.h`. In the widget's `show` method, these
+var enums determine which avatar method(s) to get their values from.
 
 Below are a few examples of vars and what they mean. See the `widget_var` list in `widget.h` for the
 definitive list of vars.
@@ -126,7 +228,7 @@ define a "var_max" as a cutoff point; see the "Graph widget" section for more.
 You may also define "var_min" if it's relevant. By default this is 0.
 
 
-## Number widget
+### Number widget
 
 The simplest and usually most compact widget for displaying a value, "style": "number" appears as a
 label with an integer number.
@@ -147,7 +249,7 @@ Focus: 100
 The numeric value comes from the given "var", displayed as a decimal integer.
 
 
-## Graph widget
+### Graph widget
 
 The graph shows an arrangement of symbols. It has two important parameters:
 
@@ -231,7 +333,7 @@ The method is specified with the "fill" field. This example uses the default "bu
 there is also a "pool" method, described in the next section.
 
 
-### fill
+#### fill
 
 With "bucket" fill, positions are filled like a row of buckets, using all symbols in the first
 position before beginning to fill the next position.  This is like the classic 5-bar HP meter.
@@ -290,7 +392,7 @@ Result:
 The total number of possible graphs is the same in each case, so both have the same resolution.
 
 
-### var_max
+#### var_max
 
 Using "graph" style widgets, usually you should provide a "var_max" value (integer) with the maximum
 typical value of "var" that will ever be rendered.
@@ -304,62 +406,7 @@ up to 100 or 200 (like focus). If a var usually varies within a range `[low, hig
 "var_max" greater than `high` to be sure the normal variance is captured in the graph's range.
 
 
-## Layout widget
-
-Use widgets with "style": "layout" to arrange child widgets in sidebar panels, giving widget ids in
-the "widgets" list field.
-
-The arrangement of child widgets is defined by the "arrange" field, which may be "columns" (default)
-to array widgets horizontally, or "rows" to arrange them vertically, one widget per row.  Widgets in
-the same row will have their horizontal space split as equally as possible.
-
-```json
-[
-  {
-    "id": "sound_focus_move_layout",
-    "type": "widget",
-    "style": "layout",
-    "arrange": "columns",
-    "widgets": [ "sound_num", "focus_num", "move_num" ]
-  },
-  {
-    "id": "stats_layout",
-    "type": "widget",
-    "style": "layout",
-    "arrange": "columns",
-    "widgets": [ "str_num", "dex_num", "int_num", "per_num" ]
-  },
-  {
-    "id": "root_layout",
-    "type": "widget",
-    "style": "layout",
-    "arrange": "rows",
-    "widgets": [
-      "sound_focus_move_layout",
-      "stats_layout"
-    ]
-  }
-]
-```
-
-The above might yield:
-
-```
-Sound: 8  Focus: 105  Move: 120
-Str: 8  Dex: 9  Int: 7  Per: 11
-```
-
-### Root layouts
-
-There are two important "root layout" widgets defined in `data/json/ui/sidebar.json`:
-
-- Widget id "root_layout_wide" is used for "labels" and "classic" sidebars
-- Widget id "root_layout_narrow" is used for "compact" and "labels narrow" sidebars
-
-Modify or override the root layout widget to define all sub-layouts or child widgets you want to see
-in the custom section of your sidebar.
-
-## Colors
+### Colors
 
 Widgets with "number" or "graph" style may define "colors", which will be used as a spectrum across
 the widget's values ("var_min" to "var_max"), applying the appropriate color at each level.
@@ -398,4 +445,3 @@ yellow, light red, and red. Such coloration could be represented with "colors" l
 }
 ```
 
-
diff --git a/src/avatar.h b/src/avatar.h
index ce91fcf48f5ee..5a4973b7e7672 100644
--- a/src/avatar.h
+++ b/src/avatar.h
@@ -279,6 +279,10 @@ class avatar : public Character
             return mon_visible;
         }
 
+        const monster_visible_info &get_mon_visible() const {
+            return mon_visible;
+        }
+
         struct daily_calories {
             int spent = 0;
             int gained = 0;
diff --git a/src/game.cpp b/src/game.cpp
index 61b1c30bda98b..830b074cc9250 100644
--- a/src/game.cpp
+++ b/src/game.cpp
@@ -429,7 +429,6 @@ void game::load_static_data()
     fullscreen = false;
     was_fullscreen = false;
     show_panel_adm = false;
-    panel_manager::get_manager().init();
 
     // These functions do not load stuff from json.
     // The content they load/initialize is hardcoded into the program.
@@ -677,8 +676,9 @@ void game::setup()
 
         load_core_data( ui );
     }
-
     load_world_modfiles( ui );
+    // Panel manager needs JSON data to be loaded before init
+    panel_manager::get_manager().init();
 
     m = map();
 
@@ -3362,25 +3362,30 @@ void game::draw_panels( bool force_draw )
     int y = 0;
     const bool sidebar_right = get_option<std::string>( "SIDEBAR_POSITION" ) == "right";
     int spacer = get_option<bool>( "SIDEBAR_SPACERS" ) ? 1 : 0;
+    // Total up height used by all panels, and see what is left over for log
     int log_height = 0;
     for( const window_panel &panel : mgr.get_current_layout().panels() ) {
+        // The panel with height -2 is the message log panel
         if( panel.get_height() != -2 && panel.toggle && panel.render() ) {
             log_height += panel.get_height() + spacer;
         }
     }
     log_height = std::max( TERMY - log_height, 3 );
+    // Draw each panel having render() true
     for( const window_panel &panel : mgr.get_current_layout().panels() ) {
         if( panel.render() ) {
             // height clamped to window height.
             int h = std::min( panel.get_height(), TERMY - y );
+            // The panel with height -2 is the message log panel
             if( h == -2 ) {
                 h = log_height;
             }
             h += spacer;
             if( panel.toggle && panel.render() && h > 0 ) {
                 if( panel.always_draw || draw_this_turn ) {
-                    panel.draw( u, catacurses::newwin( h, panel.get_width(),
-                                                       point( sidebar_right ? TERMX - panel.get_width() : 0, y ) ) );
+                    catacurses::window w = catacurses::newwin( h, panel.get_width(),
+                                           point( sidebar_right ? TERMX - panel.get_width() : 0, y ) );
+                    panel.draw( { u, w, panel.get_widget() } );
                 }
                 if( show_panel_adm ) {
                     const std::string panel_name = panel.get_name();
diff --git a/src/init.cpp b/src/init.cpp
index 4b57451a76a55..ae6777233b799 100644
--- a/src/init.cpp
+++ b/src/init.cpp
@@ -624,6 +624,7 @@ void DynamicDataLoader::unload_data()
     vpart_category::reset();
     weakpoints::reset();
     weather_types::reset();
+    widget::reset();
 }
 
 void DynamicDataLoader::finalize_loaded_data()
@@ -703,6 +704,7 @@ void DynamicDataLoader::finalize_loaded_data( loading_ui &ui )
             { _( "Anatomies" ), &anatomy::finalize_all },
             { _( "Mutations" ), &mutation_branch::finalize },
             { _( "Achievements" ), &achievement::finalize },
+            { _( "Widgets" ), &widget::finalize },
 #if defined(TILES)
             { _( "Tileset" ), &load_tileset },
 #endif
diff --git a/src/panels.cpp b/src/panels.cpp
index 9024e7525d2a2..a3fc9e8e708b9 100644
--- a/src/panels.cpp
+++ b/src/panels.cpp
@@ -85,13 +85,14 @@ static const trait_id trait_NOPAIN( "NOPAIN" );
 
 // constructor
 window_panel::window_panel(
-    const std::function<void( avatar &, const catacurses::window & )> &draw_func,
+    const std::function<void( const draw_args & )> &draw_func,
     const std::string &id, const translation &nm, const int ht, const int wd,
     const bool default_toggle_, const std::function<bool()> &render_func,
     const bool force_draw )
     : draw( draw_func ), render( render_func ), toggle( default_toggle_ ),
       always_draw( force_draw ), height( ht ), width( wd ), id( id ), name( nm )
 {
+    wgt = widget_id::NULL_ID();
 }
 
 // ====================================
@@ -106,26 +107,6 @@ static std::string trunc_ellipse( const std::string &input, unsigned int trunc )
     return input;
 }
 
-static void draw_rectangle( const catacurses::window &w, nc_color, point top_left,
-                            point bottom_right )
-{
-    // corners
-    mvwaddch( w, top_left, LINE_OXXO );
-    mvwaddch( w, point( top_left.x, bottom_right.y ), LINE_XXOO );
-    mvwaddch( w, point( bottom_right.x, top_left.y ), LINE_OOXX );
-    mvwaddch( w, bottom_right, LINE_XOOX );
-
-    for( int i = 1; i < bottom_right.x; i++ ) {
-        mvwaddch( w, point( i, top_left.y ), LINE_OXOX );
-        mvwaddch( w, point( i, bottom_right.y ), LINE_OXOX );
-    }
-
-    for( int i = 1; i < bottom_right.y; i++ ) {
-        mvwaddch( w, point( top_left.x, i ), LINE_XOXO );
-        mvwaddch( w, point( bottom_right.x, i ), LINE_XOXO );
-    }
-}
-
 std::pair<std::string, nc_color> display::str_text_color( const Character &p )
 {
     nc_color clr;
@@ -232,8 +213,17 @@ std::string window_panel::get_name() const
     return name.translated();
 }
 
-panel_layout::panel_layout( const translation &_name,
-                            const std::vector<window_panel> &_panels )
+void window_panel::set_widget( const widget_id &w )
+{
+    wgt = w;
+}
+
+const widget_id &window_panel::get_widget() const
+{
+    return wgt;
+}
+
+panel_layout::panel_layout( const translation &_name, const std::vector<window_panel> &_panels )
     : _name( _name ), _panels( _panels )
 {
 }
@@ -855,7 +845,7 @@ std::pair<std::string, nc_color> display::safe_mode_text_color( const bool class
 // panels code
 // ===============================
 
-static void draw_limb_health( avatar &u, const catacurses::window &w, bodypart_id bp )
+static void draw_limb_health( const avatar &u, const catacurses::window &w, bodypart_id bp )
 {
     const bool no_feeling = u.has_trait( trait_NOPAIN );
     static auto print_symbol_num = []( const catacurses::window & w, int num, const std::string & sym,
@@ -912,8 +902,11 @@ static void draw_limb_health( avatar &u, const catacurses::window &w, bodypart_i
     }
 }
 
-static void draw_limb2( avatar &u, const catacurses::window &w )
+static void draw_limb2( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     // print bodypart health
     int i = 0;
@@ -1440,8 +1433,11 @@ std::pair<std::string, nc_color> display::rad_badge_text_color( const Character
     return std::make_pair( rad_text, rad_color );
 }
 
-static void draw_stats( avatar &u, const catacurses::window &w )
+static void draw_stats( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     nc_color stat_clr = display::str_text_color( u ).second;
     mvwprintz( w, point_zero, c_light_gray, _( "STR" ) );
@@ -1489,8 +1485,11 @@ std::pair<std::string, nc_color> display::move_mode_text_color( const Character
     return std::make_pair( mm_text, mm_color );
 }
 
-static void draw_stealth( avatar &u, const catacurses::window &w )
+static void draw_stealth( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     mvwprintz( w, point_zero, c_light_gray, _( "Speed" ) );
     mvwprintz( w, point( 7, 0 ), value_color( u.get_speed() ), "%s", u.get_speed() );
@@ -1553,8 +1552,11 @@ static void draw_time_graphic( const catacurses::window &w )
     wprintz( w, c_white, "]" );
 }
 
-static void draw_time( const avatar &u, const catacurses::window &w )
+static void draw_time( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     // display date
     mvwprintz( w, point_zero, c_light_gray, calendar::name_season( season_of_year( calendar::turn ) ) );
@@ -1578,8 +1580,11 @@ static void draw_time( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_needs_compact( const avatar &u, const catacurses::window &w )
+static void draw_needs_compact( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
 
     auto hunger_pair = display::hunger_text_color( u );
@@ -1603,8 +1608,11 @@ static void draw_needs_compact( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_limb_narrow( avatar &u, const catacurses::window &w )
+static void draw_limb_narrow( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     int ny2 = 0;
     int i = 0;
@@ -1648,8 +1656,11 @@ static void draw_limb_narrow( avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_limb_wide( avatar &u, const catacurses::window &w )
+static void draw_limb_wide( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     int i = 0;
     for( const bodypart_id &bp :
@@ -1667,8 +1678,11 @@ static void draw_limb_wide( avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_char_narrow( avatar &u, const catacurses::window &w )
+static void draw_char_narrow( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     std::pair<std::string, nc_color> morale_pair = display::morale_face_color( u );
     // NOLINTNEXTLINE(cata-use-named-point-constants)
@@ -1708,8 +1722,12 @@ static void draw_char_narrow( avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_char_wide( avatar &u, const catacurses::window &w )
+static void draw_char_wide( const draw_args &args )
 {
+
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     std::pair<std::string, nc_color> morale_pair = display::morale_face_color( u );
     // NOLINTNEXTLINE(cata-use-named-point-constants)
@@ -1744,8 +1762,11 @@ static void draw_char_wide( avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_stat_narrow( avatar &u, const catacurses::window &w )
+static void draw_stat_narrow( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
 
     // NOLINTNEXTLINE(cata-use-named-point-constants)
@@ -1789,8 +1810,12 @@ static void draw_stat_narrow( avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_stat_wide( avatar &u, const catacurses::window &w )
+static void draw_stat_wide( const draw_args &args )
 {
+
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
 
     mvwprintz( w, point_east, c_light_gray, _( "Str  :" ) );
@@ -1832,8 +1857,11 @@ static void draw_stat_wide( avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_loc_labels( const avatar &u, const catacurses::window &w, bool minimap )
+static void draw_loc_labels( const draw_args &args, bool minimap )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     // display location
     const oter_id &cur_ter = overmap_buffer.ter( u.global_omt_location() );
@@ -1870,23 +1898,26 @@ static void draw_loc_labels( const avatar &u, const catacurses::window &w, bool
     wnoutrefresh( w );
 }
 
-static void draw_loc_narrow( const avatar &u, const catacurses::window &w )
+static void draw_loc_narrow( const draw_args &args )
 {
-    draw_loc_labels( u, w, false );
+    draw_loc_labels( args, false );
 }
 
-static void draw_loc_wide( const avatar &u, const catacurses::window &w )
+static void draw_loc_wide( const draw_args &args )
 {
-    draw_loc_labels( u, w, false );
+    draw_loc_labels( args, false );
 }
 
-static void draw_loc_wide_map( const avatar &u, const catacurses::window &w )
+static void draw_loc_wide_map( const draw_args &args )
 {
-    draw_loc_labels( u, w, true );
+    draw_loc_labels( args, true );
 }
 
-static void draw_moon_narrow( const avatar &u, const catacurses::window &w )
+static void draw_moon_narrow( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     // NOLINTNEXTLINE(cata-use-named-point-constants)
     mvwprintz( w, point( 1, 0 ), c_light_gray, _( "Moon : %s" ), display::get_moon() );
@@ -1895,8 +1926,11 @@ static void draw_moon_narrow( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_moon_wide( const avatar &u, const catacurses::window &w )
+static void draw_moon_wide( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     // NOLINTNEXTLINE(cata-use-named-point-constants)
     mvwprintz( w, point( 1, 0 ), c_light_gray, _( "Moon : %s" ), display::get_moon() );
@@ -1904,8 +1938,11 @@ static void draw_moon_wide( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_weapon_labels( const avatar &u, const catacurses::window &w )
+static void draw_weapon_labels( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     // NOLINTNEXTLINE(cata-use-named-point-constants)
     mvwprintz( w, point( 1, 0 ), c_light_gray, _( "Wield:" ) );
@@ -1916,8 +1953,11 @@ static void draw_weapon_labels( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_needs_narrow( const avatar &u, const catacurses::window &w )
+static void draw_needs_narrow( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     std::pair<std::string, nc_color> hunger_pair = display::hunger_text_color( u );
     std::pair<std::string, nc_color> thirst_pair = display::thirst_text_color( u );
@@ -1939,8 +1979,11 @@ static void draw_needs_narrow( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_needs_labels( const avatar &u, const catacurses::window &w )
+static void draw_needs_labels( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     std::pair<std::string, nc_color> hunger_pair = display::hunger_text_color( u );
     std::pair<std::string, nc_color> thirst_pair = display::thirst_text_color( u );
@@ -1966,8 +2009,11 @@ static void draw_needs_labels( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_needs_labels_alt( const avatar &u, const catacurses::window &w )
+static void draw_needs_labels_alt( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     std::pair<std::string, nc_color> hunger_pair = display::hunger_text_color( u );
     std::pair<std::string, nc_color> thirst_pair = display::thirst_text_color( u );
@@ -1991,8 +2037,11 @@ static void draw_needs_labels_alt( const avatar &u, const catacurses::window &w
     wnoutrefresh( w );
 }
 
-static void draw_sound_labels( const avatar &u, const catacurses::window &w )
+static void draw_sound_labels( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     // NOLINTNEXTLINE(cata-use-named-point-constants)
     mvwprintz( w, point( 1, 0 ), c_light_gray, _( "Sound:" ) );
@@ -2004,8 +2053,11 @@ static void draw_sound_labels( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_sound_narrow( const avatar &u, const catacurses::window &w )
+static void draw_sound_narrow( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     // NOLINTNEXTLINE(cata-use-named-point-constants)
     mvwprintz( w, point( 1, 0 ), c_light_gray, _( "Sound:" ) );
@@ -2017,8 +2069,11 @@ static void draw_sound_narrow( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_env_compact( avatar &u, const catacurses::window &w )
+static void draw_env_compact( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
 
     // Minimap to the left of text labels
@@ -2077,8 +2132,11 @@ std::pair<std::string, nc_color> display::wind_text_color( const Character &u )
     return std::make_pair( wind_text, get_wind_color( windpower ) );
 }
 
-static void render_wind( avatar &u, const catacurses::window &w, const std::string &formatstr )
+static void render_wind( const draw_args &args, const std::string &formatstr )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     mvwprintz( w, point_zero, c_light_gray,
                //~ translation should not exceed 5 console cells
@@ -2089,18 +2147,21 @@ static void render_wind( avatar &u, const catacurses::window &w, const std::stri
     wnoutrefresh( w );
 }
 
-static void draw_wind( avatar &u, const catacurses::window &w )
+static void draw_wind( const draw_args &args )
 {
-    render_wind( u, w, "%s: " );
+    render_wind( args, "%s: " );
 }
 
-static void draw_wind_padding( avatar &u, const catacurses::window &w )
+static void draw_wind_padding( const draw_args &args )
 {
-    render_wind( u, w, " %s: " );
+    render_wind( args, " %s: " );
 }
 
-static void draw_health_classic( avatar &u, const catacurses::window &w )
+static void draw_health_classic( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     vehicle *veh = g->remoteveh();
     if( veh == nullptr && u.in_vehicle ) {
         veh = veh_pointer_or_null( get_map().veh_at( u.pos() ) );
@@ -2195,8 +2256,11 @@ static void draw_health_classic( avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_armor_padding( const avatar &u, const catacurses::window &w )
+static void draw_armor_padding( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     nc_color color = c_light_gray;
     // NOLINTNEXTLINE(cata-use-named-point-constants)
@@ -2222,8 +2286,11 @@ static void draw_armor_padding( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_armor( const avatar &u, const catacurses::window &w )
+static void draw_armor( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     nc_color color = c_light_gray;
     mvwprintz( w, point_zero, color, _( "Head :" ) );
@@ -2248,8 +2315,10 @@ static void draw_armor( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_messages( avatar &, const catacurses::window &w )
+static void draw_messages( const draw_args &args )
 {
+    const catacurses::window &w = args._win;
+
     werase( w );
     int line = getmaxy( w ) - 2;
     int maxlength = getmaxx( w );
@@ -2257,8 +2326,10 @@ static void draw_messages( avatar &, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_messages_classic( avatar &, const catacurses::window &w )
+static void draw_messages_classic( const draw_args &args )
 {
+    const catacurses::window &w = args._win;
+
     werase( w );
     int line = getmaxy( w ) - 2;
     int maxlength = getmaxx( w );
@@ -2267,8 +2338,10 @@ static void draw_messages_classic( avatar &, const catacurses::window &w )
 }
 
 #if defined(TILES)
-static void draw_mminimap( avatar &, const catacurses::window &w )
+static void draw_mminimap( const draw_args &args )
 {
+    const catacurses::window &w = args._win;
+
     werase( w );
     g->draw_pixel_minimap( w );
     wnoutrefresh( w );
@@ -2276,7 +2349,7 @@ static void draw_mminimap( avatar &, const catacurses::window &w )
 #endif
 
 // Print monster info to the given window
-void display::print_mon_info( avatar &u, const catacurses::window &w, int hor_padding,
+void display::print_mon_info( const avatar &u, const catacurses::window &w, int hor_padding,
                               bool compact )
 {
     const monster_visible_info &mon_visible = u.get_mon_visible();
@@ -2435,85 +2508,63 @@ void display::print_mon_info( avatar &u, const catacurses::window &w, int hor_pa
     }
 }
 
-static void draw_compass( avatar &u, const catacurses::window &w )
+static void draw_compass( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     display::print_mon_info( u, w );
     wnoutrefresh( w );
 }
 
-static void draw_compass_compact( avatar &u, const catacurses::window &w )
+static void draw_compass_compact( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     display::print_mon_info( u, w, 0, true );
     wnoutrefresh( w );
 }
 
-static void draw_compass_padding( avatar &u, const catacurses::window &w )
+static void draw_compass_padding( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     display::print_mon_info( u, w, 1 );
     wnoutrefresh( w );
 }
 
-static void draw_compass_padding_compact( avatar &u, const catacurses::window &w )
+static void draw_compass_padding_compact( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     display::print_mon_info( u, w, 1, true );
     wnoutrefresh( w );
 }
 
-static void draw_overmap_narrow( avatar &u, const catacurses::window &w )
+static void draw_overmap( const draw_args &args )
 {
-    werase( w );
-    const tripoint_abs_omt curs = u.global_omt_location();
-    draw_rectangle( w, c_light_gray, point_zero, point( 31, 13 ) );
-    // NOLINTNEXTLINE(cata-use-named-point-constants)
-    overmap_ui::draw_overmap_chunk( w, u, curs, point( 1, 1 ), 30, 12 );
-    wnoutrefresh( w );
-}
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
 
-static void draw_overmap_wide( avatar &u, const catacurses::window &w )
-{
     werase( w );
     const tripoint_abs_omt curs = u.global_omt_location();
-    draw_rectangle( w, c_light_gray, point_zero, point( 43, 19 ) );
     // NOLINTNEXTLINE(cata-use-named-point-constants)
-    overmap_ui::draw_overmap_chunk( w, u, curs, point( 1, 1 ), 42, 18 );
+    overmap_ui::draw_overmap_chunk( w, u, curs, point_zero, getmaxx( w ) - 1, getmaxy( w ) - 1 );
     wnoutrefresh( w );
 }
 
-// Custom moddable sidebar
-static void draw_mod_sidebar( avatar &u, const catacurses::window &w, const std::string layout_name,
-                              const int width )
+static void draw_veh_compact( const draw_args &args )
 {
-    werase( w );
-
-    // Render each row of the root layout widget
-    widget root = widget_id( layout_name ).obj();
-    int row_num = 0;
-    for( const widget_id &row_wid : root._widgets ) {
-        widget row_widget = row_wid.obj();
-        trim_and_print( w, point( 1, row_num ), width - 1, c_light_gray, _( row_widget.layout( u,
-                        width - 1 ) ) );
-        row_num++;
-    }
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
 
-    wnoutrefresh( w );
-}
-
-static void draw_mod_sidebar_narrow( avatar &u, const catacurses::window &w )
-{
-    draw_mod_sidebar( u, w, "root_layout_narrow", 31 );
-}
-
-static void draw_mod_sidebar_wide( avatar &u, const catacurses::window &w )
-{
-    draw_mod_sidebar( u, w, "root_layout_wide", 43 );
-}
-
-static void draw_veh_compact( const avatar &u, const catacurses::window &w )
-{
     werase( w );
 
     // vehicle display
@@ -2530,8 +2581,11 @@ static void draw_veh_compact( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_veh_padding( const avatar &u, const catacurses::window &w )
+static void draw_veh_padding( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
 
     // vehicle display
@@ -2548,8 +2602,11 @@ static void draw_veh_padding( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_ai_goal( const avatar &u, const catacurses::window &w )
+static void draw_ai_goal( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     behavior::tree needs;
     needs.add( &behavior__node_t_npc_needs.obj() );
@@ -2560,8 +2617,11 @@ static void draw_ai_goal( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_location_classic( const avatar &u, const catacurses::window &w )
+static void draw_location_classic( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
 
     mvwprintz( w, point_zero, c_light_gray, _( "Location:" ) );
@@ -2571,8 +2631,10 @@ static void draw_location_classic( const avatar &u, const catacurses::window &w
     wnoutrefresh( w );
 }
 
-static void draw_weather_classic( avatar &, const catacurses::window &w )
+static void draw_weather_classic( const draw_args &args )
 {
+    const catacurses::window &w = args._win;
+
     werase( w );
 
     if( get_map().get_abs_sub().z < 0 ) {
@@ -2589,8 +2651,11 @@ static void draw_weather_classic( avatar &, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_lighting_classic( const avatar &u, const catacurses::window &w )
+static void draw_lighting_classic( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
 
     const std::pair<std::string, nc_color> ll = get_light_level(
@@ -2608,8 +2673,11 @@ static void draw_lighting_classic( const avatar &u, const catacurses::window &w
     wnoutrefresh( w );
 }
 
-static void draw_weapon_classic( const avatar &u, const catacurses::window &w )
+static void draw_weapon_classic( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
 
     mvwprintz( w, point_zero, c_light_gray, _( "Weapon  :" ) );
@@ -2626,8 +2694,11 @@ static void draw_weapon_classic( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_time_classic( const avatar &u, const catacurses::window &w )
+static void draw_time_classic( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
 
     // display date
@@ -2652,8 +2723,10 @@ static void draw_time_classic( const avatar &u, const catacurses::window &w )
     wnoutrefresh( w );
 }
 
-static void draw_hint( const avatar &, const catacurses::window &w )
+static void draw_hint( const draw_args &args )
 {
+    const catacurses::window &w = args._win;
+
     werase( w );
     std::string press = press_x( ACTION_TOGGLE_PANEL_ADM );
     // NOLINTNEXTLINE(cata-use-named-point-constants)
@@ -2692,29 +2765,41 @@ static void draw_weariness_partial( const avatar &u, const catacurses::window &w
     wnoutrefresh( w );
 }
 
-static void draw_weariness( const avatar &u, const catacurses::window &w )
+static void draw_weariness( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     draw_weariness_partial( u, w, point_zero, false );
     wnoutrefresh( w );
 }
 
-static void draw_weariness_narrow( const avatar &u, const catacurses::window &w )
+static void draw_weariness_narrow( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     draw_weariness_partial( u, w, point_east, false );
     wnoutrefresh( w );
 }
 
-static void draw_weariness_wide( const avatar &u, const catacurses::window &w )
+static void draw_weariness_wide( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
     draw_weariness_partial( u, w, point_east, true );
     wnoutrefresh( w );
 }
 
-static void draw_weariness_classic( const avatar &u, const catacurses::window &w )
+static void draw_weariness_classic( const draw_args &args )
 {
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
     werase( w );
 
     std::pair<std::string, nc_color> weary = display::weariness_text_color( u );
@@ -2757,24 +2842,36 @@ static void print_mana( const Character &you, const catacurses::window &w,
     wnoutrefresh( w );
 }
 
-static void draw_mana_classic( const Character &you, const catacurses::window &w )
+static void draw_mana_classic( const draw_args &args )
 {
-    print_mana( you, w, "%s: %s %s: %s", -8, -5, 20, -5 );
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
+    print_mana( u, w, "%s: %s %s: %s", -8, -5, 20, -5 );
 }
 
-static void draw_mana_compact( const Character &you, const catacurses::window &w )
+static void draw_mana_compact( const draw_args &args )
 {
-    print_mana( you, w, "%s %s %s %s", 4, -5, 12, -5 );
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
+    print_mana( u, w, "%s %s %s %s", 4, -5, 12, -5 );
 }
 
-static void draw_mana_narrow( const Character &you, const catacurses::window &w )
+static void draw_mana_narrow( const draw_args &args )
 {
-    print_mana( you, w, " %s: %s %s : %s", -5, -5, 9, -5 );
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
+    print_mana( u, w, " %s: %s %s : %s", -5, -5, 9, -5 );
 }
 
-static void draw_mana_wide( const Character &you, const catacurses::window &w )
+static void draw_mana_wide( const draw_args &args )
 {
-    print_mana( you, w, " %s: %s %s : %s", -5, -5, 13, -5 );
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+
+    print_mana( u, w, " %s: %s %s : %s", -5, -5, 13, -5 );
 }
 
 // ============
@@ -2820,12 +2917,10 @@ static std::vector<window_panel> initialize_default_classic_panels()
     ret.emplace_back( window_panel( draw_compass_padding_compact, "Alt Compass",
                                     to_translation( "Alt Compass" ),
                                     5, 44, false ) );
-    ret.emplace_back( window_panel( draw_overmap_wide, "Overmap", to_translation( "Overmap" ),
+    ret.emplace_back( window_panel( draw_overmap, "Overmap", to_translation( "Overmap" ),
                                     20, 44, false ) );
     ret.emplace_back( window_panel( draw_messages_classic, "Log", to_translation( "Log" ),
                                     -2, 44, true ) );
-    ret.emplace_back( window_panel( draw_mod_sidebar_wide, "Custom", to_translation( "Custom" ),
-                                    8, 44, false ) );
 #if defined(TILES)
     ret.emplace_back( window_panel( draw_mminimap, "Map", to_translation( "Map" ),
                                     -1, 44, true, default_render, true ) );
@@ -2866,10 +2961,8 @@ static std::vector<window_panel> initialize_default_compact_panels()
     ret.emplace_back( window_panel( draw_compass_compact, "Alt Compass",
                                     to_translation( "Alt Compass" ),
                                     5, 32, true ) );
-    ret.emplace_back( window_panel( draw_overmap_narrow, "Overmap", to_translation( "Overmap" ),
+    ret.emplace_back( window_panel( draw_overmap, "Overmap", to_translation( "Overmap" ),
                                     14, 32, false ) );
-    ret.emplace_back( window_panel( draw_mod_sidebar_narrow, "Custom", to_translation( "Custom" ),
-                                    8, 32, false ) );
 #if defined(TILES)
     ret.emplace_back( window_panel( draw_mminimap, "Map", to_translation( "Map" ),
                                     -1, 32, true, default_render, true ) );
@@ -2919,10 +3012,8 @@ static std::vector<window_panel> initialize_default_label_narrow_panels()
     ret.emplace_back( window_panel( draw_compass_padding_compact, "Alt Compass",
                                     to_translation( "Alt Compass" ),
                                     5, 32, false ) );
-    ret.emplace_back( window_panel( draw_overmap_narrow, "Overmap", to_translation( "Overmap" ),
+    ret.emplace_back( window_panel( draw_overmap, "Overmap", to_translation( "Overmap" ),
                                     14, 32, false ) );
-    ret.emplace_back( window_panel( draw_mod_sidebar_narrow, "Custom", to_translation( "Custom" ),
-                                    8, 32, false ) );
 #if defined(TILES)
     ret.emplace_back( window_panel( draw_mminimap, "Map", to_translation( "Map" ),
                                     -1, 32, true, default_render, true ) );
@@ -2976,10 +3067,8 @@ static std::vector<window_panel> initialize_default_label_panels()
     ret.emplace_back( window_panel( draw_compass_padding_compact, "Alt Compass",
                                     to_translation( "Alt Compass" ),
                                     5, 44, false ) );
-    ret.emplace_back( window_panel( draw_overmap_wide, "Overmap", to_translation( "Overmap" ),
+    ret.emplace_back( window_panel( draw_overmap, "Overmap", to_translation( "Overmap" ),
                                     20, 44, false ) );
-    ret.emplace_back( window_panel( draw_mod_sidebar_wide, "Custom", to_translation( "Custom" ),
-                                    8, 44, false ) );
 #if defined(TILES)
     ret.emplace_back( window_panel( draw_mminimap, "Map", to_translation( "Map" ),
                                     -1, 44, true, default_render, true ) );
@@ -2990,6 +3079,58 @@ static std::vector<window_panel> initialize_default_label_panels()
     return ret;
 }
 
+// Message on how to use the custom sidebar panel and edit its JSON
+static void draw_custom_hint( const draw_args &args )
+{
+    const catacurses::window &w = args._win;
+
+    werase( w );
+    // NOLINTNEXTLINE(cata-use-named-point-constants)
+    mvwprintz( w, point( 1, 0 ), c_white, _( "Custom sidebar" ) );
+    // NOLINTNEXTLINE(cata-use-named-point-constants)
+    mvwprintz( w, point( 1, 1 ), c_light_gray,
+               _( "Edit sidebar.json to adjust." ) );
+    mvwprintz( w, point( 1, 2 ), c_light_gray,
+               _( "See SIDEBAR_MOD.md for help." ) );
+
+    wnoutrefresh( w );
+}
+
+// Initialize custom panels from a given "sidebar" style widget
+static std::vector<window_panel> initialize_default_custom_panels( const widget &wgt )
+{
+    std::vector<window_panel> ret;
+
+    // Use defined width, or at least 16
+    const int width = std::max( wgt._width, 16 );
+
+    // Show hint on configuration
+    ret.emplace_back( window_panel( draw_custom_hint, "Hint", to_translation( "Hint" ),
+                                    3, width, true ) );
+
+    // Add window panel for each child widget
+    for( const widget_id &row_wid : wgt._widgets ) {
+        widget row_widget = row_wid.obj();
+        ret.emplace_back( row_widget.get_window_panel( width ) );
+    }
+
+    // Add compass, message log, and map to fill remaining space
+    // TODO: Make these into proper widgets
+    ret.emplace_back( window_panel( draw_messages, "Log", to_translation( "Log" ),
+                                    -2, width, true ) );
+#if defined(TILES)
+    ret.emplace_back( window_panel( draw_mminimap, "Map", to_translation( "Map" ),
+                                    -1, width, true, default_render, true ) );
+#endif // TILES
+    ret.emplace_back( window_panel( draw_compass_padding_compact, "Compass",
+                                    to_translation( "Compass" ),
+                                    5, width, true ) );
+    ret.emplace_back( window_panel( draw_overmap, "Overmap", to_translation( "Overmap" ),
+                                    7, width, false ) );
+
+    return ret;
+}
+
 static std::map<std::string, panel_layout> initialize_default_panel_layouts()
 {
     std::map<std::string, panel_layout> ret;
@@ -3003,13 +3144,22 @@ static std::map<std::string, panel_layout> initialize_default_panel_layouts()
     ret.emplace( "labels", panel_layout( to_translation( "labels" ),
                                          initialize_default_label_panels() ) );
 
+    // Add panel layout for each "sidebar" widget
+    for( const widget &wgt : widget::get_all() ) {
+        if( wgt._style == "sidebar" ) {
+            ret.emplace( wgt._label.translated(),
+                         panel_layout( wgt._label, initialize_default_custom_panels( wgt ) ) );
+        }
+    }
+
     return ret;
 }
 
 panel_manager::panel_manager()
 {
     current_layout_id = "labels";
-    layouts = initialize_default_panel_layouts();
+    // Set empty layouts; these will be populated by load()
+    layouts = std::map<std::string, panel_layout>();
 }
 
 panel_layout &panel_manager::get_current_layout()
@@ -3046,6 +3196,7 @@ int panel_manager::get_width_left()
 
 void panel_manager::init()
 {
+    layouts = initialize_default_panel_layouts();
     load();
     update_offsets( get_current_layout().panels().begin()->get_width() );
 }
@@ -3152,7 +3303,7 @@ void panel_manager::show_adm()
     ctxt.register_action( "MOVE_PANEL" );
     ctxt.register_action( "TOGGLE_PANEL" );
 
-    const std::vector<int> column_widths = { 17, 37, 17 };
+    const std::vector<int> column_widths = { 25, 37, 17 };
 
     size_t current_col = 0;
     size_t current_row = 0;
@@ -3174,7 +3325,7 @@ void panel_manager::show_adm()
     const int popup_height = 24;
     ui_adaptor ui;
     ui.on_screen_resize( [&]( ui_adaptor & ui ) {
-        w = catacurses::newwin( popup_height, 75,
+        w = catacurses::newwin( popup_height, 83,
                                 point( ( TERMX / 2 ) - 38, ( TERMY / 2 ) - 10 ) );
 
         ui.position_from_window( w );
diff --git a/src/panels.h b/src/panels.h
index 0847fbd557b20..c7d8f6845d97d 100644
--- a/src/panels.h
+++ b/src/panels.h
@@ -12,6 +12,7 @@
 #include "color.h"
 #include "coordinates.h"
 #include "translations.h"
+#include "widget.h"
 
 class JsonIn;
 class JsonOut;
@@ -116,7 +117,7 @@ std::pair<std::string, nc_color> rad_badge_text_color( const Character &u );
 std::string weight_string( const Character &u );
 
 // Prints a list of nearby monsters
-void print_mon_info( avatar &u, const catacurses::window &, int hor_padding = 0,
+void print_mon_info( const avatar &u, const catacurses::window &, int hor_padding = 0,
                      bool compact = false );
 } // namespace display
 
@@ -129,30 +130,57 @@ void draw_overmap_chunk( const catacurses::window &w_minimap, const avatar &you,
 
 bool default_render();
 
+// Arguments to pass into the static draw function (in window_panel::draw)
+// Includes public avatar (_ava) and window (_win) references, and private widget reference
+// passed to the constructor, accessible with get_widget().
+struct draw_args {
+    public:
+        const avatar &_ava;
+        const catacurses::window &_win;
+
+        draw_args( const avatar &a, const catacurses::window &w, const widget_id &wgt ) :
+            _ava( a ), _win( w ), _wgt( wgt ) {}
+
+        widget *get_widget() const {
+            return _wgt.is_null() ? nullptr : const_cast<widget *>( &*_wgt );
+        }
+    private:
+        widget_id _wgt;
+};
+
+// A window_panel is a rectangular region or drawable area within the sidebar window.
+// It corresponds to a section that the player may toggle or rearrange from the in-game sidebar options.
+// It is associated with a draw function (taking draw_args with avatar and window), along with id and name.
+// The height, width, and default toggle state must be provided to the constructor as well.
 class window_panel
 {
     public:
-        window_panel( const std::function<void( avatar &, const catacurses::window & )> &draw_func,
+        window_panel( const std::function<void( const draw_args & )> &draw_func,
                       const std::string &id, const translation &nm, int ht, int wd, bool default_toggle_,
                       const std::function<bool()> &render_func = default_render, bool force_draw = false );
 
-        std::function<void( avatar &, const catacurses::window & )> draw;
+        std::function<void( const draw_args & )> draw;
         std::function<bool()> render;
 
         int get_height() const;
         int get_width() const;
         const std::string &get_id() const;
         std::string get_name() const;
+        void set_widget( const widget_id &w );
+        const widget_id &get_widget() const;
         bool toggle;
         bool always_draw;
 
     private:
         int height;
         int width;
+        widget_id wgt;
         std::string id;
         translation name;
 };
 
+// A panel_layout is a collection of window_panels drawn in order from top to bottom.
+// It is associated with the user-selectable layouts named "classic", "compact", "labels", etc.
 class panel_layout
 {
     public:
@@ -167,6 +195,9 @@ class panel_layout
         std::vector<window_panel> _panels;
 };
 
+// The panel_manager allows the player choose their current panel layout and window panels.
+// The player's selected panel_layout, enabled window_panels and what order they appear in,
+// are saved to the PATH_INFO::panel_options() file, typically config/panel_options.json.
 class panel_manager
 {
     public:
@@ -205,6 +236,7 @@ class panel_manager
         std::string current_layout_id;
         std::map<std::string, panel_layout> layouts;
 
+        friend widget;
 };
 
 #endif // CATA_SRC_PANELS_H
diff --git a/src/string_id_null_ids.cpp b/src/string_id_null_ids.cpp
index 3c9486c521d9d..126a4a3b08d6a 100644
--- a/src/string_id_null_ids.cpp
+++ b/src/string_id_null_ids.cpp
@@ -21,7 +21,7 @@ MAKE_NULL_ID( harvest_list, "null" )
 MAKE_NULL_ID( Item_spawn_data, "null" )
 MAKE_NULL_ID( effect_type, "null" )
 MAKE_NULL_ID( material_type, "null" )
-
+MAKE_NULL_ID( widget, "null" )
 MAKE_NULL_ID( monfaction, "" )
 MAKE_NULL_ID( nested_mapgen, "null" )
 MAKE_NULL_ID( overmap_land_use_code, "" )
diff --git a/src/widget.cpp b/src/widget.cpp
index 537d5e23a08bb..ddef3a1be28bc 100644
--- a/src/widget.cpp
+++ b/src/widget.cpp
@@ -36,6 +36,11 @@ void widget::reset()
     widget_factory.reset();
 }
 
+const std::vector<widget> &widget::get_all()
+{
+    return widget_factory.get_all();
+}
+
 // Convert widget "var" enums to string equivalents
 namespace io
 {
@@ -183,6 +188,11 @@ void widget::load( const JsonObject &jo, const std::string & )
     }
 }
 
+void widget::finalize()
+{
+    // Nothing to do?
+}
+
 int widget::get_var_max( const avatar &ava )
 {
     // Some vars (like HP) have an inherent maximum, used unless the widget overrides it
@@ -329,6 +339,70 @@ std::string widget::show( const avatar &ava )
     }
 }
 
+// Drawing function, provided as a callback to the window_panel constructor.
+// Handles rendering a widget's content into a window panel.
+static void custom_draw_func( const draw_args &args )
+{
+    const avatar &u = args._ava;
+    const catacurses::window &w = args._win;
+    widget *wgt = args.get_widget();
+
+    // Get full window width
+    const int width = catacurses::getmaxx( w );
+    // Leave 1 character space for margin on left and right
+    const int margin = 1;
+    const int widt = width - 2 * margin;
+
+    // Quit if there is nothing to draw or no space to draw it
+    if( wgt == nullptr || width <= 0 ) {
+        return;
+    }
+
+    werase( w );
+    if( wgt->_style == "sidebar" ) {
+    } else if( wgt->_style == "layout" ) {
+        if( wgt->_arrange == "rows" ) {
+            // Layout widgets in rows
+            // FIXME: Be able to handle rows that are themselves more than one line!
+            // Could this be done in the layout() function somehow (by returning newlines?)
+            int row_num = 0;
+            for( const widget_id &row_wid : wgt->_widgets ) {
+                widget row_widget = row_wid.obj();
+                trim_and_print( w, point( margin, row_num ), widt, c_light_gray, row_widget.layout( u,
+                                widt ) );
+                row_num++;
+            }
+        } else {
+            // Layout widgets in columns
+            // For now, this is the default when calling layout()
+            // So, just layout self on a single line
+            trim_and_print( w, point( margin, 0 ), widt, c_light_gray, _( wgt->layout( u, widt ) ) );
+        }
+    } else {
+        // No layout, just a widget - simply layout self on a single line
+        trim_and_print( w, point( margin, 0 ), widt, c_light_gray, _( wgt->layout( u, widt ) ) );
+    }
+    wnoutrefresh( w );
+}
+
+window_panel widget::get_window_panel( const int width, const int req_height )
+{
+    // Width is fixed, but height may vary depending on child widgets
+    int height = req_height;
+
+    // For layout with rows, height will be number of rows
+    // (assuming each row is only 1 line)
+    if( _style == "layout" && _arrange == "rows" ) {
+        height = _widgets.size();
+    }
+    // Minimap and log do not have a predetermined height
+    // (or they should allow caller to customize height)
+
+    window_panel win( custom_draw_func, _label.translated(), _label, height, width, true );
+    win.set_widget( this->id );
+    return win;
+}
+
 bool widget::uses_text_function()
 {
     switch( _var ) {
@@ -575,9 +649,22 @@ std::string widget::layout( const avatar &ava, const unsigned int max_width )
 {
     std::string ret;
     if( _style == "layout" ) {
-        // Divide max_width equally among all widgets
-        int child_width = max_width / _widgets.size();
-        int remainder = max_width % _widgets.size();
+        // Widgets with "rows" arrangement must be laid out from window_panel
+        if( _arrange == "rows" ) {
+            debugmsg( "widget layout called with rows" );
+        }
+        const int num_widgets = _widgets.size();
+        if( num_widgets == 0 ) {
+            debugmsg( "widget layout has no widgets" );
+        }
+        // Number of spaces between columns
+        const int col_padding = 2;
+        // Subtract column padding to get space available for widgets
+        const int avail_width = max_width - col_padding * ( num_widgets - 1 );
+        // Divide available width equally among all widgets
+        const int child_width = avail_width / num_widgets;
+        // Keep remainder to distribute
+        int remainder = avail_width % num_widgets;
         for( const widget_id &wid : _widgets ) {
             widget cur_child = wid.obj();
             int cur_width = child_width;
@@ -586,11 +673,11 @@ std::string widget::layout( const avatar &ava, const unsigned int max_width )
                 cur_width += 1;
                 remainder -= 1;
             }
-            // Allow 2 spaces of padding after each column, except last column (full-justified)
+            // Layout child in this column
+            ret += string_format( "%s", cur_child.layout( ava, cur_width ) );
+            // Add column padding until we reach the last column
             if( wid != _widgets.back() ) {
-                ret += string_format( "%s  ", cur_child.layout( ava, cur_width - 2 ) );
-            } else {
-                ret += string_format( "%s", cur_child.layout( ava, cur_width ) );
+                ret += std::string( col_padding, ' ' );
             }
         }
     } else {
diff --git a/src/widget.h b/src/widget.h
index 57e097ec427d8..5c8fe7f6fdb08 100644
--- a/src/widget.h
+++ b/src/widget.h
@@ -9,6 +9,7 @@
 //#include "cata_variant.h"
 #include "enum_traits.h"
 #include "generic_factory.h"
+#include "panels.h"
 #include "string_id.h"
 #include "translations.h"
 #include "type_id.h"
@@ -79,12 +80,16 @@ class JsonObject;
 template<typename T>
 class generic_factory;
 
+// Forward declaration, due to codependency on panels.h
+class window_panel;
+
 // A widget is a UI element displaying information from the underlying value of a widget_var.
 // It may be loaded from a JSON object having "type": "widget".
 class widget
 {
     private:
         friend class generic_factory<widget>;
+        friend void custom_draw_fn( avatar &u, const catacurses::window &w, const widget &wgt );
 
         widget_id id;
         bool was_loaded = false;
@@ -125,8 +130,12 @@ class widget
         // Load JSON data for a widget (uses generic factory widget_factory)
         static void load_widget( const JsonObject &jo, const std::string &src );
         void load( const JsonObject &jo, const std::string &src );
+        // Finalize anything that must wait until all widgets are loaded
+        static void finalize();
         // Reset to defaults using generic widget_factory
         static void reset();
+        // Get all widget instances from the factory
+        static const std::vector<widget> &get_all();
 
         // Layout this widget within max_width, including child widgets. Calling layout on a regular
         // (non-layout style) widget is the same as show(), but will pad with spaces inside the
@@ -134,6 +143,8 @@ class widget
         std::string layout( const avatar &ava, unsigned int max_width = 0 );
         // Display labeled widget, with value (number, graph, or string) from an avatar
         std::string show( const avatar &ava );
+        // Return a window_panel for rendering this widget at given width (and possibly height)
+        window_panel get_window_panel( const int width, const int req_height = 1 );
         // Return a colorized string for a _var associated with a description function
         std::string color_text_function_string( const avatar &ava );
         // Return true if the current _var is one which uses a description function
diff --git a/tests/widget_test.cpp b/tests/widget_test.cpp
index 2e15749826767..0044ebda34bc7 100644
--- a/tests/widget_test.cpp
+++ b/tests/widget_test.cpp
@@ -7,6 +7,9 @@
 static const itype_id itype_rad_badge( "rad_badge" );
 
 // test widgets defined in data/json/sidebar.json and data/mods/TEST_DATA/widgets.json
+static const widget_id widget_test_2_column_layout( "test_2_column_layout" );
+static const widget_id widget_test_3_column_layout( "test_3_column_layout" );
+static const widget_id widget_test_4_column_layout( "test_4_column_layout" );
 static const widget_id widget_test_bp_wetness_head_num( "test_bp_wetness_head_num" );
 static const widget_id widget_test_bp_wetness_torso_num( "test_bp_wetness_torso_num" );
 static const widget_id widget_test_bucket_graph( "test_bucket_graph" );
@@ -393,20 +396,120 @@ TEST_CASE( "radiation badge widget", "[widget][radiation]" )
     CHECK( rads_w.layout( ava ) == "RADIATION: <color_c_pink> black </color>" );
 }
 
-TEST_CASE( "layout widgets", "[widget][layout]" )
+// Widgets with "layout" style can combine other widgets in columns or rows.
+//
+// Using "arrange": "columns", width is divided as equally as possible among widgets.
+// With C columns, (C-1)*2 characters are allotted for space between columns (__):
+//
+//     C=2: FIRST__SECOND
+//     C=3: FIRST__SECOND__THIRD
+//     C=4: FIRST__SECOND__THIRD__FOURTH
+//
+// So total width available to each column is:
+//
+//     (W - (C-1)*2) / C
+//
+// At 24 width, 2 columns, each column gets (24 - (2-1)*2) / 2 == 11 characters.
+// At 36 width, 2 columns, each column gets (36 - (2-1)*2) / 2 == 17 characters.
+// At 36 width, 3 columns, each column gets (36 - (3-1)*2) / 3 == 10 characters.
+//
+// This test case calls layout() at different widths for 2-, 3-, and 4-column layouts,
+// to verify and demonstrate how the space is distributed among widgets in columns.
+//
+TEST_CASE( "layout widgets in columns", "[widget][layout][columns]" )
 {
-    widget stats_w = widget_test_stat_panel.obj();
+    widget stat_w = widget_test_stat_panel.obj();
+    widget two_w = widget_test_2_column_layout.obj();
+    widget three_w = widget_test_3_column_layout.obj();
+    widget four_w = widget_test_4_column_layout.obj();
 
     avatar &ava = get_avatar();
     clear_avatar();
 
-    CHECK( stats_w.layout( ava, 32 ) ==
-           string_format( "STR: 8  DEX: 8  INT: 8  PER:   8" ) );
-    CHECK( stats_w.layout( ava, 38 ) ==
-           string_format( "STR:   8  DEX:   8  INT:  8  PER:    8" ) );
-    CHECK( stats_w.layout( ava, 40 ) ==
-           string_format( "STR:   8  DEX:   8  INT:   8  PER:     8" ) );
-    CHECK( stats_w.layout( ava, 42 ) ==
-           string_format( "STR:    8  DEX:    8  INT:   8  PER:     8" ) );
+    ava.str_max = 8;
+    ava.dex_max = 8;
+    ava.int_max = 8;
+    ava.per_max = 8;
+    ava.movecounter = 50;
+    ava.set_focus( 120 );
+    ava.set_speed_base( 100 );
+    ava.magic->set_mana( 1000 );
+
+    // Two columns
+    // string ruler:                   123456789012345678901234567890123456
+    CHECK( two_w.layout( ava, 24 ) == "MOVE:    50  SPEED:  100" );
+    CHECK( two_w.layout( ava, 25 ) == "MOVE:     50  SPEED:  100" );
+    CHECK( two_w.layout( ava, 26 ) == "MOVE:     50  SPEED:   100" );
+    CHECK( two_w.layout( ava, 27 ) == "MOVE:      50  SPEED:   100" );
+    CHECK( two_w.layout( ava, 28 ) == "MOVE:      50  SPEED:    100" );
+    CHECK( two_w.layout( ava, 29 ) == "MOVE:       50  SPEED:    100" );
+    CHECK( two_w.layout( ava, 30 ) == "MOVE:       50  SPEED:     100" );
+    CHECK( two_w.layout( ava, 31 ) == "MOVE:        50  SPEED:     100" );
+    CHECK( two_w.layout( ava, 32 ) == "MOVE:        50  SPEED:      100" );
+    CHECK( two_w.layout( ava, 33 ) == "MOVE:         50  SPEED:      100" );
+    CHECK( two_w.layout( ava, 34 ) == "MOVE:         50  SPEED:       100" );
+    CHECK( two_w.layout( ava, 35 ) == "MOVE:          50  SPEED:       100" );
+    CHECK( two_w.layout( ava, 36 ) == "MOVE:          50  SPEED:        100" );
+    // string ruler:                   123456789012345678901234567890123456
+
+    // Three columns
+    // string ruler:                     1234567890123456789012345678901234567890
+    CHECK( three_w.layout( ava, 36 ) == "MOVE:    50  SPEED:  100  FOCUS: 120" );
+    CHECK( three_w.layout( ava, 37 ) == "MOVE:    50  SPEED:  100  FOCUS:  120" );
+    CHECK( three_w.layout( ava, 38 ) == "MOVE:     50  SPEED:  100  FOCUS:  120" );
+    CHECK( three_w.layout( ava, 39 ) == "MOVE:     50  SPEED:   100  FOCUS:  120" );
+    CHECK( three_w.layout( ava, 40 ) == "MOVE:     50  SPEED:   100  FOCUS:   120" );
+    CHECK( three_w.layout( ava, 41 ) == "MOVE:      50  SPEED:   100  FOCUS:   120" );
+    CHECK( three_w.layout( ava, 42 ) == "MOVE:      50  SPEED:    100  FOCUS:   120" );
+    CHECK( three_w.layout( ava, 43 ) == "MOVE:      50  SPEED:    100  FOCUS:    120" );
+    CHECK( three_w.layout( ava, 44 ) == "MOVE:       50  SPEED:    100  FOCUS:    120" );
+    CHECK( three_w.layout( ava, 45 ) == "MOVE:       50  SPEED:     100  FOCUS:    120" );
+    CHECK( three_w.layout( ava, 46 ) == "MOVE:       50  SPEED:     100  FOCUS:     120" );
+    // string ruler:                     1234567890123456789012345678901234567890123456
+
+    // Four columns
+    // string ruler:                    123456789012345678901234567890123456789012
+    CHECK( stat_w.layout( ava, 32 ) == "STR:  8  DEX:  8  INT: 8  PER: 8" );
+    CHECK( stat_w.layout( ava, 33 ) == "STR:  8  DEX:  8  INT:  8  PER: 8" );
+    CHECK( stat_w.layout( ava, 34 ) == "STR:  8  DEX:  8  INT:  8  PER:  8" );
+    CHECK( stat_w.layout( ava, 35 ) == "STR:   8  DEX:  8  INT:  8  PER:  8" );
+    CHECK( stat_w.layout( ava, 36 ) == "STR:   8  DEX:   8  INT:  8  PER:  8" );
+    CHECK( stat_w.layout( ava, 37 ) == "STR:   8  DEX:   8  INT:   8  PER:  8" );
+    CHECK( stat_w.layout( ava, 38 ) == "STR:   8  DEX:   8  INT:   8  PER:   8" );
+    CHECK( stat_w.layout( ava, 39 ) == "STR:    8  DEX:   8  INT:   8  PER:   8" );
+    CHECK( stat_w.layout( ava, 40 ) == "STR:    8  DEX:    8  INT:   8  PER:   8" );
+    CHECK( stat_w.layout( ava, 41 ) == "STR:    8  DEX:    8  INT:    8  PER:   8" );
+    CHECK( stat_w.layout( ava, 42 ) == "STR:    8  DEX:    8  INT:    8  PER:    8" );
+    CHECK( stat_w.layout( ava, 43 ) == "STR:     8  DEX:    8  INT:    8  PER:    8" );
+    CHECK( stat_w.layout( ava, 44 ) == "STR:     8  DEX:     8  INT:    8  PER:    8" );
+    CHECK( stat_w.layout( ava, 45 ) == "STR:     8  DEX:     8  INT:     8  PER:    8" );
+    CHECK( stat_w.layout( ava, 46 ) == "STR:     8  DEX:     8  INT:     8  PER:     8" );
+    // string ruler:                    1234567890123456789012345678901234567890123456
+
+    // Column alignment
+    // Layout keeps labels vertically aligned for layouts with the same number of widgets
+    // string ruler:                    123456789012345678901234567890123456789012345678
+    CHECK( stat_w.layout( ava, 48 ) == "STR:      8  DEX:      8  INT:     8  PER:     8" );
+    CHECK( four_w.layout( ava, 48 ) == "MOVE:    50  SPEED:  100  FOCUS: 120  MANA: 1000" );
+
+    // string ruler:                    1234567890123456789012345678901234567890123456789012
+    CHECK( stat_w.layout( ava, 52 ) == "STR:       8  DEX:       8  INT:      8  PER:      8" );
+    CHECK( four_w.layout( ava, 52 ) == "MOVE:     50  SPEED:   100  FOCUS:  120  MANA:  1000" );
+
+    // string ruler:                    12345678901234567890123456789012345678901234567890123456
+    CHECK( stat_w.layout( ava, 56 ) == "STR:        8  DEX:        8  INT:       8  PER:       8" );
+    CHECK( four_w.layout( ava, 56 ) == "MOVE:      50  SPEED:    100  FOCUS:   120  MANA:   1000" );
+
+    // string ruler:                    123456789012345678901234567890123456789012345678901234567890
+    CHECK( stat_w.layout( ava, 60 ) == "STR:         8  DEX:         8  INT:        8  PER:        8" );
+    CHECK( four_w.layout( ava, 60 ) == "MOVE:       50  SPEED:     100  FOCUS:    120  MANA:    1000" );
+
+    // TODO: Consider re-distributing space so values are closer to labels, like this:
+    // 48 width
+    //     "STR: 8      DEX: 8      INT: 8      PER: 8      "
+    //     "MOVE: 0     SPEED: 100  FOCUS: 100  MANA: 1000  "
+    // 60 width
+    //     "STR: 8         DEX: 8         INT: 8         PER: 8         "
+    //     "MOVE: 0        SPEED: 100     FOCUS: 100     MANA: 1000     "
 }