Hello, loves! Tout le monde déteste l’IA.

Added in Post:
This first bit is all waste. The scheme that seems to work works by accident and isn’t the right approach at all. Skip to 0400.

We need to get the boundary behavior right. This may mean I have to understand my code. Arrgh. Long, but rather nice.

If you are like me—and if you are, you have my sincere sympathy—and if you’ve done much graphics coding at all, there have been places where you fiddled with the calls and numbers and then backed away slowly, without quite understanding why the code worked. Just me? OK.

I am confident that at our normal scale of 4, the screen behaves as intended: the view scrolls side to side and up and down, leaving Dot in the center of the screen … unless said scrolling would leave the view looking outside the space of possible cells: we never try to look at cell -1, or cell max+1, instead stopping with cell 0 or cell max on the edge. Once the edge point is reached, Dot moves toward the edge rather than always staying centered and the dungeon moving around her.

I freely grant that I got that behavior by bashing things. It wasn’t random bashing, quite. The values used are sensible ones, the cell size, the min and max cell numbers, the cell size, and such. But the way they get combined and used isn’t clear to me, and I am not entirely clear about the camera methods and properties used.

At scales greater than 1, we need to ensure that we never try to draw cells that are outside the bounds [0, max). At scale 1, the dungeon exactly fits the screen. At scale 4, the scrolling works. Let’s see what happens at scale 2.

Well! I am surprised to find that it works perfectly for scale 2! I still want to understand but at least we don’t have to change it.

I suspect that scale 3 will be a bit off, and it is:

map with Dot at edge showing 1/2 cell in from border

In the picture above, there is a half-cell gap between the edge cell and the border. That might be acceptable, but it’s not right. I think it’ll be wrong because there’s an 0.5 coming up somewhere in the calculations. Something like that. Maybe.

Let’s check the code that handles this issue, then probably we’ll instrument it (ok, add some print statements to it) to see if we can see where it’s going wrong.

class Cameras:
    def init_zoom(self, zoom):
        self.dungeon_cam.zoom = zoom
        width_margin = self.compute_margin(self.max_x, zoom)
        height_margin = self.compute_margin(self.max_y, zoom)
        print(f'{self.view.width=}, {self.view.height=} {width_margin=} {height_margin=}')
        self.dungeon_camera_bounds = (
            self.margin_rectangle(self.view.width / 2, self.view.height, width_margin, height_margin)
        )

    def compute_margin(self, cell_count, zoom):
        cells_visible = cell_count // zoom
        desired_margin = cells_visible // 2
        pixel_margin = desired_margin * cell_size
        print(f'{cells_visible=}, {desired_margin=}, {pixel_margin=}')
        return pixel_margin

    def margin_rectangle(self, view_width, view_height, width_margin, height_margin):
        shift = -14*8*4/self.dungeon_cam.zoom # move rectangle left to shift the viewpoint right
        rect = arcade.LRBT(shift + width_margin, shift + view_width - width_margin, height_margin, view_height - height_margin)
        return rect

I think I see the issue, in compute_margin. What happens if we change it like this:

    def compute_margin(self, cell_count, zoom):
        cells_visible = cell_count // zoom
        desired_margin = cells_visible / 2
        pixel_margin = desired_margin * cell_size
        print(f'{cells_visible=}, {desired_margin=}, {pixel_margin=}')
        return pixel_margin

That has no useful effect. Good try, but didn’t win the big stuffed rabbit. I add a print of rect and run, getting this very telling info dump:

zoom=4, cells_visible=14, desired_margin=7.0, pixel_margin=112.0
zoom=4, cells_visible=14, desired_margin=7.0, pixel_margin=112.0
self.view.width=1792, self.view.height=896 width_margin=112.0 height_margin=112.0
rect=Rect(left=0.0, right=672.0, bottom=112.0, top=784.0, width=672.0, height=672.0, x=336.0, y=448.0)

zoom=3, cells_visible=18, desired_margin=9.0, pixel_margin=144.0
zoom=3, cells_visible=18, desired_margin=9.0, pixel_margin=144.0
self.view.width=1792, self.view.height=896 width_margin=144.0 height_margin=144.0
rect=Rect(left=-5.333333333333343, right=602.6666666666666, bottom=144.0, top=752.0, width=608.0, height=608.0, x=298.66666666666663, y=448.0)

