controls/LogViewer.py
author Edouard Tisserant
Wed, 01 Dec 2021 09:54:02 +0100
branchRuntimeLists
changeset 3394 9ea29ac18837
parent 2737 38afed869ff6
child 3303 0ffb41625592
permissions -rw-r--r--
RUNTIME: Variable trace now uses limited list and buffer instead of flags in instance tree that was requiring systematical instance tree traversal, and worst size buffer. Forcing and retain still use tree traversal.
#!/usr/bin/env python
# -*- coding: utf-8 -*-

# This file is part of Beremiz, a Integrated Development Environment for
# programming IEC 61131-3 automates supporting plcopen standard and CanFestival.
#
# Copyright (C) 2013: Edouard TISSERANT and Laurent BESSARD
#
# See COPYING file for copyrights details.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.


from __future__ import absolute_import
from __future__ import division
from datetime import datetime
from time import time as gettime
from weakref import proxy

import numpy
import wx
from six.moves import xrange

from controls.CustomToolTip import CustomToolTip, TOOLTIP_WAIT_PERIOD
from editors.DebugViewer import DebugViewer, REFRESH_PERIOD
from runtime.loglevels import LogLevelsCount, LogLevels
from util.BitmapLibrary import GetBitmap


THUMB_SIZE_RATIO = 1. / 8.


