controls/LogViewer.py
author Andrey Skvortsov <andrej.skvortzov@gmail.com>
Thu, 28 Sep 2017 12:20:40 +0300
changeset 1831 56b48961cc68
parent 1768 691083b5682a
child 1832 0f1081928d65
permissions -rw-r--r--
fix (old-style-class) Old-style class defined error for most parts of
the code

only PyJS code is left, because it does some fancy tricks and can't be
easily fixed. So far PyJS doesn't support Python3 anyway.
#!/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 datetime import datetime
from time import time as gettime
import numpy

import wx

from controls.CustomToolTip import CustomToolTip, TOOLTIP_WAIT_PERIOD
from editors.DebugViewer import DebugViewer, REFRESH_PERIOD
from targets.typemapping import LogLevelsCount, LogLevels
from util.BitmapLibrary import GetBitmap
from weakref import proxy

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()
            width, height = self.GetClientSize()
            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='Courier')
        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 else None
        self.CleanButton.Enable(self.LogSource is not None)
        if log_source is not None:
            self.ResetLogMessages()
            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='')

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

            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()