zoom=2, cells_visible=28, desired_margin=14.0, pixel_margin=224.0
zoom=2, cells_visible=28, desired_margin=14.0, pixel_margin=224.0
self.view.width=1792, self.view.height=896 width_margin=224.0 height_margin=224.0
rect=Rect(left=0.0, right=448.0, bottom=224.0, top=672.0, width=448.0, height=448.0, x=224.0, y=448.0)

zoom=1, cells_visible=56, desired_margin=28.0, pixel_margin=448.0
zoom=1, cells_visible=56, desired_margin=28.0, pixel_margin=448.0
self.view.width=1792, self.view.height=896 width_margin=448.0 height_margin=448.0
rect=Rect(left=0.0, right=0.0, bottom=448.0, top=448.0, width=0.0, height=0.0, x=0.0, y=448.0)

I fiddled with the settings for a bit. Then I stopped to study how that .grips.constrain_xy works. Then I drew a picture. Studied some more. Finally concluded that the code shown above is really the wrong way to do it. Went to bed.


0400 Hours. Really.

Added in Post:
What follows is long but just because I refactor the code in very tiny steps, showing each one, unless I accidentally skipped one. So you can scan, if you’re even still here.

The above was done starting at 0900 Thursday, with studying going on Thursday afternoon and evening. It is now 0400 hours on Friday. I am awake for no particular reason and so I decided to get up and try something new. I am a morning person, but this is a bit much. Still, I was just awake enough to realize that I wasn’t gong to drop back to sleep.

Here is my new plan, as always rough and sure to change:

  1. Rip out the mini-map.
  2. Reduce the window back down to one pane wide.
  3. Make the minimal changes to put the zoomable map back inside the window.
  4. Change the window centering to use the right approach.

I think “the right approach” is, approximately, set the camera center to the desired position, unless that position is too close to the edge, in which case set it a fixed distance from the edge, equal to half the view size.

I am at a save point, nothing bad committed. Let’s get to it.

In DungeonView:

class DungeonView(arcade.View):
    def __init__(self, dungeon):
        if arcade.window_commands._window:
            super().__init__()
            self.cameras = Cameras(self, dungeon.max_x, dungeon.max_y, zoom=params.zoom_factor)
        self.dungeon = dungeon
        self.pub_sub = dungeon.pub_sub
        self.keys =  None
        self.keyed_sprites = None # updated below
        KeyedSpriteListMaker(dungeon).update(self)
        self.subscribe(self.dungeon, self.pub_sub)
        if arcade.window_commands._window:
            self.section_manager = SectionManager(self)
            self.scroller_section = ScrollerSection(
                self.window.width/2, 0,
                self.window.width/2, self.window.height,
                name="RightScroller",
                prevent_dispatch_view=set()
            )
            self.section_manager.add_section(
                self.scroller_section
            )
            self.mini_map_section = MiniMapSection(
                self,
                0, 0, self.window.width/2, self.window.height,
                name="MiniMap",
                prevent_dispatch_view=set()
            )
            self.section_manager.add_section(
                self.mini_map_section
            )

Remove the last two statements, removing the minimap. Change the scroller section to start at 0,0 and be the width of the window, which we need to change in a moment.

        if arcade.window_commands._window:
            self.section_manager = SectionManager(self)
            self.scroller_section = ScrollerSection(
                0, 0,
                self.window.width, self.window.height,
                name="Scroller",
                prevent_dispatch_view=set()
            )
            self.section_manager.add_section(
                self.scroller_section
            )

In main, change the window size to square:

def main():
    # random.seed(35)
    # random.seed(234)
    layout = DungeonLayout(56, 56)
    dungeon = Dungeon(layout)
    stepper = make_build_table(layout, dungeon)
    screen_width = cell_size*dungeon.max_x
    screen_height = cell_size*dungeon.max_y
    arcade.Window(screen_width, screen_height, 'Caveat Emptor')
    ...

At this point I expect to see the scroller but no map, but I won’t be surprised at something worse. It’s different, not necessarily worse:

single-width map, divided in half, scroller correct, nothing on left, big map on right

