Repository: projectmesa/mesa
Pull request #1332 (2022-05-19 09:31:39)

feat: Implement Checkbox UserParam class

codecov[bot]: # [Codecov](https://codecov.io/gh/projectmesa/mesa/pull/1332?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) Report
> Merging [#1332](https://codecov.io/gh/projectmesa/mesa/pull/1332?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) (b5bb744) into [main](https://codecov.io/gh/projectmesa/mesa/commit/59b58bc37ba92b01d82b7804d4f53e7bc765c2bb?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) (59b58bc) will **increase** coverage by `0.12%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##             main    #1332      +/-   ##
==========================================
+ Coverage   89.52%   89.65%   +0.12%     
==========================================
  Files          19       19              
  Lines        1289     1305      +16     
  Branches      252      253       +1     
==========================================
+ Hits         1154     1170      +16     
  Misses        100      100              
  Partials       35       35              
```


| [Impacted Files](https://codecov.io/gh/projectmesa/mesa/pull/1332?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) | Coverage Δ | |
|---|---|---|
| [mesa/visualization/UserParam.py](https://codecov.io/gh/projectmesa/mesa/pull/1332/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa#diff-bWVzYS92aXN1YWxpemF0aW9uL1VzZXJQYXJhbS5weQ==) | `93.39% <100.00%> (+1.17%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/projectmesa/mesa/pull/1332?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/projectmesa/mesa/pull/1332?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa). Last update [59b58bc...b5bb744](https://codecov.io/gh/projectmesa/mesa/pull/1332?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa).


Patch:
diff --git a/examples/wolf_sheep/wolf_sheep/server.py b/examples/wolf_sheep/wolf_sheep/server.py
index 2bfb446a..fcdda90a 100644
--- a/examples/wolf_sheep/wolf_sheep/server.py
+++ b/examples/wolf_sheep/wolf_sheep/server.py
@@ -48,9 +48,7 @@ chart_element = mesa.visualization.ChartModule(
 )
 
 model_params = {
-    "grass": mesa.visualization.UserSettableParameter(
-        "checkbox", "Grass Enabled", True
-    ),
+    "grass": mesa.visualization.Checkbox("Grass Enabled", True),
     "grass_regrowth_time": mesa.visualization.Slider("Grass Regrowth Time", 20, 1, 50),
     "initial_sheep": mesa.visualization.Slider(
         "Initial Sheep Population", 100, 10, 300
diff --git a/mesa/visualization/UserParam.py b/mesa/visualization/UserParam.py
index 5f9812b7..a4eb7c6e 100644
--- a/mesa/visualization/UserParam.py
+++ b/mesa/visualization/UserParam.py
@@ -139,6 +139,19 @@ class UserParam:
         )  # Return _value as value, value is the same
         return result
 
+    def maybe_raise_error(self, valid):
+        if not valid:
+            msg = self._ERROR_MESSAGE.format(self.param_type, self.name)
+            raise ValueError(msg)
+
+    @property
+    def value(self):
+        return self._value
+
+    @value.setter
+    def value(self, value):
+        self._value = value
+
 
 class Slider(UserParam):
     """
@@ -170,10 +183,7 @@ class Slider(UserParam):
         valid = not (
             self.value is None or self.min_value is None or self.max_value is None
         )
-
-        if not valid:
-            msg = self._ERROR_MESSAGE.format(self.param_type, name)
-            raise ValueError(msg)
+        self.maybe_raise_error(valid)
 
     @property
     def value(self):
@@ -186,3 +196,23 @@ class Slider(UserParam):
             self._value = self.min_value
         elif self._value > self.max_value:
             self._value = self.max_value
+
+
+class Checkbox(UserParam):
+    """
+    Boolean checkbox.
+
+    Example:
+
+    boolean_option = Checkbox('My Boolean', True)
+    """
+
+    def __init__(self, name="", value=None, description=None):
+        self.param_type = CHECKBOX
+        self.name = name
+        self._value = value
+        self.description = description
+
+        # Validate option types to make sure values are supplied properly
+        valid = isinstance(self.value, bool)
+        self.maybe_raise_error(valid)
diff --git a/mesa/visualization/templates/js/runcontrol.js b/mesa/visualization/templates/js/runcontrol.js
index bb308f74..5689b83c 100644
--- a/mesa/visualization/templates/js/runcontrol.js
+++ b/mesa/visualization/templates/js/runcontrol.js
@@ -188,25 +188,30 @@ const initGUI = function (model_params) {
 
   const addBooleanInput = function (param, obj) {
     const domID = param + "_id";
-    sidebar.append(
-      [
-        "<div>",
-        "<p><label for='" +
-          domID +
-          "' class='badge badge-primary'>" +
-          obj.name +
-          "</label></p>",
-        "<input class='model-parameter' id='" + domID + "' type='checkbox'/>",
-        "</div>",
-      ].join("")
-    );
-    $("#" + domID).bootstrapSwitch({
-      state: obj.value,
-      size: "small",
-      onSwitchChange: function (e, state) {
-        onSubmitCallback(param, state);
-      },
+    const _switch = document.createElement("div");
+    _switch.className = "form-check form-switch";
+
+    const label = `
+      <label class="form-check-label badge bg-primary" for="${domID}">
+        ${obj.name}
+      </label>
+    `;
+    _switch.innerHTML += label;
+
+    const input = document.createElement("input");
+    Object.assign(input, {
+      className: "form-check-input model-parameter",
+      type: "checkbox",
+      id: domID,
+      checked: obj.value,
     });
+    input.setAttribute("role", "switch");
+    input.addEventListener("change", (event) =>
+      onSubmitCallback(param, event.currentTarget.checked)
+    );
+    _switch.appendChild(input);
+
+    sidebar.append(_switch);
   };
 
   const addNumberInput = function (param, obj) {
@@ -216,7 +221,7 @@ const initGUI = function (model_params) {
         "<div>",
         "<p><label for='" +
           domID +
-          "' class='badge badge-primary'>" +
+          "' class='badge bg-primary'>" +
           obj.name +
           "</label></p>",
         "<input class='model-parameter' id='" + domID + "' type='number'/>",
@@ -236,16 +241,16 @@ const initGUI = function (model_params) {
     let tooltip = "";
     // Enable tooltip label
     if (obj.description !== null) {
-      tooltip = `title='${obj.description}'`
+      tooltip = `title='${obj.description}'`;
     }
 
     sidebar.append(
       [
         "<div>",
         "<p>",
-        `<a id='${tooltipID}' ${tooltip} data-toggle='tooltip' data-placement='top' class='badge badge-primary'>`,
+        `<span id='${tooltipID}' ${tooltip} data-bs-toggle='tooltip' data-bs-placement='top' class='badge bg-primary'>`,
         obj.name,
-        "</a>",
+        "</span>",
         "</p>",
         "<input id='" + domID + "' type='text' />",
         "</div>",
@@ -274,13 +279,13 @@ const initGUI = function (model_params) {
     const template = [
       "<p><label for='" +
         domID +
-        "' class='badge badge-primary'>" +
+        "' class='badge bg-primary'>" +
         obj.name +
         "</label></p>",
       "<div class='dropdown'>",
       "<button id='" +
         domID +
-        "' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>" +
+        "' class='btn btn-default dropdown-toggle' type='button' data-bs-toggle='dropdown'>" +
         obj.value +
         " " +
         span,
@@ -292,7 +297,7 @@ const initGUI = function (model_params) {
       const choiceID = domID + "_choice_" + i;
       choiceIdentifiers.push(choiceID);
       template.push(
-        "<li role='presentation'><a class='pick-choice' id='" +
+        "<li role='presentation'><a class='pick-choice dropdown-item' id='" +
           choiceID +
           "' role='menuitem' tabindex='-1' href='#'>",
         obj.choices[i],
diff --git a/mesa/visualization/templates/modular_template.html b/mesa/visualization/templates/modular_template.html
index 5b75089e..c126958b 100644
--- a/mesa/visualization/templates/modular_template.html
+++ b/mesa/visualization/templates/modular_template.html
@@ -1,8 +1,7 @@
 <!DOCTYPE html>
 <head>
 	<title>{{ model_name }} (Mesa visualization)</title>
-    <link href="/static/external/bootstrap-4.6.1-dist/css/bootstrap.min.css" type="text/css" rel="stylesheet" />
-    <link href="/static/external/bootstrap-switch-3.3.4/dist/css/bootstrap3/bootstrap-switch.min.css" type="text/css" rel="stylesheet" />
+    <link href="/static/external/bootstrap-5.1.3-dist/css/bootstrap.min.css" type="text/css" rel="stylesheet" />
     <link href="/static/external/bootstrap-slider-11.0.2/dist/css/bootstrap-slider.min.css" type="text/css" rel="stylesheet" />
     <link href="/static/css/visualization.css" type="text/css" rel="stylesheet" />
 
@@ -21,10 +20,10 @@
 <body>
 
     <!-- Navbar -->
-    <nav class="navbar navbar-dark bg-dark navbar-static-top navbar-expand-md mb-3">
+    <nav class="navbar navbar-dark bg-dark navbar-static-top navbar-expand-lg mb-3">
         <div class="container">
-            <button type="button" class="navbar-toggler collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
-                <span class="sr-only">Toggle navigation</span>
+            <button type="button" class="navbar-toggler collapsed" data-bs-toggle="collapse" data-bs-target="#navbar" aria-expanded="false" aria-controls="navbar">
+                <span class="visually-hidden">Toggle navigation</span>
                 &#x2630;
             </button>
             <a class="navbar-brand" href="#">{{ model_name }}</a>
@@ -32,12 +31,12 @@
             <div id="navbar" class="navbar-collapse collapse">
                 <ul class="nav navbar-nav">
                     <li class="nav-item">
-                        <a href="#" data-toggle="modal" data-target="#about" data-title="About" data-content="#about-content" class="nav-link">
+                        <a href="#" data-bs-toggle="modal" data-bs-target="#about" data-bs-title="About" data-bs-content="#about-content" class="nav-link">
                             About
                         </a>
                     </li>
                 </ul>
-                <ul class="nav navbar-nav ml-auto">
+                <ul class="nav navbar-nav ms-auto">
                     <li id="play-pause" class="nav-item"><a href="#" class="nav-link">Start</a>
                     </li>
                     <li id="step" class="nav-item"><a href="#" class="nav-link">Step</a>
@@ -45,7 +44,7 @@
                     <li id="reset" class="nav-item"><a href="#" class="nav-link">Reset</a>
                     </li>
                 </ul>
-            </div><!--/.nav-collapse -->
+            </div>
         </div>
     </nav>
     <div class="container d-flex flex-row">
@@ -53,7 +52,7 @@
         <div class="col-xl-8 col-lg-8 col-md-8 col-9" id="elements">
             <div id="elements-topbar">
                 <div">
-                    <label class="badge badge-primary" for="fps" style="margin-right: 15px">Frames Per Second</label>
+                    <label class="badge bg-primary" for="fps" style="margin-right: 15px">Frames Per Second</label>
                     <input id="fps" data-slider-id="fps" type="text">
                 </div>
                 <p>Current Step: <span id="currentStep">0</span>
@@ -63,12 +62,12 @@
     </div>
 
     <!-- About modal -->
-    <div id="about" class="modal fade" tabindex="-1" role="dialog">
+    <div id="about" class="modal fade" tabindex="-1" role="dialog" aria-hidden="true">
         <div class="modal-dialog modal-lg">
             <div class="modal-content">
                 <div class="modal-header">
                     <h4 class="modal-title">About {{ model_name }}</h4>
-                    <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&#xD7;</span>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
                     </button>
                 </div>
                 <div class="modal-body">
@@ -82,8 +81,7 @@
 
     <!-- Bottom-load all JavaScript dependencies -->
     <script src="/static/js/external/jquery-2.2.4.min.js"></script>
-    <script src="/static/external/bootstrap-4.6.1-dist/js/bootstrap.bundle.min.js"></script>
-    <script src="/static/external/bootstrap-switch-3.3.4/dist/js/bootstrap-switch.min.js"></script>
+    <script src="/static/external/bootstrap-5.1.3-dist/js/bootstrap.bundle.min.js"></script>
     <script src="/static/external/bootstrap-slider-11.0.2/dist/bootstrap-slider.min.js"></script>
 
     <!-- Script includes go here -->
diff --git a/setup.py b/setup.py
index 044566e4..50f988e0 100644
--- a/setup.py
+++ b/setup.py
@@ -66,7 +66,7 @@ def ensure_JS_dep_single(url, out_name=None):
 # hardcoded included files and versions in: mesa/visualization/templates/modular_template.html
 
 # Ensure Bootstrap
-bootstrap_version = "4.6.1"
+bootstrap_version = "5.1.3"
 ensure_JS_dep(
     f"bootstrap-{bootstrap_version}-dist",
     f"https://github.com/twbs/bootstrap/releases/download/v{bootstrap_version}/bootstrap-{bootstrap_version}-dist.zip",
@@ -79,13 +79,6 @@ ensure_JS_dep(
     f"https://github.com/seiyria/bootstrap-slider/archive/refs/tags/v{bootstrap_slider_version}.zip",
 )
 
-# Ensure Bootstrap Switch
-bootstrap_switch_version = "3.3.4"
-ensure_JS_dep(
-    f"bootstrap-switch-{bootstrap_switch_version}",
-    f"https://github.com/Bttstrp/bootstrap-switch/archive/refs/tags/v{bootstrap_switch_version}.zip",
-)
-
 jquery_version = "2.2.4"
 ensure_JS_dep_single(
     f"https://code.jquery.com/jquery-{jquery_version}.min.js",


Test patch:
diff --git a/tests/test_usersettableparam.py b/tests/test_usersettableparam.py
index 99f8ee22..64c6de66 100644
--- a/tests/test_usersettableparam.py
+++ b/tests/test_usersettableparam.py
@@ -1,11 +1,12 @@
 from unittest import TestCase
-from mesa.visualization.UserParam import UserSettableParameter, Slider
+from mesa.visualization.UserParam import UserSettableParameter, Slider, Checkbox
 
 
 class TestOption(TestCase):
     def setUp(self):
         self.number_option = UserSettableParameter("number", value=123)
         self.checkbox_option = UserSettableParameter("checkbox", value=True)
+        self.checkbox_option_standalone = Checkbox(value=True)
         self.choice_option = UserSettableParameter(
             "choice",
             value="I am your default choice",
@@ -22,9 +23,10 @@ class TestOption(TestCase):
         assert self.number_option.value == 321
 
     def test_checkbox(self):
-        assert self.checkbox_option.value
-        self.checkbox_option.value = False
-        assert not self.checkbox_option.value
+        for option in [self.checkbox_option, self.checkbox_option_standalone]:
+            assert option.value
+            option.value = False
+            assert not option.value
 
     def test_choice(self):
         assert self.choice_option.value == "I am your default choice"