Nifty Idea
Based on a thing I did yesterday, I have an idea that I think will be quite nice. BorderedText, good yesterday and gratuitously better today.
The plain white text for the Scroller doesn’t have very good contrast:

Arcade and pyglet don’t offer a Text with a different stroke from fill color, but there’s a suggested trick where you draw the text five times, four times offset in one color, then once centered in the contrasting color. Yesterday afternoon I did that, and the result is quite nice:

I addressed the problem yesterday while you were away. Let’s take a look at how I did it then. The overview is this:
- During
announce: -
Upon receiving the
announcemessage that gives Scroller a text line to display, Scroller creates an Arcade.Text item, doing the conversion to text just once perannounce, storing the Text item in its buffer. Scroller also calculates the center X value at that time. - During
draw: -
Scroller iterates over as many lines as it is expected to display. It fetches each line, calculates its current y position (as it scrolls up). It then calls
draw_outlined, which draws the text the requisite five times and returns. - During
update: - No change here, we increment the y_bump quantity and when it hits the maximum, we flush the top line from our buffer and, if the buffer needs a line, we announce a blank line to keep the buffer sufficiently full.
Let’s have a look at the relevant code.
class Scroller:
def __init__(self, lines=4, base=(512,800)):
self.buffer = []
self.base_x, self.base_y = base
self.blank = arcade.Text('', x=self.base_x, y=self.base_y, font_size=24)
for i in range(lines):
self.buffer.append(self.blank)
self.lines = lines
self.line_height = 36
self.y_bump = 0
def announce(self, message: str) -> None:
text = arcade.Text(message, x=self.base_x, y=self.base_y, font_size=24)
text.x = text.x - text.content_width // 2
self.buffer.append(text)
def draw(self):
for line in range(self.lines):
text = self.message(line)
y_pos = self.y_position(line)
text.y = y_pos
self.draw_outlined(text)
def draw_outlined(self, text: arcade.Text):
offset = 1
offsets = [
(offset, offset),
(offset,-offset),
(-offset, offset),
(-offset, -offset),
]
text.color = arcade.color.BLACK
for dx,dy in offsets:
text.x += dx
text.y += dy
text.draw()
text.x -= dx
text.y -= dy
text.color = arcade.color.WHITE
text.draw()
draw_outlined expects the input text to be positioned at the desired central position for the line, so each time through the offsetting loop, draw_outlined offsets the text a bit, draws it, then puts it back, maintaining that expectation. After offsetting to the four corners, it draws the text one more time, in place.
As we saw in the second picture above, this works perfectly well. However …
It could be better. For each of the four lines we currently scroll, we call text.draw five times, for a total of <thinks> at least twenty calls to the draw. While saving the Text object is faster than draw_text, because we only do the rendering once, it’s not as fast as it might be.
Now I freely grant that we have no reason to believe it isn’t fast enough. We have no real reason to make it faster. But I have this idea …
- The Batch
- Arcade and pyglet have the notion of a Batch. It is claimed that you could add thousands of Text objects to a batch and its drawing cost would be little more than just drawing one. That’s all very well, you say, but 20? How much could it cost?
-
And you’re right, entirely right. But I have this idea, and it seems nifty.
-
What if … just if … instead of creating a single Text in the buffer, we created a batch? Could we then just update the coordinates as needed and draw the batch all at once?
My plan, roughly, is this:
We currently have a Text instance in each element of our buffer. We’ll create a new object that holds five Text instances and a batch containing those five. When it comes time to draw, we’ll update the individual Text instance’s y position and then draw the batch.
We’ll have to hold onto the instances, because you can’t access what’s inside the batch, which, I gather, is some amazingly complicated graphical thingie. But, if my reading of the documentation is accurate, it will notice that you’ve updated your original instances and do the right thing.
So, just because it’s there, I plan to do it. If I needed to rationalize the decision, I’d say something about learning to use Batch, which is surely a key thing to know. And, who knows, it might come in handy someday. My real reason is that I want to.
I think I’ll TDD this, not because there’s much to it, but because working with the new class on its own seems easier then just trying to patch it right in.
class TestBatch:
def test_batch(self):
msg = 'hello world'
batch = TextBatch(msg)
assert isinstance(batch.batch, Batch)
There’s no way to inspect what’s in a Batch but we can at least be sure we have one. So far the class is this:
class TextBatch:
def __init__(self, msg):
self.msg = msg
self.batch = Batch()
We want to have five Texts in the TextBatch, so let’s posit a list and size:
class TestBatch:
def test_batch(self):
msg = 'hello world'
batch = TextBatch(msg)
assert isinstance(batch.batch, Batch)
assert len(batch.texts) ==5
But let’s create them, not just fake them.
class TextBatch:
def __init__(self, msg):
self.msg = msg
self.batch = Batch()
self.texts = []
b = arcade.color.BLACK
w = arcade.color.WHITE
plan = [
( 1, 1, b),
( 1,-1, b),
(-1, 1, b),
(-1,-1, b),
( 0, 0, w)
]
for dx, dy, color in plan:
text = arcade.Text(self.msg, dx, dy, color, batch=self.batch)
self.texts.append(text)
We can see here why I like to create new object inside a test file. I can write things out in a safe place, trying things, learning what I need. What we have now is five texts with x and y 1, -1, or 0, with the ones with the 1 x and y in black and the 0,0 one in white. And they are all in the batch.
That’s not quite what we need. I think we need to provide our base x value and to do the centering as we create these guys. I’ll change the init, and the code in the loop.
class TextBatch:
def __init__(self, msg, base_x=512):
self.msg = msg
self.base_x = base_x
self.batch = Batch()
self.texts = []
b = arcade.color.BLACK
w = arcade.color.WHITE
plan = [
( 1, 1, b),
( 1,-1, b),
(-1, 1, b),
(-1,-1, b),
( 0, 0, w)
]
for dx, dy, color in plan:
text = arcade.Text(self.msg, self.base_x+dx, dy, color, batch=self.batch)
text.x = text.x - text.content_width // 2
self.texts.append(text)
I need a test for this last bit. Oh, and I need the font_size as well.
class TestBatch:
def test_batch(self):
arcade.Window()
msg = 'hello world'
test_line = arcade.Text(msg,0, 0, arcade.color.BLACK, font_size=24)
offset = test_line.content_width // 2
batch = TextBatch(msg, 24, 666)
assert isinstance(batch.batch, Batch)
assert len(batch.texts) ==5
white_one = batch.texts[4]
assert white_one.x == 666 - offset
And the class so far:
class TextBatch:
def __init__(self, msg, font_size=24, base_x=512):
self.msg = msg
self.font_size = font_size
self.base_x = base_x
self.batch = Batch()
self.texts = []
b = arcade.color.BLACK
w = arcade.color.WHITE
plan = [
( 1, 1, b),
( 1,-1, b),
(-1, 1, b),
(-1,-1, b),
( 0, 0, w)
]
for dx, dy, color in plan:
text = arcade.Text(self.msg, self.base_x+dx, dy, color,font_size=self.font_size, batch=self.batch)
text.x = text.x - text.content_width // 2
self.texts.append(text)
I think this is getting close to what we need, and I’m quite glad that I didn’t struggle this out while trying to integrate it as well. We need a draw method that plugs in the y position for each item, draws the batch, and backs out the y position. I’ll just code that and we’ll see what it does when we plug it in.
I am expecting trouble, but feel that we’re close.
def draw(self, y_pos):
for text in self.texts:
text.y = text.y + y_pos
self.batch.draw()
for text in self.texts:
text.y = text.y - y_pos
Now, in theory, I should be able to “just” plug this new object into Scroller, like this:
class Scroller:
def __init__(self, lines=4, base=(512,800)):
self.buffer = []
self.base_x, self.base_y = base
self.blank = TextBatch('', base_x=self.base_x, font_size=24)
for i in range(lines):
self.buffer.append(self.blank)
self.lines = lines
self.line_height = 36
self.y_bump = 0
def announce(self, message: str) -> None:
batch = TextBatch(message, font_size=24, base_x=self.base_x)
self.buffer.append(batch)
def draw(self):
for line in range(self.lines):
batch = self.message(line)
y_pos = self.y_position(line)
batch.draw(y_pos)
For once, in practice, theory works! The program correctly draws the scroll just as before but ever so much more efficiently.
Review the Class
Let’s take a quick look at the new class and see if we can improve it a bit.
class TextBatch:
def __init__(self, msg, font_size=24, base_x=512):
self.msg = msg
self.font_size = font_size
self.base_x = base_x
self.batch = Batch()
self.texts = []
b = arcade.color.BLACK
w = arcade.color.WHITE
plan = [
( 1, 1, b),
( 1,-1, b),
(-1, 1, b),
(-1,-1, b),
( 0, 0, w)
]
for dx, dy, color in plan:
text = arcade.Text(self.msg, self.base_x+dx, dy, color,font_size=self.font_size, batch=self.batch)
text.x = text.x - text.content_width // 2
self.texts.append(text)
def draw(self, y_pos):
for text in self.texts:
text.y = text.y + y_pos
self.batch.draw()
for text in self.texts:
text.y = text.y - y_pos
That init is pretty huge. Let’s see what we can do. Let’s rename texts to bordered_text.
I’d like to break out the whole creation of bordered text into its own method. I think it’ll take a couple of steps to get it right. First extract:
class TextBatch:
def __init__(self, msg, font_size=24, base_x=512):
self.msg = msg
self.font_size = font_size
self.base_x = base_x
self.batch = Batch()
self.bordered_text = []
self.create_bordered_text()
def create_bordered_text(self):
b = arcade.color.BLACK
w = arcade.color.WHITE
plan = [
(1, 1, b),
(1, -1, b),
(-1, 1, b),
(-1, -1, b),
(0, 0, w)
]
for dx, dy, color in plan:
text = arcade.Text(self.msg, self.base_x + dx, dy, color, font_size=self.font_size, batch=self.batch)
text.x = text.x - text.content_width // 2
self.bordered_text.append(text)
Turn that into a method returning the list:
class TextBatch:
def __init__(self, msg, font_size=24, base_x=512):
self.msg = msg
self.font_size = font_size
self.base_x = base_x
self.batch = Batch()
self.bordered_text = self.create_bordered_text()
def create_bordered_text(self):
lines = []
b = arcade.color.BLACK
w = arcade.color.WHITE
plan = [
(1, 1, b),
(1, -1, b),
(-1, 1, b),
(-1, -1, b),
(0, 0, w)
]
for dx, dy, color in plan:
text = arcade.Text(self.msg, self.base_x + dx, dy, color, font_size=self.font_size, batch=self.batch)
text.x = text.x - text.content_width // 2
lines.append(text)
return lines
Let’s extract the plan to its own method.
def create_bordered_text(self):
lines = []
plan = self.plan()
for dx, dy, color in plan:
text = arcade.Text(self.msg, self.base_x + dx, dy, color, font_size=self.font_size, batch=self.batch)
text.x = text.x - text.content_width // 2
lines.append(text)
return lines
def plan(self):
b = arcade.color.BLACK
w = arcade.color.WHITE
plan = [
(1, 1, b),
(1, -1, b),
(-1, 1, b),
(-1, -1, b),
(0, 0, w)
]
return plan
A couple of inlines …
def create_bordered_text(self):
lines = []
for dx, dy, color in self.plan():
text = arcade.Text(self.msg, self.base_x + dx, dy, color, font_size=self.font_size, batch=self.batch)
text.x = text.x - text.content_width // 2
lines.append(text)
return lines
def plan(self):
b = arcade.color.BLACK
w = arcade.color.WHITE
return [
(1, 1, b),
(1, -1, b),
(-1, 1, b),
(-1, -1, b),
(0, 0, w)
]
One more extract:
def create_bordered_text(self):
lines = []
for dx, dy, color in self.plan():
text = self.create_offset_text(dx, dy, color)
lines.append(text)
return lines
def create_offset_text(self, dx, dy, color):
text = arcade.Text(self.msg, self.base_x + dx, dy, color, font_size=self.font_size, batch=self.batch)
text.x = text.x - text.content_width // 2
return text
Would we like a comprehension there? We would. Here’s the whole class now:
class TextBatch:
def __init__(self, msg, font_size=24, base_x=512):
self.msg = msg
self.font_size = font_size
self.base_x = base_x
self.batch = Batch()
self.bordered_text = self.create_bordered_text()
def create_bordered_text(self):
return [ self.create_offset_text(dx, dy, color)
for dx, dy, color in self.plan() ]
def create_offset_text(self, dx, dy, color):
text = arcade.Text(self.msg, self.base_x + dx, dy, color, font_size=self.font_size, batch=self.batch)
text.x = text.x - text.content_width // 2
return text
def plan(self):
b = arcade.color.BLACK
w = arcade.color.WHITE
return [
(1, 1, b),
(1, -1, b),
(-1, 1, b),
(-1, -1, b),
(0, 0, w)
]
def draw(self, y_pos):
for text in self.bordered_text:
text.y = text.y + y_pos
self.batch.draw()
for text in self.bordered_text:
text.y = text.y - y_pos
I think it’s pretty clear that TextBatch isn’t the right name. The right name might well be BorderedText, although that it still a bit too general, as this is a y-positionable centered bordered text object, which is probably not a great class name.
I think we’ll call it BorderedText. We will probably have some other use for it, and if so, we’ll see then how to improve it.
Summary
I am pleased with this outcome. The Scroller hardly had to change at all, simply using the new class and calling its draw method with the desired y position. The BorderedText object itself is self-contained and the only odd thing about it is that it sets and then un-sets the y-position in the draw loop, necessary because we want to retain the offsets.
- Hmm …
- There might be another way to do that. Let’s not look for it right now, but perhaps we could use the plan and just set and not need to unset. There would be some advantage to that because each change to y will do some kind of update in the batch.
-
But not now. We’re at a good stopping point right here.
It was a pleasant surprise to realize that we could properly call the new object BorderedText, which honestly hadn’t occurred to me until just then. I was more focused on how it was going to work, a batch of Text, than on how it was used … until I used it.
Anyway, nifty little object, allegedly much faster, about which we really don’t care. I just wanted to do it, and I did it. So there!
See you next time!