Interesting but not sought after, as my sainted brother once said about green shoes. Let’s look at the view and see what we can remove to fix this up. I think we’ll remove the ctx stuff that splits the screen for sure, and the vertical line. And some shift ideas. Here is on_draw before any changes:

    def on_draw(self):
        self.clear()
        arcade.draw_line(self.window.width/2, 0, self.window.width/2, self.window.height, arcade.color.WHITE)
        player_cell = self.dungeon.player_cell
        if player_cell:
            self.cameras.scroll_dungeon_cam(self.dungeon.player_cell)
        with self.cameras.dungeon_cam.activate():
            ctx = self.window.ctx
            ctx.scissor = (
                self.window.width/2, 0, self.window.width/2, self.window.height
            )
            self.keyed_sprites.draw()
            self.draw_adventurer()
            self.draw_flood()
            ctx.scissor = None

Remove the ctx scissoring stuff, and the line.

    def on_draw(self):
        self.clear()
        player_cell = self.dungeon.player_cell
        if player_cell:
            self.cameras.scroll_dungeon_cam(self.dungeon.player_cell)
        with self.cameras.dungeon_cam.activate():
            self.keyed_sprites.draw()
            self.draw_adventurer()
            self.draw_flood()

This may draw a full window, off center, but again I won’t be surprised at much.

single-width map, full size dungeon segment, not centered on Dot

That’s about what I expected. Now we need to look inside the Cameras object, where the map scrolling happens. Note that DungeonView is scrolling and drawing with the Cameras dungeon_cam. There is currently only one camera in Cameras, but we’ll not concern ourselves with that detail just now. Here’s the code from Cameras:

class Cameras:
    def __init__(self, view, max_x, max_y, zoom):
        self.view = view
        self.max_x = max_x
        self.max_y = max_y
        self.scroller = Scroller(lines=4, base=(512,800))
        self.dungeon_cam = arcade.Camera2D()
        self.init_zoom(zoom)

    def init_zoom(self, zoom):
        self.dungeon_cam.zoom = zoom
        width_margin = self.compute_margin(self.max_x, zoom)
        height_margin = self.compute_margin(self.max_y, zoom)
        print(f'{self.view.width=}, {self.view.height=} {width_margin=} {height_margin=}')
        self.dungeon_camera_bounds = (
            self.margin_rectangle(self.view.width / 2, self.view.height, width_margin, height_margin)
        )

    def compute_margin(self, cell_count, zoom):
        cells_visible = cell_count // zoom
        desired_margin = cells_visible // 2
        pixel_margin = desired_margin * cell_size
        print(f'{desired_margin=}, {cells_visible=}, {pixel_margin=}')
        return pixel_margin

    def margin_rectangle(self, view_width, view_height, width_margin, height_margin):
        shift = -14*8*4/self.dungeon_cam.zoom # move rectangle left to shift the viewpoint right
        rect = arcade.LRBT(shift + width_margin, shift + view_width - width_margin, height_margin, view_height - height_margin)
        return rect

    def scroll_dungeon_cam(self, cell):
        cx, cy = cell.xy
        # shift = (cx - self.max_x)*cell_size/4
        shift = -14*8*4/self.dungeon_cam.zoom  # TODO
        self.dungeon_cam.position = (shift + cx * cell_size, cy * cell_size)
        # self.dungeon_cam.position = (cx * cell_size, cy * cell_size)
        self.dungeon_cam.position = (
            arcade.camera.grips.constrain_xy(
                self.dungeon_cam.view_data, self.dungeon_camera_bounds
            )
        )

I think we’ll rip out most everything here and set the dungeon_cam position to the desired position and leave it there. I think it might just display Dot. We’ll see. As changed:

class Cameras:
    def __init__(self, view, max_x, max_y, zoom):
        self.view = view
        self.max_x = max_x
        self.max_y = max_y
        self.scroller = Scroller(lines=4, base=(512,800))
        self.dungeon_cam = arcade.Camera2D()
        self.init_zoom(zoom)

    def init_zoom(self, zoom):
        self.dungeon_cam.zoom = zoom

    def scroll_dungeon_cam(self, cell):
        cx, cy = cell.xy
        self.dungeon_cam.position = (cx * cell_size, cy * cell_size)

I was thinking at some earlier point, why doesn’t Cell know how to return the screen-scaled x and y? We’ll try to remember that. What does this do on screen?

single-width map, full size dungeon segment, nearly? centered on Dot

That looks nearly right but I’m not sure whether it’s centered as we’d like. Odd. Let’s draw some cross-hairs. It is OK, this is what I expect:

single-width map, full size dungeon segment, cross-hairs at Dot's tile

We center on Dot’s position, which is actually the lower left corner of her tile. We may find that we need to adjust that, but it has been how we do it all along.

I think that if Dot were to walk to a border, the border would come in to meet her, which we do not want. Even before Dot gets there, I see that when we zoom out we are not getting the leftmost tile. Perhaps others as well. Compare these two pics.

single-width map, zoomed-out dungeon segment, nearly? centered on Dot

single-width map, zoomed-out dungeon segment, nearly? centered on Dot, showing one more tile on left

Note that after I move Dot left, we see one more tile there. That is odd. Moving right along …

single-width map, zoomed-out dungeon segment, showing black space to left

We do not want that black space on the left and top. I am more concerned about the missing bit when zoomed out. That’s the first surprise we’ve had this morning.

After walking Dot around a bit, I realize that the bug is in my head (not in the RFK Jr sense). Dot does not start at center screen. So until we do something about the margins, we cannot expect the zoomed out screen to be centered.

We have reached step 4: Change the window centering to use the right approach.

Imagine a small square, our window/view, floating over a large square, our world. Our window is 16 (a random number chosen to be seriously divisible by 2) times the number of cells in x and y, which is 56. The window is nominally 896 pixels wide. (On my retina screen it is actually 2016, for reasons that only Apple knows.) At zoom 4, our starting size, we have 14x14 tiles on the screen, because 56 over 4 is 14. When we are near a border, therefore, we want to stop scrolling when half that many tiles are showing on that near size.

Here is where we set the view position:

class Cameras:
    def scroll_dungeon_cam(self, cell):
        cx, cy = cell.xy
        self.dungeon_cam.position = (cx * cell_size, cy * cell_size)

We want to limit that position so that it doesn’t get too close to zero or to 896. At zoom 4, we want 7 squares on each side. Let me just hammer that in: it helps me think about these things.

    def scroll_dungeon_cam(self, cell):
        cx, cy = cell.xy
        mx = self.max_x*cell_size
        my = self.max_y*cell_size
        margin = 7*16
        px = cx * cell_size
        if px < margin: px = margin
        if px > mx - margin: px = mx - margin
        py = cy * cell_size
        if py < margin: py = margin
        if py > my - margin: py = my - margin
        self.dungeon_cam.position = (px, py)

That’s the code for zoom 4, where we need 7 cells. It works. Of course the other zooms are only using seven, so they do not look right.

At zoom 2, the margin should be 14, not 7, since twice as many tiles show up at 2 compared to 4. So the equation is this:

        mx = self.max_x*cell_size
        margin_x = mx/(2*self.dungeon_cam.zoom)

And the method is this:

    def scroll_dungeon_cam(self, cell):
        cx, cy = cell.xy
        mx = self.max_x*cell_size
        my = self.max_y*cell_size
        margin_x = mx/(2*self.dungeon_cam.zoom)
        margin_y = my/(2*self.dungeon_cam.zoom)
        px = cx * cell_size
        if px < margin_x: px = margin_x
        if px > mx - margin_x: px = mx - margin_x
        py = cy * cell_size
        if py < margin_y: py = margin_y
        if py > my - margin_y: py = my - margin_y
        self.dungeon_cam.position = (px, py)

And that works as intended. We can improve this code. (I should hope so!) Let’s see if we can do it in nice tiny steps.

Note:
Nice little refactoring sequence here, small steps to better code.

Let’s reorder things.

    def scroll_dungeon_cam(self, cell):
        cx, cy = cell.xy
        
        mx = self.max_x*cell_size
        margin_x = mx/(2*self.dungeon_cam.zoom)
        px = cx * cell_size
        if px < margin_x: px = margin_x
        if px > mx - margin_x: px = mx - margin_x

        my = self.max_y*cell_size
        margin_y = my/(2*self.dungeon_cam.zoom)
        py = cy * cell_size
        if py < margin_y: py = margin_y
        if py > my - margin_y: py = my - margin_y
        self.dungeon_cam.position = (px, py)

PyCharm notes the duplication, doesn’t offer a solution. Isn’t that first conditional max?

        mx = self.max_x*cell_size
        margin_x = mx/(2*self.dungeon_cam.zoom)
        px = cx * cell_size
        px = max(px, margin_x)
        if px > mx - margin_x: px = mx - margin_x

