wxPython 实现滚动字幕效果
过年在家刷视频号直播时发现弹幕互动游戏,挺有意思的,刚好诠释了“反射”(一种基于字符串的事件驱动)的用法。想要自己也做一个弹幕游戏,于是就有了这个基本的需求,先让弹幕滚动显示出来,直播时可以当作小挂件。
滚动文字功能
先查 API 文档和 demo 看有没相应的控件和例子,发现 wxPython 有滚动文字控件 wx.lib.ticker.Ticker (Ticker翻译:滚动条;收报机;心脏;跑马灯;断续器)。
类签名:
wx.lib.ticker.Ticker(parent, id=-1, text="", fgcolor = wx.BLACK, bgcolor = wx.WHITE, start=True, ppf=2, fps=20, direction="rtl", pos=wx.DefaultPosition, size=wx.DefaultSize, style=wx.NO_BORDER, name="Ticker")
参数:
parent (wx.Window) – the parent
id (integer) – an identifier for the control: a value of -1 is taken to mean a default
text (string) – text in the ticker
fgcolor (wx.Colour) – text/foreground color
bgcolor (wx.Colour) – background color
start (boolean) – if True, the ticker starts immediately
ppf (int) – pixels per frame
fps (int) – frames per second
direction – direction of ticking, ‘rtl’ or ‘ltr’
pos (wx.Point) – the control position. A value of (-1, -1) indicates a default position, chosen by either the windowing system or wxPython, depending on platform
name – the control name
看看效果:
源码:
# -*- coding: utf-8 -*-
import wx
from wx.lib.ticker import Ticker
class MyFrame(wx.Frame):
def __init__(self, *args, **kwargs):
super(MyFrame, self).__init__(*args, **kwargs)
self.Center()
self.ticker = Ticker(parent=self, id=-1, text="你好,世界!Hello World!😍 ",
fgcolor="#ff0000", bgcolor="#fff000", start=True,
ppf=2, fps=20, direction="rtl",
pos=wx.DefaultPosition, size=self.GetClientSize(), style=wx.NO_BORDER,
name="Ticker")
self.ticker.SetFont(wx.Font(18, family=wx.SWISS, style=wx.NORMAL, weight=wx.BOLD, faceName=u"宋体"))
self.ticker.Start()
self.ticker.Bind(wx.EVT_RIGHT_UP, self.OnClose)
self.ticker.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown)
self.ticker.Bind(wx.EVT_LEFT_UP, self.OnLeftUp)
self.ticker.Bind(wx.EVT_MOTION, self.OnMouseMove)
def OnClose(self, event):
self.Close()
def OnLeftDown(self, evt):
# CaptureMouse 把所有鼠标输入指向 self.ticker 控件窗口
self.ticker.CaptureMouse()
# ClientToScreen 把鼠标位置相对于程序窗口的坐标转换为相对于屏幕的坐标
x, y = self.ClientToScreen(evt.GetPosition())
originx, originy = self.GetPosition()
dx = x - originx
dy = y - originy
self.delta = ((dx, dy))
def OnLeftUp(self, evt):
if self.ticker.HasCapture():
self.ticker.ReleaseMouse()
def OnMouseMove(self, evt):
if evt.Dragging() and evt.LeftIsDown():
x, y = self.ClientToScreen(evt.GetPosition())
fp = (x - self.delta[0], y - self.delta[1])
# Move 将窗口移动到指定坐标
self.Move(fp)
class App(wx.App):
def OnInit(self):
frame = MyFrame(parent=None, size=(1075, 53), style=wx.NO_BORDER)
frame.Show()
return True
def OnExit(self):
return 0
if __name__ == "__main__":
app = App()
app.MainLoop()
滚动文字衔接问题
滚动循环,文字需要全部滚出才会重新滚入,有空白期。
原 Ticker 类中,滚动文字是用属性 self._text 来存储的,它是一个字符串。如果在字符串尾部附加其他新的字符串,并不能解决显示衔接问题,而且不便对指定字符串进行修改。那么直接继承 Ticker ,把 _text 改成 list,并修改相应方法。
本来是想像做动画一样,将 _text 复制一份,轮流滚动。但是因为后期还要动态添加字符串,并且不应该把所有文字都显示出来。
所以最后想到的解决办法是:另设一个列表 _show_text 用于存储要显示的字符串,_index 表示最后从 _text 添加到 _show_text 的字符串的_text 索引。 当 _show_text 头部节点滚出显示区时,从列表中去除,当尾节点尾部与显示区尾部相距超过设定间距时,则把 _text[_index+1] 附加到 _show_text。
效果,录像 GIF 帧率是 30 FPS:
源码:
# -*- coding: utf-8 -*-
# Author: SmileBasic
import wx
import wx.lib.ticker
class Ticker(wx.lib.ticker.Ticker):
def __init__(self, *args, **kwargs):
self._text = [] # 滚动字符串存储列表
self._text_max = 500 # _text 最大可存储字符串数量
self._show_text = [] # 显示字符串列表
self._show_text_size = (-1, -1)
self._gap = 0 # 滚动字符串之间间距
self._gap_pixels = 0
self._index = 0 # 最后进入显示区域的文字索引
super(Ticker, self).__init__(*args, **kwargs)
def SetText(self, text):
"""将字符串插入滚动字符串列表尾部"""
if text.strip():
if len(self._text) == self._text_max:
self._text.pop(0)
if self._index > 0:
self._index = self._index - 1
self._text.append(text)
if not self._show_text:
self._show_text.append(text)
self.Refresh()
def SetGap(self, gap):
"""设置字符串显示间距"""
self._gap = gap
def UpdateExtent(self, dc):
"""更新字符串尺寸"""
dc.SetFont(self.GetFont())
self._gap_pixels = dc.GetTextExtent(self._gap * " ")[0]
self._show_text_size = dc.GetTextExtent((self._gap * " ").join(self._show_text))
if self._show_text:
self._extent = dc.GetTextExtent(self._show_text[0])
else:
self._extent = (-1, -1)
def OnTick(self, evt):
"""更新滚动文字坐标"""
self._offset += self._ppf # offset 偏移,ppf(pixels per frame) 每帧滚动的像素
# 头节点滚出显示区
if self._offset >= self.GetSize()[0] + self._extent[0]:
self._offset = self._offset - self._extent[0] - self._gap_pixels
self._show_text.pop(0)
# 需要更新字符串尺寸
self.UpdateExtent(wx.ClientDC(self))
# 尾节点与显示区尾部相距超过指定间距
if self._offset >= self._show_text_size[0] + self._gap_pixels and self._text:
self._index = (self._index + 1) % len(self._text)
self._show_text.append(self._text[self._index])
self.UpdateExtent(wx.ClientDC(self))
self.Refresh()
def DrawText(self, dc):
"""绘制滚动文字"""
dc.SetTextForeground(self.GetForegroundColour())
dc.SetFont(self.GetFont())
self.UpdateExtent(dc)
if self._dir == "ltr":
offx = self._offset - self._extent[0]
fix_dir = -1
fix_size = 1
else:
offx = self.GetSize()[0] - self._offset
fix_dir = 1
fix_size = 0
offy = (self.GetSize()[1] - self._extent[1]) / 2
for i, x in enumerate(self._show_text):
dc.DrawText(x, int(offx), int(offy))
if i < len(self._show_text) - 1:
offx = offx + dc.GetTextExtent(self._show_text[i + fix_size])[0] * fix_dir + self._gap_pixels * fix_dir
class MyFrame(wx.Frame):
def __init__(self, *args, **kwargs):
super(MyFrame, self).__init__(*args, **kwargs)
self.Center()
self.ticker = Ticker(parent=self, id=-1, text="你好,世界!Hello World!😍",
fgcolor="#ff0000", bgcolor="#ffff00", start=True,
ppf=2, fps=20, direction="rtl",
pos=wx.DefaultPosition, size=self.GetClientSize(), style=wx.NO_BORDER | wx.TRANSPARENT_WINDOW,
name="Ticker")
self.ticker.SetFont(wx.Font(18, family=wx.SWISS, style=wx.NORMAL, weight=wx.BOLD, faceName=u"宋体"))
self.ticker.SetGap(5)
self.ticker.SetText("CSDN")
self.ticker.SetText("SmileBasic")
self.ticker.Start()
self.ticker.Bind(wx.EVT_RIGHT_UP, self.OnClose)
self.ticker.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown)
self.ticker.Bind(wx.EVT_LEFT_UP, self.OnLeftUp)
self.ticker.Bind(wx.EVT_MOTION, self.OnMouseMove)
def OnClose(self, event):
self.Close()
def OnLeftDown(self, evt):
# CaptureMouse 把所有鼠标输入指向 self.ticker 控件窗口
self.ticker.CaptureMouse()
# ClientToScreen 把鼠标位置相对于程序窗口的坐标转换为相对于屏幕的坐标
x, y = self.ClientToScreen(evt.GetPosition())
originx, originy = self.GetPosition()
dx = x - originx
dy = y - originy
self.delta = ((dx, dy))
def OnLeftUp(self, evt):
if self.ticker.HasCapture():
self.ticker.ReleaseMouse()
def OnMouseMove(self, evt):
if evt.Dragging() and evt.LeftIsDown():
x, y = self.ClientToScreen(evt.GetPosition())
fp = (x - self.delta[0], y - self.delta[1])
# Move 将窗口移动到指定坐标
self.Move(fp)
class App(wx.App):
def OnInit(self):
frame = MyFrame(parent=None, size=(1075, 53), style=wx.NO_BORDER | wx.STAY_ON_TOP | wx.TRANSPARENT_WINDOW)
frame.Show()
return True
def OnExit(self):
return 0
if __name__ == "__main__":
app = App()
app.MainLoop()
修改
很明显,这样不断对列表 _show_text 头尾进行增删操作会造成资源浪费。另外,当 _text 存储超过数量时,也会陷入这种情况。
在某些场景下,我们需要频繁的对列表的头和尾进行操作,这时我们就可以使用双端队列collections.deque,这种数据结构被设计成能够在集合两端高效地增加和删除元素,在Python中,双端队列是以双向链表的方式实现的。来源:Python高性能计算之列表 https://zhuanlan.zhihu.com/p/342861939
但是使用索引读取时,列表时间复杂度是O(1),双端队列是O(n)。_text 需要频繁使用索引,不适合使用双端队列。
观察一下,_show_text 是 _text 中连续截取的元素,可以去掉,用头节点 索引 _index 和 显示数量 _num 代替。还有,字符串宽度一般是不变的,只在设置字体时会改变,可以在存储字符串的时候,把字符串尺寸也保存。
修改后源码:
# -*- coding: utf-8 -*-
# Author: SmileBasic
import wx
import wx.lib.ticker
class Ticker(wx.lib.ticker.Ticker):
def __init__(self, *args, **kwargs):
self._text = [] # 滚动字符串存储列表,格式[[字符串尺寸, 字符串], [(56, 22), "test"]]
self._text_max = 500 # _text 最大可存储字符串数量
self._gap = 0 # 滚动字符串之间间距
self._gap_pixels = 0
self._index = 0 # 显示字符串头节点
self._num = 1 # 显示字符串数量
self._dc = wx.MemoryDC() # 用于获取文本尺寸
super(Ticker, self).__init__(*args, **kwargs)
def SetFont(self, font):
"""设置控件使用的字体"""
wx.Control.SetFont(self, font)
self._dc.SetFont(font)
# 更新存储文本尺寸
for text in self._text:
text[0] = self._dc.GetTextExtent(text[1])
def SetText(self, text):
"""将字符串插入滚动字符串列表尾部"""
if text.strip():
lens = len(self._text)
if lens >= self._text_max:
if self._index != 0:
self._text = self._text[self._index % lens:]
self._index = 0
else:
self._text.pop(0)
size = self._dc.GetTextExtent(text)
self._text.append([size, text])
self.Refresh()
def SetGap(self, gap):
"""设置字符串显示间距"""
self._gap = gap
self._gap_pixels = self._dc.GetTextExtent(self._gap * " ")[0]
def UpdateExtent(self, dc):
"""更新字符串尺寸"""
pass
def OnTick(self, evt):
"""更新滚动文字坐标"""
if not self._text:
return
self._offset += self._ppf # offset 偏移,ppf(pixels per frame) 每帧滚动的像素
# 头节点滚出显示区
w1 = self.GetSize()[0]
w2 = self._text[self._index][0][0]
if self._offset >= w1 + w2:
self._offset = self._offset - w2 - self._gap_pixels
self._index = (self._index + 1) % len(self._text)
self._num -= 1
# 尾节点与显示区尾部相距超过指定间距
w3 = sum([self._text[i % len(self._text)][0][0] for i in range(self._index, self._index + self._num)])
w3 = w3 + self._gap_pixels * self._num
if self._offset >= w3:
self._num += 1
self.Refresh()
def DrawText(self, dc):
"""绘制滚动文字"""
if not self._text:
return
dc.SetTextForeground(self.GetForegroundColour())
dc.SetFont(self.GetFont())
if self._dir == "ltr":
offx = self._offset - self._text[self._index][0][0]
fix_dir = -1
fix_size = 1
else:
offx = self.GetSize()[0] - self._offset
fix_dir = 1
fix_size = 0
lens = len(self._text)
for i in range(self._index, self._index + self._num):
offy = (self.GetSize()[1] - self._text[i % lens][0][1]) / 2
dc.DrawText(self._text[i % lens][1], int(offx), int(offy))
if i < self._index + self._num - 1:
offx = offx + self._text[(i + fix_size) % lens][0][0] * fix_dir + self._gap_pixels * fix_dir
class MyFrame(wx.Frame):
def __init__(self, *args, **kwargs):
super(MyFrame, self).__init__(*args, **kwargs)
self.Center()
self.ticker = Ticker(parent=self, id=-1, text="你好,世界!Hello World!😍",
fgcolor="#ff0000", bgcolor="#ffff00", start=True,
ppf=2, fps=20, direction="rtl",
pos=wx.DefaultPosition, size=self.GetClientSize(), style=wx.NO_BORDER | wx.TRANSPARENT_WINDOW,
name="Ticker")
self.ticker.SetFont(wx.Font(18, family=wx.SWISS, style=wx.NORMAL, weight=wx.BOLD, faceName=u"宋体"))
self.ticker.SetGap(5)
self.ticker.SetText("CSDN")
self.ticker.SetText("SmileBasic")
self.ticker.Start()
self.ticker.Bind(wx.EVT_RIGHT_UP, self.OnClose)
self.ticker.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown)
self.ticker.Bind(wx.EVT_LEFT_UP, self.OnLeftUp)
self.ticker.Bind(wx.EVT_MOTION, self.OnMouseMove)
def OnClose(self, event):
self.Close()
def OnLeftDown(self, evt):
# CaptureMouse 把所有鼠标输入指向 self.ticker 控件窗口
self.ticker.CaptureMouse()
# ClientToScreen 把鼠标位置相对于程序窗口的坐标转换为相对于屏幕的坐标
x, y = self.ClientToScreen(evt.GetPosition())
originx, originy = self.GetPosition()
dx = x - originx
dy = y - originy
self.delta = ((dx, dy))
def OnLeftUp(self, evt):
if self.ticker.HasCapture():
self.ticker.ReleaseMouse()
def OnMouseMove(self, evt):
if evt.Dragging() and evt.LeftIsDown():
x, y = self.ClientToScreen(evt.GetPosition())
fp = (x - self.delta[0], y - self.delta[1])
# Move 将窗口移动到指定坐标
self.Move(fp)
class App(wx.App):
def OnInit(self):
frame = MyFrame(parent=None, size=(1075, 53), style=wx.NO_BORDER | wx.STAY_ON_TOP | wx.TRANSPARENT_WINDOW)
frame.Show()
return True
def OnExit(self):
return 0
if __name__ == "__main__":
app = App()
app.MainLoop()