def ArrowPoints(direction, width, height, xoffset, yoffset):
    if direction == wx.TOP:
        return [wx.Point(xoffset + 1, yoffset + height - 2),
                wx.Point(xoffset + width // 2, yoffset + 1),
                wx.Point(xoffset + width - 1, yoffset + height - 2)]
    else:
        return [wx.Point(xoffset + 1, yoffset - height + 1),
                wx.Point(xoffset + width // 2, yoffset - 2),
                wx.Point(xoffset + width - 1, yoffset - height + 1)]


class LogScrollBar(wx.Panel):

    def __init__(self, parent, size):
        wx.Panel.__init__(self, parent, size=size)
        self.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown)
        self.Bind(wx.EVT_LEFT_UP, self.OnLeftUp)
        self.Bind(wx.EVT_MOTION, self.OnMotion)
        self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnEraseBackground)
        self.Bind(wx.EVT_PAINT, self.OnPaint)
        self.Bind(wx.EVT_SIZE, self.OnResize)

        self.ThumbPosition = 0.  # -1 <= ThumbPosition <= 1
        self.ThumbScrollingStartPos = None

    def GetRangeRect(self):
        width, height = self.GetClientSize()
        return wx.Rect(0, width, width, height - 2 * width)

    def GetThumbRect(self):
        width, _height = self.GetClientSize()
        range_rect = self.GetRangeRect()
        thumb_size = range_rect.height * THUMB_SIZE_RATIO
        thumb_range = range_rect.height - thumb_size
        thumb_center_position = (thumb_size + (self.ThumbPosition + 1) * thumb_range) / 2.
        thumb_start = int(thumb_center_position - thumb_size / 2.)
        thumb_end = int(thumb_center_position + thumb_size / 2.)
        return wx.Rect(0, range_rect.y + thumb_start, width, thumb_end - thumb_start)

    def RefreshThumbPosition(self, thumb_position=None):
        if thumb_position is None:
            thumb_position = self.ThumbPosition
        if self.Parent.IsMessagePanelTop():
            thumb_position = max(0., thumb_position)
        if self.Parent.IsMessagePanelBottom():
            thumb_position = min(0., thumb_position)
        if thumb_position != self.ThumbPosition:
            self.ThumbPosition = thumb_position
            self.Parent.SetScrollSpeed(self.ThumbPosition)
        self.Refresh()

    def OnLeftDown(self, event):
        self.CaptureMouse()
        posx, posy = event.GetPosition()
        width, height = self.GetClientSize()
        range_rect = self.GetRangeRect()
        thumb_rect = self.GetThumbRect()
        if range_rect.InsideXY(posx, posy):
            if thumb_rect.InsideXY(posx, posy):
                self.ThumbScrollingStartPos = wx.Point(posx, posy)
            elif posy < thumb_rect.y:
                self.Parent.ScrollToLast()
            elif posy > thumb_rect.y + thumb_rect.height:
                self.Parent.ScrollToFirst()
        elif posy < width:
            self.Parent.ScrollMessagePanelByPage(1)
        elif posy > height - width:
            self.Parent.ScrollMessagePanelByPage(-1)
        event.Skip()

    def OnLeftUp(self, event):
        self.ThumbScrollingStartPos = None
        self.RefreshThumbPosition(0.)
        if self.HasCapture():
            self.ReleaseMouse()
        event.Skip()

    def OnMotion(self, event):
        if event.Dragging() and self.ThumbScrollingStartPos is not None:
            _posx, posy = event.GetPosition()
            range_rect = self.GetRangeRect()
            thumb_size = range_rect.height * THUMB_SIZE_RATIO
            thumb_range = range_rect.height - thumb_size
            self.RefreshThumbPosition(
                max(-1., min((posy - self.ThumbScrollingStartPos.y) * 2. // thumb_range, 1.)))
        event.Skip()

    def OnResize(self, event):
        self.Refresh()
        event.Skip()

    def OnEraseBackground(self, event):
        pass

    def OnPaint(self, event):
        dc = wx.BufferedPaintDC(self)
        dc.Clear()
        dc.BeginDrawing()

        gc = wx.GCDC(dc)

        width, height = self.GetClientSize()

        gc.SetPen(wx.Pen(wx.NamedColour("GREY"), 3))
        gc.SetBrush(wx.GREY_BRUSH)

        gc.DrawLines(ArrowPoints(wx.TOP, width * 0.75, width * 0.5, 2, (width + height) // 4 - 3))
        gc.DrawLines(ArrowPoints(wx.TOP, width * 0.75, width * 0.5, 2, (width + height) // 4 + 3))

        gc.DrawLines(ArrowPoints(wx.BOTTOM, width * 0.75, width * 0.5, 2, (height * 3 - width) // 4 + 3))
        gc.DrawLines(ArrowPoints(wx.BOTTOM, width * 0.75, width * 0.5, 2, (height * 3 - width) // 4 - 3))

        thumb_rect = self.GetThumbRect()
        exclusion_rect = wx.Rect(thumb_rect.x, thumb_rect.y,
                                 thumb_rect.width, thumb_rect.height)
        if self.Parent.IsMessagePanelTop():
            exclusion_rect.y, exclusion_rect.height = width, exclusion_rect.y + exclusion_rect.height - width
        if self.Parent.IsMessagePanelBottom():
            exclusion_rect.height = height - width - exclusion_rect.y
        if exclusion_rect != thumb_rect:
            colour = wx.NamedColour("LIGHT GREY")
            gc.SetPen(wx.Pen(colour))
            gc.SetBrush(wx.Brush(colour))

            gc.DrawRectangle(exclusion_rect.x, exclusion_rect.y,
                             exclusion_rect.width, exclusion_rect.height)

        gc.SetPen(wx.GREY_PEN)
        gc.SetBrush(wx.GREY_BRUSH)

        gc.DrawPolygon(ArrowPoints(wx.TOP, width, width, 0, 0))

        gc.DrawPolygon(ArrowPoints(wx.BOTTOM, width, width, 0, height))

        gc.DrawRectangle(thumb_rect.x, thumb_rect.y,
                         thumb_rect.width, thumb_rect.height)

        dc.EndDrawing()
        event.Skip()


BUTTON_SIZE = (70, 15)


class LogButton(object):

    def __init__(self, label, callback):
        self.Position = wx.Point(0, 0)
        self.Size = wx.Size(*BUTTON_SIZE)
        self.Label = label
        self.Shown = True
        self.Callback = callback

    def __del__(self):
        self.callback = None

    def GetSize(self):
        return self.Size

    def SetPosition(self, x, y):
        self.Position = wx.Point(x, y)

    def HitTest(self, x, y):
        rect = wx.Rect(self.Position.x, self.Position.y,
                       self.Size.width, self.Size.height)
        if rect.InsideXY(x, y):
            return True
        return False

    def ProcessCallback(self):
        if self.Callback is not None:
            wx.CallAfter(self.Callback)

    def Draw(self, dc):
        dc.SetPen(wx.TRANSPARENT_PEN)
        dc.SetBrush(wx.Brush(wx.NamedColour("LIGHT GREY")))

        dc.DrawRectangle(self.Position.x, self.Position.y,
                         self.Size.width, self.Size.height)

        w, h = dc.GetTextExtent(self.Label)
        dc.DrawText(self.Label,
                    self.Position.x + (self.Size.width - w) // 2,
                    self.Position.y + (self.Size.height - h) // 2)


DATE_INFO_SIZE = 10
MESSAGE_INFO_SIZE = 18


class LogMessage(object):

    def __init__(self, tv_sec, tv_nsec, level, level_bitmap, msg):
        self.Date = datetime.utcfromtimestamp(tv_sec)
        self.Seconds = self.Date.second + tv_nsec * 1e-9
        self.Date = self.Date.replace(second=0)
        self.Timestamp = tv_sec + tv_nsec * 1e-9
        self.Level = level
        self.LevelBitmap = level_bitmap
        self.Message = msg
        self.DrawDate = True

    def __cmp__(self, other):
        if self.Date == other.Date:
            return cmp(self.Seconds, other.Seconds)
        return cmp(self.Date, other.Date)

    def GetFullText(self):
        date = self.Date.replace(second=int(self.Seconds))
        nsec = (self.Seconds % 1.) * 1e9
        return "%s at %s.%9.9d:\n%s" % (
            LogLevels[self.Level],
            str(date), nsec,
            self.Message)

    def Draw(self, dc, offset, width, draw_date):
        if draw_date:
            datetime_text = self.Date.strftime("%d/%m/%y %H:%M")
            dw, dh = dc.GetTextExtent(datetime_text)
            dc.DrawText(datetime_text, (width - dw) // 2, offset + (DATE_INFO_SIZE - dh) // 2)
            offset += DATE_INFO_SIZE

        seconds_text = "%12.9f" % self.Seconds
        sw, sh = dc.GetTextExtent(seconds_text)
        dc.DrawText(seconds_text, 5, offset + (MESSAGE_INFO_SIZE - sh) // 2)

        bw, bh = self.LevelBitmap.GetWidth(), self.LevelBitmap.GetHeight()
        dc.DrawBitmap(self.LevelBitmap, 10 + sw, offset + (MESSAGE_INFO_SIZE - bh) // 2)

        text = self.Message.replace("\n", " ")
        _mw, mh = dc.GetTextExtent(text)
        dc.DrawText(text, 15 + sw + bw, offset + (MESSAGE_INFO_SIZE - mh) // 2)

    def GetHeight(self, draw_date):
        if draw_date:
            return DATE_INFO_SIZE + MESSAGE_INFO_SIZE
        return MESSAGE_INFO_SIZE


SECOND = 1
MINUTE = 60 * SECOND
HOUR = 60 * MINUTE
DAY = 24 * HOUR

CHANGE_TIMESTAMP_BUTTONS = [(_("1d"), DAY),
                            (_("1h"), HOUR),
                            (_("1m"), MINUTE),
                            (_("1s"), SECOND)]


class LogViewer(DebugViewer, wx.Panel):

    def __init__(self, parent, window):
        wx.Panel.__init__(self, parent, style=wx.TAB_TRAVERSAL | wx.SUNKEN_BORDER)
        DebugViewer.__init__(self, None, False, False)

        main_sizer = wx.FlexGridSizer(cols=1, hgap=0, rows=2, vgap=5)
        main_sizer.AddGrowableCol(0)
        main_sizer.AddGrowableRow(1)

        filter_sizer = wx.BoxSizer(wx.HORIZONTAL)
        main_sizer.AddSizer(filter_sizer, border=5, flag=wx.TOP | wx.LEFT | wx.RIGHT | wx.GROW)

        self.MessageFilter = wx.ComboBox(self, style=wx.CB_READONLY)
        self.MessageFilter.Append(_("All"))
        levels = LogLevels[:3]
        levels.reverse()
        for level in levels:
            self.MessageFilter.Append(_(level))
        self.Bind(wx.EVT_COMBOBOX, self.OnMessageFilterChanged, self.MessageFilter)
        filter_sizer.AddWindow(self.MessageFilter, 1, border=5, flag=wx.RIGHT | wx.ALIGN_CENTER_VERTICAL)

        self.SearchMessage = wx.SearchCtrl(self, style=wx.TE_PROCESS_ENTER)
        self.SearchMessage.ShowSearchButton(True)
        self.SearchMessage.ShowCancelButton(True)
        self.Bind(wx.EVT_TEXT_ENTER, self.OnSearchMessageChanged, self.SearchMessage)
        self.Bind(wx.EVT_SEARCHCTRL_SEARCH_BTN,
                  self.OnSearchMessageSearchButtonClick, self.SearchMessage)
        self.Bind(wx.EVT_SEARCHCTRL_CANCEL_BTN,
                  self.OnSearchMessageCancelButtonClick, self.SearchMessage)
        filter_sizer.AddWindow(self.SearchMessage, 3, border=5, flag=wx.RIGHT | wx.ALIGN_CENTER_VERTICAL)

        self.CleanButton = wx.lib.buttons.GenBitmapButton(self, bitmap=GetBitmap("Clean"),
                                                          size=wx.Size(28, 28), style=wx.NO_BORDER)
        self.CleanButton.SetToolTipString(_("Clean log messages"))
        self.Bind(wx.EVT_BUTTON, self.OnCleanButton, self.CleanButton)
        filter_sizer.AddWindow(self.CleanButton)

        message_panel_sizer = wx.FlexGridSizer(cols=2, hgap=0, rows=1, vgap=0)
        message_panel_sizer.AddGrowableCol(0)
        message_panel_sizer.AddGrowableRow(0)
        main_sizer.AddSizer(message_panel_sizer, border=5, flag=wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.GROW)

        self.MessagePanel = wx.Panel(self)
        if wx.Platform == '__WXMSW__':
            self.Font = wx.Font(8, wx.SWISS, wx.NORMAL, wx.NORMAL, faceName='Courier New')
        else:
            self.Font = wx.Font(10, wx.SWISS, wx.NORMAL, wx.NORMAL, faceName='FreeMono')
        self.MessagePanel.Bind(wx.EVT_LEFT_UP, self.OnMessagePanelLeftUp)
        self.MessagePanel.Bind(wx.EVT_RIGHT_UP, self.OnMessagePanelRightUp)
        self.MessagePanel.Bind(wx.EVT_LEFT_DCLICK, self.OnMessagePanelLeftDCLick)
        self.MessagePanel.Bind(wx.EVT_MOTION, self.OnMessagePanelMotion)
        self.MessagePanel.Bind(wx.EVT_LEAVE_WINDOW, self.OnMessagePanelLeaveWindow)
        self.MessagePanel.Bind(wx.EVT_MOUSEWHEEL, self.OnMessagePanelMouseWheel)
        self.MessagePanel.Bind(wx.EVT_ERASE_BACKGROUND, self.OnMessagePanelEraseBackground)
        self.MessagePanel.Bind(wx.EVT_PAINT, self.OnMessagePanelPaint)
        self.MessagePanel.Bind(wx.EVT_SIZE, self.OnMessagePanelResize)
        message_panel_sizer.AddWindow(self.MessagePanel, flag=wx.GROW)

        self.MessageScrollBar = LogScrollBar(self, wx.Size(16, -1))
        message_panel_sizer.AddWindow(self.MessageScrollBar, flag=wx.GROW)

        self.SetSizer(main_sizer)

        self.LeftButtons = []
        for label, callback in [("+" + text, self.GenerateOnDurationButton(duration))
                                for text, duration in CHANGE_TIMESTAMP_BUTTONS]:
            self.LeftButtons.append(LogButton(label, callback))

        self.RightButtons = []
        for label, callback in [("-" + text, self.GenerateOnDurationButton(-duration))
                                for text, duration in CHANGE_TIMESTAMP_BUTTONS]:
            self.RightButtons.append(LogButton(label, callback))

        self.MessageFilter.SetSelection(0)
        self.LogSource = None
        self.ResetLogMessages()
        self.ParentWindow = window

        self.LevelIcons = [GetBitmap("LOG_" + level) for level in LogLevels]
        self.LevelFilters = [range(i) for i in xrange(4, 0, -1)]
        self.CurrentFilter = self.LevelFilters[0]
        self.CurrentSearchValue = ""

        self.ScrollSpeed = 0.
        self.LastStartTime = None
        self.ScrollTimer = wx.Timer(self, -1)
        self.Bind(wx.EVT_TIMER, self.OnScrollTimer, self.ScrollTimer)

        self.LastMousePos = None
        self.MessageToolTip = None
        self.MessageToolTipTimer = wx.Timer(self, -1)
        self.Bind(wx.EVT_TIMER, self.OnMessageToolTipTimer, self.MessageToolTipTimer)

    def __del__(self):
        self.ScrollTimer.Stop()

    def ResetLogMessages(self):
        self.ResetLogCounters()
        self.OldestMessages = []
        self.LogMessages = []
        self.LogMessagesTimestamp = numpy.array([])
        self.CurrentMessage = None
        self.HasNewData = False

    def SetLogSource(self, log_source):
        self.LogSource = proxy(log_source) if log_source is not None else None
        self.CleanButton.Enable(self.LogSource is not None)
        if log_source is not None:
            self.ResetLogMessages()
            wx.CallAfter(self.RefreshView)

    def GetLogMessageFromSource(self, msgidx, level):
        if self.LogSource is not None:
            answer = self.LogSource.GetLogMessage(level, msgidx)
            if answer is not None:
                msg, _tick, tv_sec, tv_nsec = answer
                return LogMessage(tv_sec, tv_nsec, level, self.LevelIcons[level], msg)
        return None

    def ResetLogCounters(self):
        self.previous_log_count = [None]*LogLevelsCount

    def SetLogCounters(self, log_count):
        new_messages = []
        for level, count, prev in zip(xrange(LogLevelsCount), log_count, self.previous_log_count):
            if count is not None and prev != count:
                if prev is None:
                    dump_end = max(-1, count - 10)
                    oldest_message = (-1, None)
                else:
                    dump_end = prev - 1
                for msgidx in xrange(count-1, dump_end, -1):
                    new_message = self.GetLogMessageFromSource(msgidx, level)
                    if new_message is None:
                        if prev is None:
                            oldest_message = (-1, None)
                        break
                    if prev is None:
                        oldest_message = (msgidx, new_message)
                        if len(new_messages) == 0:
                            new_messages = [new_message]
                        else:
                            new_messages.insert(0, new_message)
                    else:
                        new_messages.insert(0, new_message)
                if prev is None and len(self.OldestMessages) <= level:
                    self.OldestMessages.append(oldest_message)
                self.previous_log_count[level] = count
        new_messages.sort()
        if len(new_messages) > 0:
            self.HasNewData = True
            if self.CurrentMessage is not None:
                current_is_last = self.GetNextMessage(self.CurrentMessage)[0] is None
            else:
                current_is_last = True
            for new_message in new_messages:
                self.LogMessages.append(new_message)
                self.LogMessagesTimestamp = numpy.append(self.LogMessagesTimestamp, [new_message.Timestamp])
            if current_is_last:
                self.ScrollToLast(False)
                self.ResetMessageToolTip()
                self.MessageToolTipTimer.Stop()
                self.ParentWindow.SelectTab(self)
            self.NewDataAvailable(None)

    def FilterLogMessage(self, message, timestamp=None):
        return (message.Level in self.CurrentFilter and
                message.Message.find(self.CurrentSearchValue) != -1 and
                (timestamp is None or message.Timestamp < timestamp))

    def GetMessageByTimestamp(self, timestamp):
        if self.CurrentMessage is not None:
            msgidx = numpy.argmin(abs(self.LogMessagesTimestamp - timestamp))
            message = self.LogMessages[msgidx]
            if self.FilterLogMessage(message) and message.Timestamp > timestamp:
                return self.GetPreviousMessage(msgidx, timestamp)
            return message, msgidx
        return None, None

    def GetNextMessage(self, msgidx):
        while msgidx < len(self.LogMessages) - 1:
            message = self.LogMessages[msgidx + 1]
            if self.FilterLogMessage(message):
                return message, msgidx + 1
            msgidx += 1
        return None, None

    def GetPreviousMessage(self, msgidx, timestamp=None):
        message = None
        while 0 < msgidx < len(self.LogMessages):
            message = self.LogMessages[msgidx - 1]
            if self.FilterLogMessage(message, timestamp):
                return message, msgidx - 1
            msgidx -= 1
        if len(self.LogMessages) > 0:
            message = self.LogMessages[0]
            for _idx, msg in self.OldestMessages:
                if msg is not None and msg > message:
                    message = msg
            while message is not None:
                level = message.Level
                oldest_msgidx, _oldest_message = self.OldestMessages[level]
                if oldest_msgidx > 0:
                    message = self.GetLogMessageFromSource(oldest_msgidx - 1, level)
                    if message is not None:
                        self.OldestMessages[level] = (oldest_msgidx - 1, message)
                    else:
                        self.OldestMessages[level] = (-1, None)
                else:
                    message = None
                    self.OldestMessages[level] = (-1, None)
                if message is not None:
                    message_idx = 0
                    while (message_idx < len(self.LogMessages) and
                           self.LogMessages[message_idx] < message):
                        message_idx += 1
                    if len(self.LogMessages) > 0:
                        current_message = self.LogMessages[self.CurrentMessage]
                    else:
                        current_message = message
                    self.LogMessages.insert(message_idx, message)
                    self.LogMessagesTimestamp = numpy.insert(
                        self.LogMessagesTimestamp,
                        [message_idx],
                        [message.Timestamp])
                    self.CurrentMessage = self.LogMessages.index(current_message)
                    if message_idx == 0 and self.FilterLogMessage(message, timestamp):
                        return message, 0
                for _idx, msg in self.OldestMessages:
                    if msg is not None and (message is None or msg > message):
                        message = msg
        return None, None

    def RefreshNewData(self, *args, **kwargs):
        if self.HasNewData:
            self.HasNewData = False
            self.RefreshView()
        DebugViewer.RefreshNewData(self, *args, **kwargs)

    def RefreshView(self):
        width, height = self.MessagePanel.GetClientSize()
        bitmap = wx.EmptyBitmap(width, height)
        dc = wx.BufferedDC(wx.ClientDC(self.MessagePanel), bitmap)
        dc.Clear()
        dc.BeginDrawing()

        if self.CurrentMessage is not None:

            dc.SetFont(self.Font)

            for button in self.LeftButtons + self.RightButtons:
                button.Draw(dc)

            message_idx = self.CurrentMessage
            message = self.LogMessages[message_idx]
            draw_date = True
            offset = 5
            while offset < height and message is not None:
                message.Draw(dc, offset, width, draw_date)
                offset += message.GetHeight(draw_date)

                previous_message, message_idx = self.GetPreviousMessage(message_idx)
                if previous_message is not None:
                    draw_date = message.Date != previous_message.Date
                message = previous_message

        dc.EndDrawing()

        self.MessageScrollBar.RefreshThumbPosition()

    def IsPLCLogEmpty(self):
        empty = True
        for _level, prev in zip(xrange(LogLevelsCount), self.previous_log_count):
            if prev is not None:
                empty = False
                break
        return empty

    def IsMessagePanelTop(self, message_idx=None):
        if message_idx is None:
            message_idx = self.CurrentMessage
        if message_idx is not None:
            return self.GetNextMessage(message_idx)[0] is None
        return True

    def IsMessagePanelBottom(self, message_idx=None):
        if message_idx is None:
            message_idx = self.CurrentMessage
        if message_idx is not None:
            _width, height = self.MessagePanel.GetClientSize()
            offset = 5
            message = self.LogMessages[message_idx]
            draw_date = True
            while message is not None and offset < height:
                offset += message.GetHeight(draw_date)
                previous_message, message_idx = self.GetPreviousMessage(message_idx)
                if previous_message is not None:
                    draw_date = message.Date != previous_message.Date
                message = previous_message
            return offset < height
        return True

    def ScrollMessagePanel(self, scroll):
        if self.CurrentMessage is not None:
            message = self.LogMessages[self.CurrentMessage]
            while scroll > 0 and message is not None:
                message, msgidx = self.GetNextMessage(self.CurrentMessage)
                if message is not None:
                    self.CurrentMessage = msgidx
                    scroll -= 1
            while scroll < 0 and message is not None and not self.IsMessagePanelBottom():
                message, msgidx = self.GetPreviousMessage(self.CurrentMessage)
                if message is not None:
                    self.CurrentMessage = msgidx
                    scroll += 1
            self.RefreshView()

    def ScrollMessagePanelByPage(self, page):
        if self.CurrentMessage is not None:
            _width, height = self.MessagePanel.GetClientSize()
            message_per_page = max(1, (height - DATE_INFO_SIZE) // MESSAGE_INFO_SIZE - 1)
            self.ScrollMessagePanel(page * message_per_page)

    def ScrollMessagePanelByTimestamp(self, seconds):
        if self.CurrentMessage is not None:
            current_message = self.LogMessages[self.CurrentMessage]
            message, msgidx = self.GetMessageByTimestamp(current_message.Timestamp + seconds)
            if message is None or self.IsMessagePanelBottom(msgidx):
                self.ScrollToFirst()
            else:
                if seconds > 0 and self.CurrentMessage == msgidx and msgidx < len(self.LogMessages) - 1:
                    msgidx += 1
                self.CurrentMessage = msgidx
                self.RefreshView()

    def ResetMessagePanel(self):
        if len(self.LogMessages) > 0:
            self.CurrentMessage = len(self.LogMessages) - 1
            message = self.LogMessages[self.CurrentMessage]
            while message is not None and not self.FilterLogMessage(message):
                message, self.CurrentMessage = self.GetPreviousMessage(self.CurrentMessage)
            self.RefreshView()

    def OnMessageFilterChanged(self, event):
        self.CurrentFilter = self.LevelFilters[self.MessageFilter.GetSelection()]
        self.ResetMessagePanel()
        event.Skip()

    def OnSearchMessageChanged(self, event):
        self.CurrentSearchValue = self.SearchMessage.GetValue()
        self.ResetMessagePanel()
        event.Skip()

    def OnSearchMessageSearchButtonClick(self, event):
        self.CurrentSearchValue = self.SearchMessage.GetValue()
        self.ResetMessagePanel()
        event.Skip()

    def OnSearchMessageCancelButtonClick(self, event):
        self.CurrentSearchValue = ""
        self.SearchMessage.SetValue("")
        self.ResetMessagePanel()
        event.Skip()

    def OnCleanButton(self, event):
        if self.LogSource is not None and not self.IsPLCLogEmpty():
            self.LogSource.ResetLogCount()
        self.ResetLogMessages()
        self.RefreshView()
        event.Skip()

    def GenerateOnDurationButton(self, duration):
        def OnDurationButton():
            self.ScrollMessagePanelByTimestamp(duration)
        return OnDurationButton

    def GetCopyMessageToClipboardFunction(self, message):
        def CopyMessageToClipboardFunction(event):
            self.ParentWindow.SetCopyBuffer(message.GetFullText())
        return CopyMessageToClipboardFunction

    def GetMessageByScreenPos(self, posx, posy):
        if self.CurrentMessage is not None:
            _width, height = self.MessagePanel.GetClientSize()
            message_idx = self.CurrentMessage
            message = self.LogMessages[message_idx]
            draw_date = True
            offset = 5

            while offset < height and message is not None:
                if draw_date:
                    offset += DATE_INFO_SIZE

                if offset <= posy < offset + MESSAGE_INFO_SIZE:
                    return message

                offset += MESSAGE_INFO_SIZE

                previous_message, message_idx = self.GetPreviousMessage(message_idx)
                if previous_message is not None:
                    draw_date = message.Date != previous_message.Date
                message = previous_message
        return None

    def OnMessagePanelLeftUp(self, event):
        if self.CurrentMessage is not None:
            posx, posy = event.GetPosition()
            for button in self.LeftButtons + self.RightButtons:
                if button.HitTest(posx, posy):
                    button.ProcessCallback()
                    break
        event.Skip()

    def OnMessagePanelRightUp(self, event):
        message = self.GetMessageByScreenPos(*event.GetPosition())
        if message is not None:
            menu = wx.Menu(title='')

            menu_entry = menu.Append(help='', id=wx.ID_ANY, kind=wx.ITEM_NORMAL, text=_("Copy"))
            self.Bind(wx.EVT_MENU, self.GetCopyMessageToClipboardFunction(message), menu_entry)

            self.MessagePanel.PopupMenu(menu)
            menu.Destroy()
        event.Skip()

    def OnMessagePanelLeftDCLick(self, event):
        message = self.GetMessageByScreenPos(*event.GetPosition())
        if message is not None:
            self.SearchMessage.SetFocus()
            self.SearchMessage.SetValue(message.Message)
        event.Skip()

    def ResetMessageToolTip(self):
        if self.MessageToolTip is not None:
            self.MessageToolTip.Destroy()
            self.MessageToolTip = None

    def OnMessageToolTipTimer(self, event):
        if self.LastMousePos is not None:
            message = self.GetMessageByScreenPos(*self.LastMousePos)
            if message is not None:
                tooltip_pos = self.MessagePanel.ClientToScreen(self.LastMousePos)
                tooltip_pos.x += 10
                tooltip_pos.y += 10
                self.MessageToolTip = CustomToolTip(self.MessagePanel, message.GetFullText(), False)
                self.MessageToolTip.SetFont(self.Font)
                self.MessageToolTip.SetToolTipPosition(tooltip_pos)
                self.MessageToolTip.Show()
        event.Skip()

    def OnMessagePanelMotion(self, event):
        if not event.Dragging():
            self.ResetMessageToolTip()
            self.LastMousePos = event.GetPosition()
            self.MessageToolTipTimer.Start(int(TOOLTIP_WAIT_PERIOD * 1000), oneShot=True)
        event.Skip()

    def OnMessagePanelLeaveWindow(self, event):
        self.ResetMessageToolTip()
        self.LastMousePos = None
        self.MessageToolTipTimer.Stop()
        event.Skip()

    def OnMessagePanelMouseWheel(self, event):
        self.ScrollMessagePanel(event.GetWheelRotation() // event.GetWheelDelta())
        event.Skip()

    def OnMessagePanelEraseBackground(self, event):
        pass

    def OnMessagePanelPaint(self, event):
        self.RefreshView()
        event.Skip()

    def OnMessagePanelResize(self, event):
        width, _height = self.MessagePanel.GetClientSize()
        offset = 2
        for button in self.LeftButtons:
            button.SetPosition(offset, 2)
            w, _h = button.GetSize()
            offset += w + 2
        offset = width - 2
        for button in self.RightButtons:
            w, _h = button.GetSize()
            button.SetPosition(offset - w, 2)
            offset -= w + 2
        if self.IsMessagePanelBottom():
            self.ScrollToFirst()
        else:
            self.RefreshView()
        event.Skip()

    def OnScrollTimer(self, event):
        if self.ScrollSpeed != 0.:
            speed_norm = abs(self.ScrollSpeed)
            period = REFRESH_PERIOD / speed_norm
            self.ScrollMessagePanel(-speed_norm / self.ScrollSpeed)
            self.LastStartTime = gettime()
            self.ScrollTimer.Start(int(period * 1000), True)
        event.Skip()

    def SetScrollSpeed(self, speed):
        if speed == 0.:
            self.ScrollTimer.Stop()
        else:
            speed_norm = abs(speed)
            period = REFRESH_PERIOD / speed_norm
            current_time = gettime()
            if self.LastStartTime is not None:
                elapsed_time = current_time - self.LastStartTime
                if elapsed_time > period:
                    self.ScrollMessagePanel(-speed_norm / speed)
                    self.LastStartTime = current_time
                else:
                    period -= elapsed_time
            else:
                self.LastStartTime = current_time
            self.ScrollTimer.Start(int(period * 1000), True)
        self.ScrollSpeed = speed

    def ScrollToLast(self, refresh=True):
        if len(self.LogMessages) > 0:
            self.CurrentMessage = len(self.LogMessages) - 1
            message = self.LogMessages[self.CurrentMessage]
            if not self.FilterLogMessage(message):
                message, self.CurrentMessage = self.GetPreviousMessage(self.CurrentMessage)
            if refresh:
                self.RefreshView()

    def ScrollToFirst(self):
        if len(self.LogMessages) > 0:
            message_idx = 0
            message = self.LogMessages[message_idx]
            if not self.FilterLogMessage(message):
                next_message, msgidx = self.GetNextMessage(message_idx)
                if next_message is not None:
                    message_idx = msgidx
                    message = next_message
            while message is not None:
                message, msgidx = self.GetPreviousMessage(message_idx)
                if message is not None:
                    message_idx = msgidx
            message = self.LogMessages[message_idx]
            if self.FilterLogMessage(message):
                while message is not None:
                    message, msgidx = self.GetNextMessage(message_idx)
                    if message is not None:
                        if not self.IsMessagePanelBottom(msgidx):
                            break
                        message_idx = msgidx
                self.CurrentMessage = message_idx
            else:
                self.CurrentMessage = None
            self.RefreshView()