And isn’t that last line min?

        mx = self.max_x*cell_size
        margin_x = mx/(2*self.dungeon_cam.zoom)
        px = cx * cell_size
        px = max(px, margin_x)
        px = min(mx - margin_x, px)

And can’t we inline that?

        mx = self.max_x*cell_size
        margin_x = mx/(2*self.dungeon_cam.zoom)
        px = cx * cell_size
        px = min(mx - margin_x, max(px, margin_x))

And can’t we inline px again?

        mx = self.max_x*cell_size
        margin_x = mx/(2*self.dungeon_cam.zoom)
        px = min(mx - margin_x, max(cx * cell_size, margin_x))

Can we make a method of those last two lines? Maybe. But first, let’s get some help from Cell.

class Cell:
    @property
    def position(self):
        return self.x*cell_size, self.y*cell_size

Use that:

    def scroll_dungeon_cam(self, cell):
        px, py = cell.position

        mx = self.max_x*cell_size
        margin_x = mx/(2*self.dungeon_cam.zoom)
        px = min(mx - margin_x, max(px, margin_x))

        my = self.max_y*cell_size
        margin_y = my/(2*self.dungeon_cam.zoom)
        if py < margin_y: py = margin_y
        if py > my - margin_y: py = my - margin_y
        self.dungeon_cam.position = (px, py)

Extract method:

    def scroll_dungeon_cam(self, cell):
        px, py = cell.position

        mx = self.max_x*cell_size
        px = self.apply_margin(mx, px)

        my = self.max_y*cell_size
        margin_y = my/(2*self.dungeon_cam.zoom)
        if py < margin_y: py = margin_y
        if py > my - margin_y: py = my - margin_y
        self.dungeon_cam.position = (px, py)

    def apply_margin(self, mx, px):
        margin_x = mx / (2 * self.dungeon_cam.zoom)
        px = min(mx - margin_x, max(px, margin_x))
        return px

Might best test this, I’m pretty sure I did this all correctly but I did type a couple of things: it wasn’t all machine refactoring. Works. Apply the function in the second bit.

    def scroll_dungeon_cam(self, cell):
        px, py = cell.position
        mx = self.max_x*cell_size
        px = self.apply_margin(mx, px)
        my = self.max_y*cell_size
        py = self.apply_margin(my, py)
        self.dungeon_cam.position = (px, py)

    def apply_margin(self, mx, px):
        margin_x = mx / (2 * self.dungeon_cam.zoom)
        px = min(mx - margin_x, max(px, margin_x))
        return px

I think we should extract all that computation from the scroll method.

    def scroll_dungeon_cam(self, cell):
        px, py = self.clamped_position(cell)
        self.dungeon_cam.position = (px, py)

    def clamped_position(self, cell):
        px, py = cell.position
        mx = self.max_x * cell_size
        px = self.apply_margin(mx, px)
        my = self.max_y * cell_size
        py = self.apply_margin(my, py)
        return px, py

I see duplication. Remove it with an Extract Method? Wait, let’s do this, reorder:

    def clamped_position(self, cell):
        px, py = cell.position
        mx = self.max_x * cell_size
        my = self.max_y * cell_size
        px = self.apply_margin(mx, px)
        py = self.apply_margin(my, py)
        return px, py

Extract:

    def clamped_position(self, cell):
        px, py = cell.position
        mx, my = self.max_position()
        px = self.apply_margin(mx, px)
        py = self.apply_margin(my, py)
        return px, py

    def max_position(self):
        mx = self.max_x * cell_size
        my = self.max_y * cell_size
        return mx, my

    def apply_margin(self, mx, px):
        margin_x = mx / (2 * self.dungeon_cam.zoom)
        px = min(mx - margin_x, max(px, margin_x))
        return px

A couple of renames and inlining there in the last method:

    def scroll_dungeon_cam(self, cell):
        px, py = self.clamped_position(cell)
        self.dungeon_cam.position = (px, py)

    def clamped_position(self, cell):
        px, py = cell.position
        mx, my = self.max_position()
        px = self.apply_margin(mx, px)
        py = self.apply_margin(my, py)
        return px, py

    def max_position(self):
        mx = self.max_x * cell_size
        my = self.max_y * cell_size
        return mx, my

    def apply_margin(self, max_coord, coord):
        margin = max_coord / (2 * self.dungeon_cam.zoom)
        return min(max_coord - margin, max(coord, margin))
Note:
Sequence ends. Code is neater, most of the work was by machine refactoring so quite solid.

We’ll test again, anyway. Works. Remove the cross-hairs. Commit: save point. It’s 0700 and I think I might go back to bed. Or something.

Reflection Before Break

I am pleased with this result. It even works at zoom factor 3, which makes me think it’ll work at any factor. Our original plan, you may recall, was to zoom based on scrolling the mouse. We’ll look at that, and reflect on the code, when next I’m awake.

But I think I understand the code we have. I think it’s pretty readable, and I think I could explain it. That’s far better than what I had before, because I could never get it past “nearly always working”.

Not bad at all for being done at oh-dark-hundred hours.


Around 1500 Hours

Note:
Another nice refactoring sequence below.

Slept from about 0700 to 10-something, now back here at the keyboard. I typed in this code:

class DungeonView:
    def on_mouse_scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> bool | None:
        self.cameras.zoom(scroll_y)

class Cameras:
    def zoom(self, amount):
        if amount != 0:
            zoom = self.dungeon_cam.zoom
            zoom += amount/10
            zoom = max(1, min(zoom, 4))
            self.init_zoom(zoom)

We already had init_zoom, used in the debug key-stroke code, hardly worth noticing now that zoom works:

class Cameras:
    def init_zoom(self, zoom):
        self.dungeon_cam.zoom = zoom

And of course the actual handling of the zoom factor, as shown above. And it works:

Note:
What follows is fun, I try around eight or ten different ways of writing this one method. Good practice, good brain exercise. for those of us who program with our brains, not some borrowed fake one.

Let’s simplify the zoom method with some inlining. I guess since we’re just setting it, we can remove the check for zero. A few steps:

Expand expression.

    def zoom(self, amount):
        zoom = self.dungeon_cam.zoom
        zoom = zoom + amount/10
        zoom = max(1, min(zoom, 4))
        self.init_zoom(zoom)

Inline.

    def zoom(self, amount):
        zoom = self.dungeon_cam.zoom + amount / 10
        zoom = max(1, min(zoom, 4))
        self.init_zoom(zoom)

Inline.

    def zoom(self, amount):
        zoom = max(1, min(self.dungeon_cam.zoom + amount / 10, 4))
        self.init_zoom(zoom)

Inline.

    def zoom(self, amount):
        self.init_zoom(max(1, min(self.dungeon_cam.zoom + amount / 10, 4)))

Hm, I don’t quite like that. Too many magic numbers. Let’s extract some variables:

    def zoom(self, amount):
        min_zoom = 1
        max_zoom = 4
        scale = 10
        self.init_zoom(max(min_zoom, min(self.dungeon_cam.zoom + amount / scale, max_zoom)))

Just for fun let’s expand that max/min, even though it is pretty idiomatic.

    def zoom(self, scroll_movement):
        min_zoom = 1
        max_zoom = 4
        scale = 10
        scaled_adjustment = scroll_movement / scale
        no_more_than_max = min(self.dungeon_cam.zoom + scaled_adjustment, max_zoom)
        no_less_than_min = max(min_zoom, no_more_than_max)
        self.init_zoom(no_less_than_min)

So there are seven different ways of expressing the idea. There might be more that one could prefer, including changing the max and min to if/else or, if truly perverse, match-case. Oh, one more thing, the name of the method zoom. Should be something like adjust_zoom, and I choose this compromise method:

    def adjust_zoom(self, scroll_movement):
        min_zoom = 1
        max_zoom = 4
        scale = 10
        self.init_zoom(max(min_zoom, min(self.dungeon_cam.zoom + scroll_movement / scale, max_zoom)))

Name the magic numbers, assume that the max/min construct is well-known. Good enough. Commit: Mouse scroll wheel controls zoom between scale 1 to 4.

Summary

I think we’re done here. The zooming works nicely with scrolling of the Magic Mouse. The code is much simpler than the former attempt, which I believe was taking the wrong approach and could never have been made to work quite right. This way is how you really do it.

Maybe I’ll follow up with a short article on how it works, why it’s what one does.

This article got out of hand. If you got to the end, you have my thanks and admiration.

See you next time!