messing around with python, nostr and qt
import logging
import PySimpleGUI as sg
from PySimpleGUI import Element, Button

import gc
import asyncio
import time
from threading import Thread
from monstr.client.client import Client
from monstr.client.event_handlers import PrintEventHandler
from monstr.ident.event_handlers import NetworkedProfileEventHandler
from monstr.encrypt import Keys


class NostrBackground(Thread):

    def __init__(self, **kargs):
        self._run = True
        self._loop = asyncio.new_event_loop()

        # self._client = Client('ws://localhost:8081')
        self._client = Client('wss://nos.lol')
        # asyncio.create_task(self._client.run())
        super().__init__(target=self.run, args=(self._loop,))

    def run(self):
        print('loop started')
        self._loop.run_until_complete(self._client.run())
        print('loop ended')

    @property
    def client(self) ->Client:
        return self._client

    def stop(self):
        self._client.end()



async def test_example():

    my_background = NostrBackground()
    my_background.start()

    sg.theme('DarkAmber')   # Add a touch of color
    # All the stuff inside your window.
    layout = [  [sg.Text('npub/nsec'), sg.InputText(key='keyIn')],
                [sg.Button('Ok', key='okBut')] ]

    # Create the Window
    window = sg.Window('Window Title', layout)
    # Event Loop to process "events" and get the "values" of the inputs

    # my_background.client.subscribe(filters=[{'limit': 2}], handlers=[PrintEventHandler()])
    peh = NetworkedProfileEventHandler(client=my_background.client)
    ph = PrintEventHandler(max_length=80,
                           profile_handler=peh)

    while True:
        event, values = window.read(10)

        if event == sg.WIN_CLOSED:
            break
        elif event == 'okBut':
            n_key = Keys.get_key(values['keyIn'])
            if n_key is not None:
                asyncio.create_task(my_background.client.query(filters={
                        'authors': [n_key.public_key_hex()],
                        'limit': 10
                    },
                    do_event=ph.do_event))

        await asyncio.sleep(0.1)

    my_background.stop()
    window.close()
    layout = None
    window = None
    gc.collect()

if __name__ == "__main__":
    logging.getLogger().setLevel(logging.DEBUG)
    asyncio.run(test_example())



basic_example.py
monty888/newty
pyside_basic1.py
basic_example2.py
monty888/newty
import asyncio
import functools
import logging
import sys
from datetime import datetime
from asyncio.locks import BoundedSemaphore


import aiohttp
import markdown
from monstr.event.event import Event
from monstr.client.client import Client
from monstr.client.client import QueryTimeoutException
from monstr.util import util_funcs
from monstr.entities import Entities
from monstr.event.event_handlers import EventHandler
from monstr.client.event_handlers import LastEventHandler
from monstr.ident.event_handlers import NetworkedProfileEventHandler
from monstr.encrypt import Keys
import qasync
from qasync import asyncSlot, asyncClose, QApplication


# from PyQt5.QtWidgets import (
from PySide2.QtWidgets import (
    QWidget,
    QLabel,
    QLineEdit,
    QTextEdit,
    QPushButton,
    QVBoxLayout,
    QFormLayout,
    QHBoxLayout,
    QComboBox,
    QDateTimeEdit,
    QCheckBox,
    QSpinBox,
    QSizePolicy,
    QMainWindow,
    QScrollBar,
    QTextBrowser
)

from PySide2.QtCore import (
    QPoint
)

from PySide2.QtGui import (
    QTextCursor,
    QTextBlock,
    QTextBlockFormat
)


DEFAULT_URL = 'ws://localhost:8081'

class QueryEventHandlerText(EventHandler):

    def __init__(self,
                 textarea: QTextEdit,
                 text_func,
                 profile_handler = None):
        self._text_area = textarea
        self._text_func = text_func
        self._profile_handler: NetworkedProfileEventHandler = profile_handler

        self._wait_lock = BoundedSemaphore()

        super().__init__()

    def do_event(self, the_client: Client, sub_id, evt: Event):
        # TODO: probably we can get out of order draws, add some sort of lock...
        # we need to go async for profile fetches
        asyncio.create_task(self.ado_event(the_client, sub_id, evt))

    async def ado_event(self, the_client: Client, sub_id, evt: Event):
        async with self._wait_lock:
            c_evt: Event
            is_eose = True

            # we always get single events except via eose
            if isinstance(evt, Event):
                evt = [evt]
                is_eose = False


            # if we have one try to get the profile_handler to fetch profiles
            if self._profile_handler:
                pub_ks = [c_evt.pub_key
                          for c_evt in evt]

                await self._profile_handler.aget_profiles(pub_ks=pub_ks,
                                                          create_missing=True)

            # TODO add acceptance
            txt_arr = [await self._text_func(c_evt, self._profile_handler)
                       for c_evt in evt]

            the_text = ''.join(txt_arr)

            # wsa eose which means its a new q so just overwrite everything
            if is_eose:
                self._text_area.setHtml(the_text)
            # insert at the top
            else:
                my_cursor = self._text_area.textCursor()
                my_cursor.setPosition(0)
                self._text_area.setTextCursor(my_cursor)

                self._text_area.insertHtml(await self._text_func(evt[0], self._profile_handler))


class QueryFilterDate(QWidget):

    def __init__(self, init_date: datetime = None, day_point='now'):
        super().__init__()
        self._layout = QHBoxLayout()
        self._layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(self._layout)

        if init_date is None:
            init_date = datetime.now()
            if day_point == 'start':
                init_date = init_date.replace(hour=0,minute=0, second=0, microsecond=0)
            elif day_point == 'end':
                init_date = init_date.replace(hour=23,minute=59, second=59,microsecond=999)

        self._date_edit = QDateTimeEdit(enabled=False)
        self._date_edit.setDateTime(init_date)

        self._layout.addWidget(self._date_edit)

        self._enable_check = QCheckBox('enable')
        self._enable_check.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed))
        self._layout.addWidget(self._enable_check)

        # attach events
        self._enable_check.stateChanged.connect(self._on_enabled_changed)

    @asyncSlot()
    async def _on_enabled_changed(self, state):
        self._date_edit.setEnabled(state)

    def value(self):
        ret = None
        if self._enable_check.isChecked():
            ret = self._date_edit.dateTime().toPython()
        return ret


class QueryWindow(QMainWindow):

    def __init__(self):
        super().__init__()
        self.setCentralWidget(MainWindow())


class MyTextEdit(QTextEdit):

    def __init__(self, *args, **kargs):
        super(MyTextEdit, self).__init__(*args, **kargs)
        self.document()

        self._block_lookup = set()


    def mousePressEvent(self, e):
        fpos = e.localPos()

        pos = QPoint(int(fpos.x()), int(fpos.y()))

        cursor = self.cursorForPosition(pos)

        colN = cursor.positionInBlock()

        print('>>>>',cursor.block().text() in self._block_lookup)
        print(cursor.block().text())


    def insertHtml(self, text:str) -> None:
        self.textCursor().insertHtml(text)

        self._block_lookup.add(self.textCursor().block().text())





class MyTextBrowser(QTextBrowser):

    def __init__(self, *args, **kargs):
        super(MyTextBrowser, self).__init__(*args, **kargs)

    def mousePressEvent(self, e):
        fpos = e.localPos()

        pos = QPoint(int(fpos.x()), int(fpos.y()))

        cursor = self.cursorForPosition(pos)

        colN = cursor.positionInBlock()

        print(cursor.block().blockFormat())

        print('MOTHER FUCKER!!!')


class MainWindow(QWidget):
    """Main window."""

    def __init__(self):
        super().__init__()

        self._my_title = 'newty query'
        self._run_status = 'stopped'

        self.setWindowTitle(self._my_title)

        self._layout = QVBoxLayout()
        self.setLayout(self._layout)

        self._form_con = QWidget()
        self._form_layout = QFormLayout()
        self._form_con.setLayout(self._form_layout)

        # self._run_status = QLabel('stopped')
        # self._layout.addWidget(self._run_status)

        self._query_relay_url = QLineEdit(DEFAULT_URL)
        self._form_layout.addRow('relay url', self._query_relay_url)

        self._query_kinds = QLineEdit('')
        self._form_layout.addRow('kinds', self._query_kinds)

        self._query_authors_pane = QWidget()
        self._query_authors_pane_layout = QHBoxLayout()
        self._query_authors_pane_layout.setContentsMargins(0, 0, 0, 0)
        self._query_authors_pane.setLayout(self._query_authors_pane_layout)
        self._query_authors = QLineEdit('')
        self._query_authors_pane_layout.addWidget(self._query_authors)
        self._query_authors_sel_but = QPushButton('select')
        self._query_authors_pane_layout.addWidget(self._query_authors_sel_but)
        self._query_authors_sel_but.clicked.connect(self._open_authors_select)
        self._author_select_dialog = None

        self._form_layout.addRow('authors', self._query_authors_pane)

        self._query_ids = QLineEdit('')
        self._form_layout.addRow('ids', self._query_ids)

        self._query_e_tags = QLineEdit('')
        self._form_layout.addRow('#e', self._query_e_tags)

        self._query_hash_tags = QLineEdit('')
        self._form_layout.addRow('hash tags', self._query_hash_tags)

        self._query_limit = QSpinBox(value=20)
        self._form_layout.addRow('limit', self._query_limit)

        self._query_since = QueryFilterDate(day_point='start')
        self._form_layout.addRow('since', self._query_since)

        self._query_until = QueryFilterDate()
        self._form_layout.addRow('until', self._query_until)

        self._output_style = QComboBox()
        self._output_style.addItem('json')
        self._output_style.addItem('truncated text')
        self._output_style.addItem('full text')

        self._form_layout.addRow('output', self._output_style)

        self._keep_alive = QCheckBox('keep alive', self)
        self._form_layout.addRow(None, self._keep_alive)


        self._layout.addWidget(self._form_con)

        self._status_lbl = QLabel(self._run_status)
        self._form_layout.addRow('status', self._status_lbl)

        self._btnFetch = QPushButton("Fetch", self)
        self._btnFetch.clicked.connect(self.on_btnFetch_clicked)
        self._layout.addWidget(self._btnFetch)

        self._text_help = QTextEdit('', readOnly=True)


        # self._response_text = MyTextBrowser()
        self._response_text = MyTextEdit('', readOnly=True)

        self._response_text_vscroll = self._response_text.verticalScrollBar()

        self._response_text_vscroll.valueChanged.connect(self.on_text_scroll)
        self._at_top = True


        self._layout.addWidget(self._response_text)

        # client for running the queries
        self._query_client: Client = None
        self._query_running = False

        self._background_client = Client('wss://nos.lol')


        # hmmmm ...
        from my_tester import SeenProfiles
        self._client = Client('wss://nos.lol')
        self._profile_handler = NetworkedProfileEventHandler(client=self._client)

        self._seen_profiles = SeenProfiles()
        self._client.subscribe(handlers=self._seen_profiles,
                               filters={
                                   'kinds': [Event.KIND_TEXT_NOTE]
                               })

        self._select_user_dialog = None

        asyncio.create_task(self._client.run())




    def _test_add_menu(self):
        # self.add
        pass


    # @asyncSlot()
    def _open_authors_select(self):
        from my_tester import SelectUser
        self._select_user_dialog = SelectUser(track_seen=self._seen_profiles,
                                              profile_handler=self._profile_handler)
        # await self.exec_QDialog_async(SelectUser(track_seen=self._seen_profiles))

        self._select_user_dialog.accepted.connect(self._authors_accepted)
        self._select_user_dialog.exec_()

    @asyncSlot()
    async def _authors_accepted(self):
        self._query_authors.setText(','.join(self._select_user_dialog.selected_keys))
        self._select_user_dialog = None


    @asyncClose
    async def closeEvent(self, event):
        if self._query_client:
            self._query_client.end()

    # def updateTitle(self) -> None:
    #     super().setWindowTitle(f'{self._my_title} - {self._run_status}')

    def update_status(self):
        self._status_lbl.setText(self._run_status)

    def _set_run_status(self, status: str):
        self._run_status = status
        self.update_status()


    @staticmethod
    def _get_hex_fields(txt: str, for_field: str, max_length: int = 64) -> []:
        # helper function for extracting npub, nnote, hex type fields
        # do we ever expect anything other than max 64?..
        ret = []
        for c_v in txt.split(','):
            to_add = c_v
            if util_funcs.is_hex_part(c_v, max_length=max_length) is False:
                to_add = Entities.decode(c_v)

            if to_add is None:
                raise ValueError(f'bad value for {for_field} - {c_v}')
            ret.append(to_add)
        return ret

    def _get_query_values(self):
        outputs = {
            'json': self._json_event_print,
            'truncated text': self._basic_event_print,
            'full text': self._basic_event_print
        }

        ret = {
            'query_url': self._query_relay_url.text(),
            'filter': {},
            'output': outputs[self._output_style.currentText()],
            'keep_alive': self._keep_alive.isChecked()
        }

        kinds = self._query_kinds.text().strip()
        if kinds:
            ret['filter']['kinds'] = [int(i) for i in kinds.split(',')]

        authors = self._query_authors.text().strip()
        if authors:
            ret['filter']['authors'] = self._get_hex_fields(authors, 'authors')

        ids = self._query_ids.text().strip()
        if ids:
            ret['filter']['ids'] = self._get_hex_fields(ids, 'ids')

        e_tags = self._query_e_tags.text().strip()
        if e_tags:
            ret['filter']['#e'] = self._get_hex_fields(e_tags, '#e')

        hash_tags = self._query_hash_tags.text().strip()
        if hash_tags:
            ret['filter']['#t'] = hash_tags.split(',')

        since = self._query_since.value()
        if since:
            ret['filter']['since'] = util_funcs.date_as_ticks(since)

        until = self._query_until.value()
        if until:
            ret['filter']['until'] = util_funcs.date_as_ticks(until)

        ret['filter']['limit'] = self._query_limit.value()

        logging.info(f'MainWindow::_get_query_values filter = {ret["filter"]}')

        return ret

    async def _basic_event_print(self, evt: Event, profile_handler:NetworkedProfileEventHandler=None, **kargs) -> str:

        content = evt.content
        user = util_funcs.str_tails(evt.pub_key)
        if profile_handler and profile_handler.have_profile(evt.pub_key):
            user = (await profile_handler.aget_profile(evt.pub_key)).display_name()

        return f'<div><span style="color:blue">{user}</span>@{evt.created_at}</div>{content}<br>'

    async def _json_event_print(self, evt: Event, profile_handler=None, **kargs) -> str:
        return str(evt.event_data())

    def _stop_runnning_query(self):
        self._query_client.end()
        self._btnFetch.setText('Fetch')

    @asyncSlot()
    async def on_text_scroll(self):
        pos = self._response_text_vscroll.value()
        self._at_top = pos == 0
        if self._at_top:
            print('we can draw new events now')

        # elif pos == self._response_text_vscroll.maximum():
        #     print('at limits')

    @asyncSlot()
    async def on_btnFetch_clicked(self):
        if self._btnFetch.text() == 'Stop':
            self._stop_runnning_query()
            return

        self._btnFetch.setEnabled(False)
        self._set_run_status('running')

        try:
            use_values = self._get_query_values()
            # self.editResponse.setHtml('<b>test</b>test<div style="color:red">very nice</div>')
            self._query_client = Client(use_values['query_url'])

            # maybe option so can see if that relay can look up profiles
            profile_handler = NetworkedProfileEventHandler(client=self._query_client)

            # this should probably be default option (IS NOW!! )
            if True:
                profile_handler = self._profile_handler


            output_handler = QueryEventHandlerText(textarea=self._response_text,
                                                   text_func=use_values['output'],
                                                   profile_handler=profile_handler)





            self._response_text.clear()

            eose_done = False
            eose_time_out = 10.0
            def my_eose(the_client, sub_id, evt):
                nonlocal eose_done
                profile_handler.do_event(the_client=self._query_client,
                                         sub_id=None,
                                         evts=evt)
                self._seen_profiles.do_event(the_client=self._query_client,
                                             sub_id=None,
                                             evt=evt)
                output_handler.do_event(the_client=self._query_client,
                                        sub_id=None,
                                        evt=evt)
                eose_done = True

            async with self._query_client:
                # note added seen profiles
                self._query_client.subscribe(handlers=[profile_handler, self._seen_profiles, output_handler],
                                       filters=use_values['filter'],
                                       eose_func=my_eose)

                wait_time = 0
                while eose_done is False:
                    await asyncio.sleep(0.1)
                    wait_time += 0.1
                    if wait_time > eose_time_out:
                        raise QueryTimeoutException('query timed out mofo!!!!')

                if use_values['keep_alive']:
                    self._set_run_status('listening')
                    self._btnFetch.setText('Stop')
                    self._btnFetch.setEnabled(True)
                    while self._query_client.running:
                        await asyncio.sleep(0.1)
                        if 'until' in use_values['filter']:
                            if util_funcs.date_as_ticks(datetime.now()) > use_values['filter']['until']:
                                self._stop_runnning_query()


        except Exception as exc:
            self._set_run_status(f'Error: {exc}')
        else:
            self._set_run_status('success')
        finally:
            self._client = None
            self._btnFetch.setEnabled(True)
        print('done')

async def main():
    def close_future(future, loop):
        loop.call_later(10, future.cancel)
        future.cancel()

    loop = asyncio.get_event_loop()
    future = asyncio.Future()

    app = QApplication.instance()
    if hasattr(app, "aboutToQuit"):
        getattr(app, "aboutToQuit").connect(
            functools.partial(close_future, future, loop)
        )

    mainWindow = QueryWindow()
    mainWindow.show()

    await future
    return True


if __name__ == "__main__":
    logging.getLogger().setLevel(logging.DEBUG)
    try:
        qasync.run(main())
    except asyncio.exceptions.CancelledError:
        sys.exit(0)
basic_query.py
monty888/newty
from monstr.client.client import Client
from monstr.client.event_handlers import EventAccepter
from monstr.event.event import Event
"""
    Probably this utility acceptors would make sense to move to monstr
    doc what each does properly though
    these are used where we want to filter events beyond what is possible from the basic filters we can pass to the
    relays 
"""

class PostsOnlyAcceptor(EventAccepter):

    def accept_event(self,
                     the_client: Client,
                     sub_id: str,
                     evt: Event) -> bool:
        ret = True
        e_tags = evt.get_tags('e')
        if e_tags:
            for c_tag in e_tags:
                # old style?
                if len(c_tag) == 1:
                    ret = False
                    break
                elif len(c_tag) >= 3:
                    t_val = str(c_tag[2]).lower()
                    if t_val == 'reply':
                        ret = False
                        break

        return ret


class RepliesOnlyAcceptor(PostsOnlyAcceptor):
    def accept_event(self,
                     the_client: Client,
                     sub_id: str,
                     evt: Event) -> bool:
        return not super().accept_event(the_client, sub_id, evt)
monstr_qt/gui/event/acceptors.py
monty888/newty
from abc import abstractmethod, ABC
from monstr.encrypt import Keys
from monstr.client.event_handlers import EventAccepter
from monstr.ident.event_handlers import NetworkedProfileEventHandler

# probably we move these eventually into monstr
from monstr_qt.gui.event.acceptors import PostsOnlyAcceptor, RepliesOnlyAcceptor


# class DynamicFilter(ABC):
#
#     @abstractmethod
#     async def ainit_filter(self):
#         pass


# class EventView:
#
#     def __init__(self,
#                  name: str,
#                  view_filter: [dict],
#                  path: str = None,
#                  view_acceptor: EventAccepter = None,
#                  filter_tracker: DynamicFilter = None):
#
#         self._name = name
#         self._path = path
#         self._full_name = name
#         if path:
#             self._full_name = f'{name}{path}'
#         else:
#             self._path = ''
#
#         self._view_filter = view_filter
#         self._view_acceptor = view_acceptor
#         self._filter_tracker = filter_tracker
#
#     @property
#     def name(self):
#         return self._name
#
#     @property
#     def full_name(self):
#         return self._full_name
#
#     @property
#     def path(self):
#         return self._path
#
#     @property
#     def view_filter(self):
#         return self._view_filter
#
#     @view_filter.setter
#     def view_filter(self, view_filter):
#         self._view_filter = view_filter
#
#     @property
#     def filter_tracker(self):
#         return self._filter_tracker
#
#     @property
#     def view_acceptor(self):
#         return self._view_acceptor
#
#     def __str__(self):
#         return self.full_name
#
#
# class UserContactsFilter(DynamicFilter):
#
#     def __init__(self,
#                  base_filter: dict,
#                  for_view: EventView,
#                  for_user: Keys,
#                  profile_handler: NetworkedProfileEventHandler):
#
#         self._base_filter = base_filter
#         self._for_view = for_view
#         self._for_user = for_user
#         self._profile_handler = profile_handler
#
#     async def ainit_filter(self):
#         contacts = await self._profile_handler.aload_contacts(self._for_user.public_key_hex())
#         self._bas
#
#         self._for_view.view_filter = {
#             'kinds': [1],
#             'authors': contacts.follow_keys(),
#             'limit': 40
#         }
#
#
# class CurrentUserFollowsPostsFilter(EventView):
#
#     def __init__(self,
#                  current_user: Keys,
#                  profile_handler: NetworkedProfileEventHandler):
#         self._current_user = current_user
#         self._profile_handler = profile_handler
#         self._filter_tracker = UserContactsFilter(for_view=self,
#                                                   for_user=current_user,
#                                                   profile_handler=profile_handler)
#
#         super().__init__(name='local',
#                          path='/user/follows/posts',
#                          view_filter=None,
#                          view_acceptor=PostsOnlyAcceptor(),
#                          filter_tracker=self._filter_tracker)
#
#
# class CurrentUserFollowsThreadsFilter(EventView):
#
#     def __init__(self,
#                  current_user: Keys,
#                  profile_handler: NetworkedProfileEventHandler):
#         self._current_user = current_user
#         self._profile_handler = profile_handler
#         self._filter_tracker = UserContactsFilter(for_view=self,
#                                                   for_user=current_user,
#                                                   profile_handler=profile_handler)
#
#         super().__init__(name='local',
#                          path='/user/follows/threads',
#                          view_filter=None,
#                          view_acceptor=RepliesOnlyAcceptor(),
#                          filter_tracker=self._filter_tracker)
monstr_qt/gui/event/views.py
monty888/newty
from PySide2.QtCore import Qt, QMargins, QPoint, QRect, QSize
from PySide2.QtWidgets import QLayout, QSizePolicy
"""
    copied from https://doc.qt.io/qtforpython-6/examples/example_widgets_layouts_flowlayout.html
"""


class FlowLayout(QLayout):
    def __init__(self, parent=None):
        super().__init__(parent)

        if parent is not None:
            self.setContentsMargins(QMargins(0, 0, 0, 0))

        self._item_list = []

    def __del__(self):
        item = self.takeAt(0)
        while item:
            item = self.takeAt(0)

    def addItem(self, item):
        self._item_list.append(item)

    def count(self):
        return len(self._item_list)

    def itemAt(self, index):
        if 0 <= index < len(self._item_list):
            return self._item_list[index]

        return None

    def takeAt(self, index):
        if 0 <= index < len(self._item_list):
            return self._item_list.pop(index)

        return None

    def expandingDirections(self):
        return Qt.Orientation(0)

    def hasHeightForWidth(self):
        return True

    def heightForWidth(self, width):
        height = self._do_layout(QRect(0, 0, width, 0), True)
        return height

    def setGeometry(self, rect):
        super(FlowLayout, self).setGeometry(rect)
        self._do_layout(rect, False)

    def sizeHint(self):
        return self.minimumSize()

    def minimumSize(self):
        size = QSize()

        for item in self._item_list:
            size = size.expandedTo(item.minimumSize())

        size += QSize(2 * self.contentsMargins().top(), 2 * self.contentsMargins().top())
        return size

    def _do_layout(self, rect, test_only):
        x = rect.x()
        y = rect.y()
        line_height = 0
        spacing = self.spacing()

        for item in self._item_list:
            style = item.widget().style()
            layout_spacing_x = style.layoutSpacing(
                QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
            )
            layout_spacing_y = style.layoutSpacing(
                QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
            )
            space_x = spacing + layout_spacing_x
            space_y = spacing + layout_spacing_y
            next_x = x + item.sizeHint().width() + space_x
            if next_x - space_x > rect.right() and line_height > 0:
                x = rect.x()
                y = y + line_height + space_y
                next_x = x + item.sizeHint().width() + space_x
                line_height = 0

            if not test_only:
                item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))

            x = next_x
            line_height = max(line_height, item.sizeHint().height())

        return y + line_height - rect.y()
monstr_qt/gui/layout.py
monty888/newty
import asyncio
from PySide2.QtWidgets import (
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QTextEdit,
    QLabel,
    QSizePolicy,
    QPushButton,
    QScrollArea,
    QDialog,
    QLineEdit,
    QStyle
)

from PySide2.QtGui import (
    QPixmap,
    Qt,
    QPaintEvent,
    QPainter
)

from qasync import asyncClose
from monstr.client.client import Client
from monstr.client.event_handlers import EventAccepter
from monstr.event.event import Event
from monstr.ident.event_handlers import ProfileEventHandlerInterface, NetworkedProfileEventHandler
from monstr.ident.profile import Profile
from monstr.util import util_funcs
from monstr.encrypt import Keys
from monstr_qt.gui.layout import FlowLayout
from monstr_qt.util import ResourceFetcher
from monstr_qt.gui.util import mask_image


class VerticalScrollArea(QScrollArea):

    def __init__(self,
                 *args, **kargs):
        super(VerticalScrollArea, self).__init__(*args, **kargs)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setWidgetResizable(True)

        my_con = QWidget()
        self._layout = QVBoxLayout()
        my_con.setLayout(self._layout)

        self.setWidget(my_con)

    def addWidget(self, the_widget, **kargs):
        self._layout.addWidget(the_widget, **kargs)

    def insertTop(self, the_widget):
        self._layout.insertWidget(0, the_widget)

    def clear(self):
        for i in reversed(range(self._layout.count())):
            self._layout.itemAt(i).widget().setParent(None)


class LabelWithRemoteImage(QLabel):

    def __init__(self, *args,
                 image_url: str = None,
                 size=64,
                 resource_load: ResourceFetcher = None,
                 **kargs):

        if 'fixedWidth' not in kargs:
            kargs['fixedWidth'] = size
        if 'fixedHeight' not in kargs:
            kargs['fixedHeight'] = size

        super(LabelWithRemoteImage, self).__init__(*args, **kargs)
        self._image_url = image_url
        self._size = size
        self._resource_load = resource_load

        # set the image on the lbl or start to load it and set it when we have it
        self._set_image()

    @property
    def image_url(self):
        return self._image_url

    @image_url.setter
    def image_url(self, image_url:str):
        self._image_url = image_url
        self._set_image()

    def _set_image(self):
        ret = None
        if self._resource_load and \
                self._image_url is not None and \
                self._image_url.lower().startswith('http'):
            # we already have the image
            if self._image_url in self._resource_load:
                img_data = self._resource_load[self._image_url]
                if img_data:
                    self.setPixmap(self._pixmap_from_data(img_data))
            # we don't have it so we'll start a job to fetch and then update ourself
            else:
                asyncio.create_task(self._fetch_image())

        return ret

    def _pixmap_from_data(self, img_data: bytes):
        # we should change it so we save cache this translations also
        # and don't have to always work from the original img data
        pxmap = QPixmap()
        pxmap.loadFromData(img_data)
        # return mask_image(pxmap.scaled(self._size, self._size).toImage())
        return mask_image(pxmap, to_size=self._size)

    async def _fetch_image(self):
        img_data = await self._resource_load.get(self._image_url)
        if img_data:
            self.setPixmap(self._pixmap_from_data(img_data))


class LocationSource:

    def __init__(self,
                 name,
                 data_source,
                 profile_handler: NetworkedProfileEventHandler = None):
        self._name = name
        self._data_source = data_source
        self._profile_handler = profile_handler

    @property
    def name(self):
        return self._name

    @property
    def data_source(self):
        return self._data_source

    @property
    def profile_handler(self):
        return self._profile_handler


class Location:

    SOURCE_SEP = '://'
    PATH_SEP = '/'

    def __init__(self,
                 source: LocationSource,
                 identifier: str,
                 current_user: Keys = None,
                 data=None):

        self._source = source
        self._identifier = identifier
        self._context: str = ''
        self._cmd: str
        self._params: [[str]] = []
        self._current_user = current_user
        self._decode()
        self._data = data

    def _decode(self):
        address_parts = self._identifier.split('?')
        if len(address_parts) > 1:
            for c_param in address_parts[1].split('&'):
                n_v_split = c_param.split('=')
                if len(n_v_split) >= 2:
                    self._params.append([n_v_split[0]] + n_v_split[1].split(','))
                else:
                    # skip
                    pass

        context_parts = address_parts[0].split('/')

        self._cmd = context_parts[len(context_parts)-1]
        if len(context_parts) >= 1:
            self._context = '/'.join(context_parts[:len(context_parts)-1])

    @property
    def full_path(self):
        return f'{self._source.name}{Location.SOURCE_SEP}{self.resource_path}'

    @property
    def full_address(self):
        ret = self.full_path
        if self.params_str:
            ret += f'?{self.params_str}'
        return ret

    @property
    def params_str(self):
        ret = []
        if self._params:
            for c_param in self._params:
                ret.append(f'{c_param[0]}={",".join(c_param[1:])}')

        return '&'.join(ret)

    def get_param(self, name):
        ret = []
        for c_param in self._params:
            if c_param[0] == name:
                ret = c_param[1:]
                break
        return ret

    @property
    def resource_path(self):
        ret = self._context

        if ret:
            ret += Location.PATH_SEP

        ret += self._cmd
        return ret

    @property
    def cmd(self):
        return self._cmd

    @property
    def source(self) -> LocationSource:
        return self._source

    @property
    def context(self):
        return self._context

    @property
    def current_user(self):
        return self._current_user

    @property
    def data(self):
        return self._data

    @data.setter
    def data(self, data):
        self._data = data

    def __str__(self):
        return self.full_address


class AddressBarPane(QWidget):

    def __init__(self, *args, on_action=None, **kargs):
        super(AddressBarPane, self).__init__(*args, **kargs)

        self._layout = QHBoxLayout()
        self.setLayout(self._layout)
        self._layout.setContentsMargins(0, 0, 0, 0)

        self._back_button = QPushButton()
        pixmap = getattr(QStyle, 'SP_ArrowBack')
        icon = self.style().standardIcon(pixmap)
        self._back_button.setIcon(icon)

        self._forward_button = QPushButton()
        pixmap = getattr(QStyle, 'SP_ArrowRight')
        icon = self.style().standardIcon(pixmap)
        self._forward_button.setIcon(icon)

        self._up_button = QPushButton()
        pixmap = getattr(QStyle, 'SP_ArrowUp')
        icon = self.style().standardIcon(pixmap)
        self._up_button.setIcon(icon)


        self._layout.addWidget(self._back_button)
        self._layout.addWidget(self._forward_button)
        self._layout.addWidget(self._up_button)

        self._address_bar = QLineEdit()

        self._address_bar.returnPressed.connect(self._address_entered)

        self._layout.addWidget(self._address_bar)

        self._locations = []
        self._current_index = None

        self._forward_button.clicked.connect(self._do_forward)
        self._back_button.clicked.connect(self._do_back)
        self._up_button.clicked.connect(self._do_up)

        self._on_action = on_action

    def _address_entered(self):
        if self._address_bar.text() != self.current_location.full_address:
            # hack for now - source will be seperate
            identifier = self._address_bar.text().replace(f'{self.current_location.source.name}://','')

            n_location = Location(source=self.current_location.source,
                                  identifier=identifier,
                                  current_user=self.current_location.current_user)

            if self._on_action:
                self.append(n_location)
                self._on_action({
                    'type': 'new',
                    'location': n_location
                })

    def _do_forward(self):
        self._current_index += 1
        n_location = self._locations[self._current_index]
        self._address_bar.setText(str(n_location))
        if self._on_action:
            self._on_action({
                'type': 'forward',
                'location': n_location
            })

        self._set_nav_btn_state()

    def _do_back(self):
        self._current_index -= 1

        n_location: Location = self._locations[self._current_index]
        self._address_bar.setText(n_location.full_address)

        if self._on_action:
            self._on_action({
                'type': 'back',
                'location': n_location
            })

        self._set_nav_btn_state()

    def _do_up(self):
        c_location = self.current_location
        context_split = c_location.context.split('/')

        context_split = context_split[:len(context_split)-1]
        if c_location.cmd:
            context_split += [c_location.cmd]
        n_identifier = '/'.join(context_split)

        if c_location.params_str:
            n_identifier += f'?{c_location.params_str}'

        n_location = Location(source=c_location.source,
                              identifier=n_identifier,
                              current_user=c_location.current_user)
        if self._on_action:
            self.append(n_location)

            self._on_action({
                'type': 'up',
                'location': n_location
            })

        self._set_nav_btn_state()

    def set_state(self, locations: [Location], index: int = 0):
        self._locations = locations
        self._current_index = index
        self._address_bar.setText(self.current_location.full_address)
        self._set_nav_btn_state()

    def get_state(self) -> ([Location], int):
        return self._locations, self._current_index

    def _set_nav_btn_state(self):
        self._back_button.setEnabled(self._current_index is not None
                                     and self._current_index != 0)

        self._forward_button.setEnabled(self._current_index is not None
                                        and self._current_index != len(self._locations)-1)

        c_location: Location = self._locations[self._current_index]
        self._up_button.setEnabled(c_location.context != '')

    # @property
    # def current_text(self):
    #     return self._locations[self._current_index]

    @property
    def current_location(self) -> Location:
        return self._locations[self._current_index]

    def append(self, location: Location):
        # we append to where we at, locations after that point are cut
        if self._current_index < len(self._locations)-1:
            self._locations = self._locations[:self._current_index+1]

        self._locations.append(location)
        self._current_index = len(self._locations)-1

        self._address_bar.setText(location.full_address)
        self._set_nav_btn_state()


if __name__ == "__main__":
    src = LocationSource('local', None)
    location = Location(source=src,
                        identifier='user/profile')

    print(location.full_path)
monstr_qt/gui/nostr.py
monty888/newty
import asyncio
from abc import ABC, abstractmethod
from functools import partial
from typing import Dict

from PySide2.QtWidgets import (
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QLabel,
    QTextEdit,
    QPushButton,
    QSizePolicy
)

from PySide2.QtGui import (
    Qt
)


from monstr.client.client import Client
from monstr.client.event_handlers import EventAccepter
from monstr.event.event import Event
from monstr.ident.profile import Profile
from monstr.encrypt import Keys
from monstr.ident.event_handlers import NetworkedProfileEventHandler, ProfileEventHandlerInterface
from monstr.util import util_funcs
from monstr_qt.util import ResourceFetcher
from monstr_qt.gui.nostr import VerticalScrollArea, FlowLayout, LabelWithRemoteImage
from monstr_qt.gui.panes.panes import ViewPane
from monstr_qt.gui.nostr import Location
from monstr_qt.gui.event.acceptors import PostsOnlyAcceptor, RepliesOnlyAcceptor
# from monstr_qt.gui.event.views import CurrentUserFollowsPostsFilter, CurrentUserFollowsThreadsFilter


class DynamicFilter(ABC):
    """
            A filter that requires data from relay
        """
    @abstractmethod
    async def ainit_filter(self):
        pass


class UserContactsFilter(DynamicFilter):
    """
        TODO: this should also be changing on follower changes
    """
    def __init__(self,
                 base_filter: dict,
                 for_user: Keys,
                 profile_handler: NetworkedProfileEventHandler):
        self._base_filter = base_filter
        self._filter = None
        self._for_user = for_user
        self._profile_handler = profile_handler

    async def ainit_filter(self) -> dict:
        contacts = await self._profile_handler.aload_contacts(self._for_user.public_key_hex())
        self._filter = self._base_filter.copy()
        self._filter['authors'] = contacts.follow_keys()
        return self._filter

    @property
    def filter(self):
        # TODO: this should error if ainit_filter hasn't beem called
        return self._filter


class EventView:

    def __init__(self,
                 view_filter: [dict | DynamicFilter],
                 view_acceptor: EventAccepter = None):

        self._view_filter = view_filter
        self._view_acceptor = view_acceptor

    @property
    def view_filter(self):
        return self._view_filter

    @view_filter.setter
    def view_filter(self, view_filter):
        self._view_filter = view_filter

    @property
    def view_acceptor(self):
        return self._view_acceptor


class BasicEventDisplay(QWidget):

    def __init__(self,
                 *args,
                 event: Event,
                 callback,
                 profile_handler:ProfileEventHandlerInterface = None,
                 resource_loader: ResourceFetcher = None,
                 current_user: Keys = None,
                 **kargs):
        super(BasicEventDisplay, self).__init__(*args, **kargs)

        self._event = event
        self._profile_handler = profile_handler
        self._callback = callback
        self._current_user = current_user

        # self._scaled_pixmap_cache = LRUCache(maxsize=10000)

        self._resource_load = resource_loader

        # self._event_layout = QGridLayout()
        # self._event_layout.setColumnStretch(0, 1)

        # try with vbox, maybe grid will be better?
        self._event_layout = QVBoxLayout()
        # three sections header, main body(content) and footer (actions?)
        self._create_panes()
        # create the widgets and fill with event data (that we have now)
        self._create_widgets()

        self.setLayout(self._event_layout)

    def _create_panes(self):
        # create title container
        self._title_pane = QWidget()
        self._title_layout = QHBoxLayout()
        self._title_layout.setContentsMargins(0, 0, 0, 0)
        self._title_pane.setLayout(self._title_layout)
        self._event_layout.addWidget(self._title_pane)

        # create content area
        self._content_pane = QWidget()
        self._content_layout = QHBoxLayout()
        self._content_layout.setContentsMargins(0, 0, 0, 0)
        self._content_pane.setLayout(self._content_layout)
        self._event_layout.addWidget(self._content_pane)

        # create the footer area
        self._foot_pane = QWidget()
        self._foot_layout = QHBoxLayout()
        self._foot_layout.setContentsMargins(0, 0, 0, 0)
        self._foot_pane.setLayout(self._foot_layout)
        self._event_layout.addWidget(self._foot_pane)

        # split footer into 2 panes one for tags, one for controls?
        self._hash_pane = QWidget()
        # self._hash_scroll = QScrollArea()
        # self._hash_scroll.setWidget(self._hash_pane)
        self._hash_layout = QHBoxLayout()

        self._hash_layout = FlowLayout()

        self._hash_layout.setContentsMargins(0, 0, 0, 0)
        self._hash_pane.setLayout(self._hash_layout)
        self._foot_layout.addWidget(self._hash_pane)


    def _create_widgets(self):
        author_k = self._event.pub_key
        author_p: Profile = None

        if self._profile_handler and self._profile_handler.have_profile(author_k):
            author_p = self._profile_handler.get_profile(author_k)

        # add
        user_display = util_funcs.str_tails(author_k)
        if author_p:
            user_display = author_p.display_name()
        self._pub_k_widget = QLabel(user_display)
        self._pub_k_widget.setStyleSheet('font-weight: bold;')
        self._title_layout.addWidget(self._pub_k_widget)

        # title_layout.addWidget(QSpacerItem())
        time_label = QLabel(str(self._event.created_at))
        time_label.setAlignment(Qt.AlignRight)

        self._title_layout.addWidget(time_label)

        # create text widget and fill with event content
        evt_content = self._event.content
        if self._event.kind == Event.KIND_ENCRYPT \
                and self._current_user is not None and self._current_user.private_key_hex():
            try:
                evt_content = Event.decrypt_nip4(evt=self._event,
                                                 keys=self._current_user).content
            except Exception as e:
                print(e)
                # TODO fix monstr exception text this leaks private key!!!
                evt_content = str(e)


        my_text = QTextEdit(evt_content)
        my_text.setReadOnly(True)
        # my_text.setLineWrapMode(QTextEdit.FixedColumnWidth)
        # my_text.setLineWrapColumnOrWidth(160)
        my_text.setMaximumHeight(100)
        my_text.sizePolicy().setHorizontalPolicy(QSizePolicy.Expanding)

        # make placeholder images for if we can't load or aren't loading e.g. robbos
        author_img_url = None
        if author_p:
            author_img_url = author_p.get_attr('picture')


        # self._profile_img_lbl.setAlignment(Qt.AlignTop)
        # self._profile_img_lbl.setStyleSheet('border: 1px solid black; border-radius: 10px')
        if author_p:
            author_img_url = author_p.get_attr('picture')

        self._profile_img_lbl = LabelWithRemoteImage('[X]',
                                                     image_url=author_img_url,
                                                     size=64,
                                                     resource_load=self._resource_load)

        self._content_layout.addWidget(self._profile_img_lbl, alignment=Qt.AlignTop)
        self._content_layout.addWidget(my_text, alignment=Qt.AlignTop)

        # todo what to put here in the footer?
        hash_tags = self._event.get_tags_value('t')
        for v in hash_tags:
            tag_widget = QPushButton(f'#{v}')
            tag_widget.setFlat(True)
            tag_widget.setStyleSheet('color: blue;')
            tag_widget.clicked.connect(partial(self._tag_pressed, v))
            self._hash_layout.addWidget(tag_widget)

    def _tag_pressed(self, *args):
        self._callback({
            'type': 'hashtag_clicked',
            'name': args[0]
        })

    def updated_profiles(self, updated_profiles: set):
        if self._event.pub_key in updated_profiles:
            author_p: Profile = self._profile_handler.get_profile(self._event.pub_key)
            self._pub_k_widget.setText(author_p.display_name())
            self._profile_img_lbl.image_url = author_p.get_attr('picture')


def event_view_from_location(location: Location,
                             profile_handler: NetworkedProfileEventHandler) -> dict[str, str] | EventView:
    """
        good enough for now I guess....
        decoding the content is going to be the main pain...

    """

    # if user context user then error if we don't have a current_user
    if location.context == 'user':
        if location.current_user is None:
            return {
                'error': f'no user set for can\'t create view {location.full_path}'
            }

    param_args = {}
    hash_params = location.get_param('t')
    if hash_params:
        param_args['#t'] = hash_params

    if location.cmd == 'note':
        """
            this should contain posts and replies
        """
        filter_args = {
            'limit': 20,
            'kinds': [1]
        }

        filter_args.update(param_args)

        if location.context == 'user':
            filter_args['authors'] = [location.current_user.public_key_hex()]

        ret = EventView(view_filter=filter_args)

    elif location.cmd == 'post':
        # as not but we add an acceptor so that only posts are included

        filter_args = {
            'limit': 20,
            'kinds': [1]
        }

        filter_args.update(param_args)

        filter_tracker = None
        if location.context == 'user':
            filter_args['authors'] = [location.current_user.public_key_hex()]
        elif location.context == 'user/follows':
            filter_args['limit'] = 40
            filter_args = UserContactsFilter(base_filter=filter_args,
                                             for_user=location.current_user,
                                             profile_handler=profile_handler)

        ret = EventView(view_filter=filter_args,
                        view_acceptor=PostsOnlyAcceptor())

    elif location.cmd == 'reply':
        # only where we were replying to some other event

        filter_args = {
            'limit': 20,
            'kinds': [1]
        }

        filter_args.update(param_args)

        if location.context == 'user':
            filter_args['authors'] = [location.current_user.public_key_hex()]
        elif location.context == 'user/follows':
            filter_args['limit'] = 40
            filter_args = UserContactsFilter(base_filter=filter_args,
                                             for_user=location.current_user,
                                             profile_handler=profile_handler)

        ret = EventView(view_filter=filter_args,
                        view_acceptor=RepliesOnlyAcceptor())

    elif location.cmd == 'dm':
        filter_args = {
            'limit': 20,
            'kinds': [4]
        }
        if location.context == 'user':
            filter_args = [
                {
                    'kinds': [4],
                    'limit': 20,
                    'authors': [location.current_user.public_key_hex()]
                },
                {
                    'kinds': [4],
                    'limit': 20,
                    '#p': [location.current_user.public_key_hex()]
                }
            ]
        ret = EventView(view_filter=filter_args)
    else:
        ret = {
            'error': f'unknown command {location.cmd}'
        }

    return ret


class EventViewPane(ViewPane):
    """
        updating scroll view of events using BasicEventDisplay for each event

        TODO: change from QDialog to be on a widget
            working with client pool
            handing in the things it needs

    """
    def __init__(self,
                 *args,
                 location: Location,
                 on_actions=None,
                 resource_fetcher: ResourceFetcher = None,
                 **kargs):

        super(EventViewPane, self).__init__(location=location, *args, **kargs)

        # self._layout = QVBoxLayout()
        # self.setLayout(self._layout)
        # self.setFixedWidth(1000)

        self._scroll_pane = VerticalScrollArea()

        # should be handed in
        self._resource_fetch = resource_fetcher

        # self._scroll_pane = QWidget()
        # self._scroll_layout = QVBoxLayout()
        # self._scroll_pane.setLayout(self._scroll_layout)

        self._layout.addWidget(self._scroll_pane)

        # for now this is good enough, probably the actual source
        # will always be either Client/ClientPool which to us are identical
        self._location = location
        self._client = location.source.data_source

        self._profile_handler = location.source.profile_handler

        self._view = event_view_from_location(location=location,
                                              profile_handler=self._profile_handler)
        if isinstance(self._view, EventView):
            self._current_filter = self._view.view_filter
            self._event_acceptor = self._view.view_acceptor
            self._sub_id = None
            self._scroll_pane.addWidget(QLabel('loading...'), alignment=Qt.AlignTop)
            asyncio.create_task(self.fetch_events())
        else:
            self._scroll_pane.addWidget(QLabel(self._view['error']), alignment=Qt.AlignTop)

        # map events to widgets
        self._events_map = {}

        self._open = True
        self._on_actions = on_actions


    @property
    def current_filter(self):
        return self._current_filter

    @property
    def event_acceptor(self):
        return self._event_acceptor

    def stop(self):
        if self._sub_id:
            self._client.unsubscribe(self._sub_id)
            self._events_map = {}
            # self._scroll_pane.clear()

        self._open = False

    def pause(self):
        # should just be self._stop_current() but doesnt seem to work at the moment
        # looks like QT killed the pane? we might need to recreate pane
        pass

    def restart(self):
        if self._sub_id is None:
            asyncio.create_task(self.fetch_events())
        else:
            self._client.subscribe(sub_id=self._sub_id,
                                   filters=self._current_filter,
                                   eose_func=self.on_eose,
                                   handlers=self)

    async def fetch_events(self):
        await self._client.wait_connect()

        if isinstance(self._current_filter, DynamicFilter):
            await self._current_filter.ainit_filter()
            event_filter = self._current_filter.filter
        else:
            event_filter = self._current_filter

        self._scroll_pane.clear()
        self._sub_id = self._client.subscribe(filters=event_filter,
                                              eose_func=self.on_eose,
                                              handlers=self)
        while self._client.running and self._open is True:
            await asyncio.sleep(0.1)

    def _make_event_widget(self, evt):
        evt_widget = BasicEventDisplay(event=evt,
                                       profile_handler=self._profile_handler,
                                       resource_loader=self._resource_fetch,
                                       current_user=self._location.current_user,
                                       callback=self._do_actions)

        self._events_map[evt.id] = {
            'widget': evt_widget
        }

        return evt_widget

    def _do_actions(self, data):
        if self._on_actions:
            self._on_actions(data)

    async def _fetch_events_profile(self, evts: [Event]):
        if not self._profile_handler:
            return

        c_evt: Event

        to_fetch = set()
        for c_evt in evts:
            # if not self._profile_handler.have_profile(c_evt.pub_key):
            to_fetch.add(c_evt.pub_key)

        # to_fetch = list(to_fetch)
        #
        # if to_fetch:
        loaded_profiles = await self._profile_handler.aget_profiles(to_fetch, create_missing=True)
        # tell all our widgets that we loaded these profiles, up to then if they want to do
        # anything with it

        loaded_profiles = set([p.public_key for p in loaded_profiles])
        for c_item in self._events_map.values():
            c_widget = c_item['widget']
            c_widget.updated_profiles(loaded_profiles)

    def do_event(self, the_client: Client, sub_id, evt: Event):
        if self._event_acceptor is None or \
                self._event_acceptor.accept_event(the_client, sub_id, evt):
            self._scroll_pane.insertTop(self._make_event_widget(evt))
            asyncio.create_task(self._fetch_events_profile([evt]))

    def on_eose(self, the_client: Client, sub_id, evt: [Event]):
        Event.sort(evt, inplace=True, reverse=True)
        accepted_evts = []
        for c_evt in evt:
            if self._event_acceptor is None \
                    or self._event_acceptor.accept_event(the_client, sub_id, c_evt):
                accepted_evts.append(c_evt)
                self._scroll_pane.addWidget(self._make_event_widget(c_evt))

        asyncio.create_task(self._fetch_events_profile(accepted_evts))

    def closeEvent(self, event):
        self._open = False
monstr_qt/gui/panes/event.py
monty888/newty
from abc import abstractmethod
from PySide2.QtWidgets import (
    QWidget,
    QVBoxLayout,
    QLabel
)

from monstr_qt.gui.nostr import Location


class ViewPane(QWidget):

    def __init__(self,
                 location: Location,
                 *args,
                 **kargs):
        super(ViewPane, self).__init__(*args, **kargs)
        self._location = location
        self._layout = QVBoxLayout()
        self.setLayout(self._layout)

    @abstractmethod
    def stop(self):
        """
        for example unregister any subscriptions and end anyother task, won't be restarted after this
        """

    @abstractmethod
    def pause(self):
        """
        for example unregister any subscriptions, can be restarted
        """

    @abstractmethod
    def restart(self):
        """
        for example re-register any subscriptions, call after pause
        """

    @property
    def location(self) -> Location:
        return self._location
monstr_qt/gui/panes/panes.py
monty888/newty
import asyncio

from PySide2.QtWidgets import (
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QLabel,
    QTextEdit,
    QPushButton,
    QSizePolicy,
    QFormLayout,
    QLineEdit,
    QTextEdit,
    QSpacerItem
)

from PySide2.QtGui import (
    QPixmap,
    Qt,
    QPaintEvent,
    QPainter
)

from monstr.ident.event_handlers import NetworkedProfileEventHandler
from monstr_qt.gui.panes.panes import ViewPane
from monstr_qt.gui.nostr import Location
from monstr_qt.util import ResourceFetcher
from monstr_qt.gui.nostr import VerticalScrollArea
from monstr.ident.profile import Profile
from monstr.client.client import Client
from monstr.encrypt import Keys


class PictureArea(QWidget):
    def __init__(self,
                 profile: Profile,
                 resource_fetcher: ResourceFetcher = None,
                 *args,
                 **kargs):
        super(PictureArea, self).__init__(*args, **kargs)

        self._layout = QVBoxLayout()
        self.setLayout(self._layout)

        self._resource_fetcher = resource_fetcher
        self._profile = profile

        # TODO when we have media disabled something like this would be possible so
        #  we should have something for that case
        #

        self._picture_area = QLabel('loading...')
        self._layout.addWidget(self._picture_area)
        if resource_fetcher:
            asyncio.create_task(self.load_images())
        else:
            self._picture_area.setText('TODO --- media is disabled????')

    async def load_images(self):
        banner_img = None
        profile_img = None

        # we should send this off together, look into amd then wait for both to complete
        profile_img_url = self._profile.get_attr('picture')
        if profile_img_url:
            profile_img = await self._resource_fetcher.get(profile_img_url)

        banner_img_url = self._profile.get_attr('banner')
        if banner_img_url:
            banner_img = await self._resource_fetcher.get(banner_img_url)

        if banner_img and profile_img:
            # self._picture_area.setText('yeah man')

            pxmap = QPixmap()
            pxmap.loadFromData(banner_img)

            print(self.parent().width())

            # pxmap = pxmap.scaledToWidth(self.width(), Qt.SmoothTransformation)
            pxmap = pxmap.scaled(640, 128, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)


            self._picture_area.setPixmap(pxmap)


class ProfileFields(QWidget):

    def __init__(self,
                 profile: Profile,
                 *args,
                 **kargs):
        super(ProfileFields, self).__init__(*args, **kargs)

        self._layout = QFormLayout()

        self.setLayout(self._layout)

        self._profile = profile
        self._name = ''
        self._nip05 = ''
        self._about = ''
        self._picture = ''
        self._banner = ''

        # TODO: change Profile get_attr to have default value
        def get_attr(name):
            ret = self._profile.get_attr(name)
            if ret is None:
                ret = ''
            return ret

        others = []
        if self._profile:
            self._name = self._profile.name
            self._nip05 = self._profile.nip05
            self._about = get_attr('about')
            self._picture = get_attr('picture')
            self._banner = get_attr('banner')

            # add any other fields we see that are not included above
            for k,v in self._profile.attrs.items():
                if k not in ('name', 'nip05', 'about', 'picture', 'banner'):
                    others.append([k, v])


        self._layout.addRow('name', QLineEdit(self._name))
        self._layout.addRow('nip05', QLineEdit(self._nip05))

        self._about_text = QTextEdit(self._about)
        # self._about_text.setMaximumHeight(100)
        self._layout.addRow('about', self._about_text)

        self._layout.addRow('picture', QLineEdit(self._picture))
        self._layout.addRow('banner', QLineEdit(self._banner))

        for c_extras in others:
            self._layout.addRow(c_extras[0], QLineEdit(str(c_extras[1])))


class ProfileViewPane(ViewPane):

    def __init__(self,
                 location: Location,
                 on_actions=None,
                 resource_fetcher: ResourceFetcher = None,
                 *args,
                 **kargs):
        super(ProfileViewPane, self).__init__(location=location,
                                              *args, **kargs)

        self._location = location
        self._client = location.source.data_source
        self._profile_handler = location.source.profile_handler
        self._resource_fetcher = resource_fetcher

        self._context = location.context
        self._user = location.current_user
        context_split = self._context.split('/')
        self._context_leaf = context_split[len(context_split)-1]

        if context_split and (self._context_leaf == 'user' or self._context_leaf.startswith('npub')):
            asyncio.create_task(self._create_user())
        else:
            asyncio.create_task(self._create_view())

    async def _create_user(self):
        """
            create single page view when we're looking at an individual profile
        """
        for_key = self._user.public_key_hex()

        if self._context_leaf != 'user':
            for_key = Keys.get_key(self._context_leaf).public_key_hex()

        await self._client.wait_connect()
        my_profile: Profile = await self._profile_handler.aget_profile(for_key)


        scroll = VerticalScrollArea()
        self.layout().addWidget(scroll)


        scroll.addWidget(PictureArea(profile=my_profile,
                                     resource_fetcher=self._resource_fetcher))
        scroll.addWidget(ProfileFields(profile=my_profile))


    async def _create_view(self):
        """
            list view where new profile events comde in for example at source://profile
        """
        self._layout.addWidget(QLabel('scroller mother fucker!!!'))


    def stop(self):
        pass

    def pause(self):
        pass

    def restart(self):
        pass
monstr_qt/gui/panes/profile.py
monty888/newty
from PySide2.QtCore import Qt, QRect
from PySide2.QtGui import QImage, QBrush, QPainter, QPixmap, QWindow


def mask_image(image: QImage | QPixmap, to_size=None) -> QPixmap:
    """
        from https://www.geeksforgeeks.org/pyqt5-how-to-create-circular-image-from-any-image/
        mask image into a circle amd resize if to_size is given
    """

    if isinstance(image, QPixmap):
        image = image.toImage()

    # convert image to 32-bit ARGB (adds an alpha
    # channel ie transparency factor):
    image.convertToFormat(QImage.Format_ARGB32)

    # Crop image to a square:
    imgsize = min(image.width(), image.height())
    rect = QRect(
        (image.width() - imgsize) / 2,
        (image.height() - imgsize) / 2,
        imgsize,
        imgsize,
    )

    image = image.copy(rect)

    # Create the output image with the same dimensions
    # and an alpha channel and make it completely transparent:
    out_img = QImage(imgsize, imgsize, QImage.Format_ARGB32)
    out_img.fill(Qt.transparent)

    # Create a texture brush and paint a circle
    # with the original image onto the output image:
    brush = QBrush(image)

    # Paint the output image
    painter = QPainter(out_img)
    painter.setBrush(brush)

    # Don't draw an outline
    painter.setPen(Qt.NoPen)

    # drawing circle
    painter.drawEllipse(0, 0, imgsize, imgsize)

    # closing painter event
    painter.end()

    # Convert the image to a pixmap and rescale it.
    pr = QWindow().devicePixelRatio()
    pm = QPixmap.fromImage(out_img)
    pm.setDevicePixelRatio(pr)

    # also resizing
    if to_size is not None:
        to_size *= pr
        pm = pm.scaled(to_size, to_size, Qt.KeepAspectRatio,
                       Qt.SmoothTransformation)

    # return back the pixmap data
    return pm
monstr_qt/gui/util.py
monty888/newty
import asyncio
import aiohttp
import logging
from cachetools import TTLCache, LRUCache


class ResourceFetcher:
    """
        very basic http resource fetcher, with a LRU cache
    """
    def __init__(self):
        self._cache = LRUCache(10000)

        self._in_progress = {}

    async def get(self, url):
        if url in self._cache:
            ret = self._cache[url]
        else:
            if url not in self._in_progress:
                self._in_progress[url] = True
                self._cache[url] = ret = await self._do_fetch(url)

            else:

                timeout = 10.0
                wait_time = 0
                while url not in self._cache:
                    await asyncio.sleep(1)
                    wait_time += 1.0
                    if wait_time >= timeout:
                        raise Exception(f'timeout loading resource {url}')

                # if we didn't throw it should exist by now
                ret = self._cache[url]


        return ret

    async def _do_fetch(self, url):
        ret = None
        async with aiohttp.ClientSession(headers={
            'Accept': 'image/*'
        }) as session:

            retry_count = 5
            attempt = 1
            wait_time = 1

            while ret is None and attempt <= retry_count:
                try:
                    async with session.get(url) as response:
                        if response.status == 200:
                            ret = await response.read()
                            self._cache[url] = ret

                except Exception as e:
                    # note we just continue without relay specific info... maybe do some better fullback
                    logging.debug(f'ResourceFetcher::get failed: {e} for {url}')
                    retry_count += 1
                    await asyncio.sleep(wait_time)
                    wait_time *= 2

            self._in_progress[url] = False

        return ret

    def __contains__(self, url):
        return url in self._cache

    def __getitem__(self, url):
        return self._cache[url]


async def test_resource_fetch():
    my_resources = ResourceFetcher()
    print(await my_resources.get('https://oxtr.dev/assets/profilepic-animated-small.gif'))


if __name__ == '__main__':
    logging.getLogger().setLevel(logging.DEBUG)
    asyncio.run(test_resource_fetch())
monstr_qt/util.py
monty888/newty
import asyncio
from functools import partial
import logging
import sys

from monstr.event.event import Event
from monstr.client.client import Client
from monstr.ident.profile import Profile
from monstr.event.event_handlers import EventHandler
from monstr.ident.event_handlers import NetworkedProfileEventHandler
import qasync
from qasync import asyncSlot, asyncClose, QApplication
from monstr_qt.util import ResourceFetcher
from monstr_qt.gui.nostr import AddressBarPane, Location, LocationSource
from monstr_qt.gui.panes.panes import ViewPane
from monstr_qt.gui.panes.event import EventViewPane
from monstr_qt.gui.panes.profile import ProfileViewPane
from monstr.encrypt import Keys

import PySide2
# from PyQt5.QtWidgets import (
from PySide2.QtWidgets import (
    QWidget,
    QLabel,
    QLineEdit,
    QPushButton,
    QVBoxLayout,
    QHBoxLayout,
    QComboBox,
    QMainWindow,
    QDialog,
    QDialogButtonBox,
    QListView,
    QAbstractItemView,
    QTabWidget
)

import PySide2.QtCore as QtCore
from PySide2.QtCore import (
    QSortFilterProxyModel
)

from PySide2.QtWebEngineWidgets import QWebEngineView

from PySide2.QtGui import (
    QStandardItem,
    QStandardItemModel,
    Qt,
    QPixmap
)

DEFAULT_URL = 'ws://localhost:8081'


class SeenProfiles(EventHandler):

    def __init__(self):
        self._seen_keys = set()

    def do_event(self, the_client: Client, sub_id, evt: Event):
        if isinstance(evt, Event):
            evt = [evt]

        for c_evt in evt:
            self._seen_keys.add(c_evt.pub_key)

    def seen(self):
        ret = list(self._seen_keys)
        ret.sort()
        return ret


class TestWindow(QMainWindow):

    def __init__(self):
        super().__init__()
        self.setCentralWidget(MainWindow())
        self.setWindowTitle('Testing stuff...')

    @asyncClose
    async def closeEvent(self, event):
        pass
        # self._client.end()


class MainWindow(QWidget):
    """Main window."""

    def __init__(self):

        super().__init__()
        self._layout = QVBoxLayout()
        self.setLayout(self._layout)

        self._select_user = QPushButton('select author')
        self._select_user.clicked.connect(partial(self.on_btnFetch_clicked, self._select_user))
        self._layout.addWidget(self._select_user)

        self._web_test = QPushButton('web test')
        self._web_test.clicked.connect(partial(self.on_btnFetch_clicked, self._web_test))
        self._layout.addWidget(self._web_test)

        self._scroll_test = QPushButton('scroll test')
        self._scroll_test.clicked.connect(partial(self.on_btnFetch_clicked, self._scroll_test))
        self._layout.addWidget(self._scroll_test)

        self._image_test = QPushButton('image test')
        self._image_test.clicked.connect(partial(self.on_btnFetch_clicked, self._image_test))
        self._layout.addWidget(self._image_test)

        self._client = Client('wss://nos.lol')
        self._profile_handler = NetworkedProfileEventHandler(client=self._client)

        self._seen_profiles = SeenProfiles()
        self._client.subscribe(handlers=self._seen_profiles,
                               filters={
                                   'kinds': [Event.KIND_TEXT_NOTE]
                               })

        self._select_user_dialog = None

        asyncio.create_task(self._client.run())

    @asyncClose
    async def closeEvent(self, event):
        pass

    def update_status(self):
        self._status_lbl.setText(self._run_status)

    def _set_run_status(self, status: str):
        pass

    @asyncSlot()
    async def on_text_scroll(self):
        pass

    # @asyncSlot()
    def on_btnFetch_clicked(self, *args):
        if args[0] == self._select_user:
            self._select_user_dialog = SelectUser(track_seen=self._seen_profiles,
                                                  profile_handler=self._profile_handler)
            # await self.exec_QDialog_async(SelectUser(track_seen=self._seen_profiles))

            self._select_user_dialog.accepted.connect(self._authors_accepted)
            self._select_user_dialog.exec_()

        elif args[0] == self._web_test:
            # hmmm gui doesn't work - maybe sort later but don't really want to go this route
            # anyhow...
            WebTest().exec_()
        elif args[0] == self._scroll_test:
            # EventViewDialog().exec_()
            EventViewWindow().show()


        elif args[0] == self._image_test:
            print('mother fucker')
            ImageView().exec_()


    @asyncSlot()
    async def _authors_accepted(self):
        print(self._select_user_dialog.selected_keys)
        self._select_user_dialog = None

    # async def exec_QDialog_async(self, the_dialog):
    #     # TODO: this isn't making modal like we expected....
    #     future = asyncio.Future()
    #     the_dialog.finished.connect(lambda r: future.set_result(r))
    #     the_dialog.open()
    #     the_dialog.show()
    #     return await future


class AuthorCombo(QComboBox):

    def __init__(self, *args, **kargs):
        super().__init__(*args, **kargs)

    def paintEvent(self, event):
        super(AuthorCombo, self).paintEvent(event)


# class SimpleDataItem(QStandardItem):
#
#     def __init__(self, *args, link_data, **kargs):
#         self._link_data = link_data
#         super(SimpleDataItem, self).__init__(*args, **kargs)
#

class MyUserProxyFilterModel(QSortFilterProxyModel):

    def __init__(self, *args, **kargs):
        super(MyUserProxyFilterModel, self).__init__(*args, **kargs)
        self._filter_str = ''

    def filterAcceptsRow(self, source_row:int, source_parent:PySide2.QtCore.QModelIndex) -> bool:
        idx = self.sourceModel().index(source_row, 0)
        search_str = self.sourceModel().data(idx)
        p: Profile
        item_data = self.sourceModel().itemData(idx)

        # role - todo look into this shit and what is role 257?!
        if 257 in item_data:
            item_data = item_data[257]
            p = item_data['profile']
            if not 'search_str' in item_data:
                search_str = f'{p.public_key}:{p.display_name()}'
                about = p.get_attr('about')
                if about:
                    search_str += f':{about}'

                search_str = item_data['search_str'] = search_str.casefold()

                self.sourceModel().setData(idx, item_data, 257)


            else:
                search_str = item_data['search_str']

        return self.filter_str == '' or self.filter_str in search_str

    @property
    def filter_str(self):
        return self._filter_str

    @filter_str.setter
    def filter_str(self, filter_str):
        self._filter_str = filter_str.lower()


class WebTest(QDialog):

    def __init__(self,
                 *args, **kargs):

        super(WebTest, self).__init__(*args, **kargs)

        self.setWindowTitle('fucks sake')
        self._layout = QVBoxLayout()
        self.setLayout(self._layout)

        self._url_text = QLineEdit(placeholderText='url')
        self._layout.addWidget(self._url_text)
        webview = QWebEngineView()
        webview.setUrl('www.google.co.uk')
        webview.show()
        self._layout.addWidget(webview)


class EventViewWindow(QWidget):

    def __init__(self,
                 *args, **kargs):

        super(EventViewWindow, self).__init__(*args, **kargs)

        self.setWindowTitle('view events')
        self._layout = QVBoxLayout()
        self.setLayout(self._layout)

        self._client = Client('wss://nos.lol')
        self._profile_handler = NetworkedProfileEventHandler(client=self._client)

        self._resource_loader = ResourceFetcher()

        self._current_source = LocationSource(name='local',
                                              data_source=self._client,
                                              profile_handler=self._profile_handler)
        # self._current_user = Keys(pub_k='5c4bf3e548683d61fb72be5f48c2dff0cf51901b9dd98ee8db178efe522e325f')


        # hacky load so we have a user
        from monstr.ident.alias import ProfileFileAlias
        my_profileLoader = ProfileFileAlias('/home/monty/.nostrpy/profiles.csv')
        self._current_user = my_profileLoader.get_profile('monty').keys
        asyncio.create_task(self._client.run())
        #####

        # tab container
        self._tabs = QTabWidget()
        self._tab_info = []

        # basic event view
        # self._create_new_tab('post')

        # testing ....
        self._create_new_tab('user/dm')

        self._create_new_tab('user/post')
        self._create_new_tab('user/reply')

        # another type of tab, we'll need to merga how this works with standard event tabs
        # done at end because currently it'll break other tabs as is...

        self._create_new_tab('user/follows/post')
        self._create_new_tab('user/follows/reply')
        self._create_new_tab('user/profile')


        self._address_bar = AddressBarPane(on_action=self._address_change)

        self._layout.addWidget(self._address_bar)

        self._layout.addWidget(self._tabs)

        self._tabs.currentChanged.connect(self._tabChanged)

        self.set_tab_state(0)

    def set_tab_state(self, tab_idx):
        tab_info: dict = self._tab_info[tab_idx]
        self._address_bar.set_state(locations=tab_info['locations'],
                                    index=tab_info['location_index'])

    def _address_change(self, data):
        tab_idx = self._tabs.currentIndex()
        tab_info: dict = self._tab_info[tab_idx]
        the_pane: ViewPane = self._get_current_view_pane(tab_idx)

        # stop current pane
        the_pane.stop()
        c_location: Location = tab_info['locations'][tab_info['location_index']]
        c_location.data = None

        n_location = data['location']
        change_type = data['type']


        tab_con: QWidget = tab_info['tab_con']

        item = tab_con.layout().takeAt(0)
        item.widget().setParent(None)

        # now start new view
        n_pane: ViewPane = n_location.data

        if n_pane is None:
            n_pane = self._create_pane_from_location(n_location)
            n_location.data = n_pane

        else:
            # at the moment as will stop and kill panes as we move away we should never get here
            # this would exist if we paused and were going to restart
            pass

        tab_con.layout().addWidget(n_pane)
        self._update_tab_state()

    def _get_current_location(self, tab_idx: int = None) -> Location:
        if tab_idx is None:
            tab_idx = self._tabs.currentIndex()

        tab_info: dict = self._tab_info[tab_idx]

        c_loc: Location

        print(tab_info['location_index'])
        for c_loc in tab_info['locations']:
            print(c_loc.full_address, c_loc.data)

        return tab_info['locations'][tab_info['location_index']]

    def _get_current_view_pane(self, tab_idx: int = None) -> ViewPane:
        return self._get_current_location(tab_idx).data

    def _do_actions(self, data):
        type = data['type']
        if type == 'hashtag_clicked':
            tab_idx = self._tabs.currentIndex()
            tab_info: dict = self._tab_info[tab_idx]
            the_pane: ViewPane = self._get_current_view_pane(tab_idx)
            # stop current pane
            the_pane.stop()

            c_location: Location = tab_info['locations'][tab_info['location_index']]
            c_location.data = None

            tab_con: QWidget = tab_info['tab_con']
            tab_con.layout().takeAt(0)

            context = c_location.context

            location = self._get_new_location(f'{context}/post?t={data["name"]}')
            n_pane = self._create_pane_from_location(location)
            location.data = n_pane

            tab_con.layout().addWidget(n_pane)

            # hash_view = HashView(data["name"])
            #
            # # TODO: this should just take view obj surely?
            # event_pane.set_view(new_filter=hash_view.view_filter,
            #                     event_acceptor=hash_view.view_acceptor)
            #
            self._address_bar.append(location)
            self._update_tab_state()

    def _update_tab_state(self):
        tab_idx = self._tabs.currentIndex()
        tab_info: dict = self._tab_info[tab_idx]
        locations, idx = self._address_bar.get_state()
        tab_info['location_index'] = idx
        tab_info['locations'] = locations

        print('we now have n locations ----', len(locations))

        self._tabs.setTabText(tab_idx, self._get_tab_name_for_location(locations[idx]))

    @staticmethod
    def _get_tab_name_for_location(location: Location):
        ret = location.resource_path
        hash = location.get_param('t')
        if hash:
            ret += f'#{"-".join(hash)}'
        return ret

    def _create_pane_from_location(self, location: Location):
        """
         think about these mappings e.g.
         local://          - just src, default to posts ?
         local://{pubkey}  - default to profile page

         should that be handled here or should the location automaticlly add a default cmd?
         (the location should probably needs improving so that if the last word isn't a commnad then its context?)
        """

        create_map = {
            'profile': self._create_profile_pane,
            'note': self._create_event_pane,
            'post': self._create_event_pane,
            'reply': self._create_event_pane,
            'dm': self._create_event_pane
        }

        return create_map[location.cmd](location)

    def _create_profile_pane(self, location: Location) -> ProfileViewPane:
        return ProfileViewPane(location=location,
                               on_actions=None,
                               resource_fetcher=self._resource_loader)

    def _create_event_pane(self, location: Location) -> EventViewPane:
        return EventViewPane(location=location,
                             on_actions=self._do_actions,
                             resource_fetcher=self._resource_loader)

    def _get_new_location(self, identifier:str,
                          data_source: LocationSource = None):
        if data_source is None:
            data_source = self._current_source

        return Location(source=data_source,
                        identifier=identifier,
                        current_user=self._current_user)

    def _create_new_tab(self, identifier: str,
                        data_source: LocationSource = None):

        location = self._get_new_location(identifier=identifier,
                                          data_source=data_source)

        n_pane = self._create_pane_from_location(location)
        location.data = n_pane

        # instead of adding n_pane directly to tab we create tab_con
        # as user moves adround in this tab panes are removed(stoped)
        # restarted or created into here
        tab_con = QWidget()
        tab_con_layout = QVBoxLayout()
        tab_con_layout.setContentsMargins(0,0,0,0)
        tab_con.setLayout(tab_con_layout)
        tab_con_layout.addWidget(n_pane)

        self._tabs.addTab(tab_con, location.resource_path)

        self._tab_info.append({
            'tab_con': tab_con,
            'location_index': 0,
            'locations': [location]
        })


    @asyncSlot()
    async def _tabChanged(self):
        self.set_tab_state(self._tabs.currentIndex())

    def closeEvent(self, event):
        self._client.end()
        for c_tab_info in self._tab_info:
            active_view: ViewPane = c_tab_info['locations'][c_tab_info['location_index']].data
            if active_view:
                active_view.stop()


class ImageView(QDialog):

    def __init__(self,
                 *args, **kargs):

        super(ImageView, self).__init__(*args, **kargs)
        self.setWindowTitle('load images')

        self._layout = QVBoxLayout()
        self.setLayout(self._layout)

        self._url_text = QLineEdit(placeholderText='url')
        self._layout.addWidget(self._url_text)

        do_btn = QPushButton('do it!!!')
        self._layout.addWidget(do_btn)
        do_btn.clicked.connect(self._do_load)

        self._image = QPixmap()
        self._image_lbl = QLabel()
        self._image_lbl.setFixedWidth(64)
        self._image_lbl.setFixedHeight(64)

        self._layout.addWidget(self._image_lbl)

        self._loader = ResourceFetcher()

    @asyncSlot()
    async def _do_load(self):
        data = await self._loader.get(self._url_text.text())
        self._image.loadFromData(data)
        self._image_lbl.setPixmap(self._image.scaled(64,64))


class SelectUser(QDialog):

    def __init__(self,
                 track_seen: SeenProfiles,
                 title='select profiles',
                 profile_handler=None,
                 *args, **kargs):

        super(SelectUser, self).__init__(*args, **kargs)

        self.setWindowTitle(title)

        # we couldn't show anything if this doesnt exists so we'll just let this err in that case
        self._seen = track_seen
        self._profile_handler: NetworkedProfileEventHandler = profile_handler

        self._layout = QVBoxLayout()
        self.setLayout(self._layout)

        self._select_text = QLineEdit(placeholderText='npub, hex or name')
        self._select_text.textChanged.connect(self._search_changed)
        self._layout.addWidget(self._select_text)

        # mid pane that'll contain sector area and selected list
        self._mid_pane = QWidget()
        self._mid_pane_layout = QHBoxLayout()
        self._mid_pane.setLayout(self._mid_pane_layout)
        self._layout.addWidget(self._mid_pane)



        # make select list
        self._list_view = QListView()

        all_items = self._seen.seen()
        # all_items = ['93cccf9fea855caa331d6263266928276c4c3ee1f9b05764f7c1f72ebf0bf643']
        self._list_model = self._init_list_model(all_items)

        self._sort_proxy = MyUserProxyFilterModel()
        self._sort_proxy.setSourceModel(self._list_model)
        self._sort_proxy.setSortCaseSensitivity(Qt.CaseInsensitive)
        self._list_view.setModel(self._sort_proxy)
        self._list_view.setSelectionMode(QAbstractItemView.MultiSelection)


        self._mid_pane_layout.addWidget(self._list_view)
        self._list_view.selectionModel().selectionChanged.connect(self._list_selection_changed)


        # create the area between select and selected lists
        # self._mid_mid_pane = QWidget()
        # self._mid_mid_layout = QVBoxLayout()
        # self._mid_mid_pane.setLayout(self._mid_mid_layout)
        # self._mid_mid_layout.addWidget(QPushButton('>'))
        # self._mid_mid_layout.addWidget(QPushButton('<'))
        # self._mid_pane_layout.addWidget(self._mid_mid_pane)

        self._mid_mid_pane = QDialogButtonBox(orientation=Qt.Vertical)
        self._mid_mid_layout = self._mid_mid_pane.layout()
        self._add_sel_but = QPushButton('>', enabled=False)
        self._add_sel_but.clicked.connect(self._move_selection_selected)


        self._mid_mid_layout.addWidget(self._add_sel_but)
        self._mid_mid_layout.addWidget(QPushButton('<'))
        self._mid_mid_layout.addWidget(QPushButton('clear &all'))
        self._mid_mid_layout.addWidget(QPushButton('&reset'))


        # self._mid_mid_layout = QVBoxLayout()
        # self._mid_mid_pane.setLayout(self._mid_mid_layout)
        # self._mid_mid_layout.addWidget(QPushButton('>'))
        # self._mid_mid_layout.addWidget(QPushButton('<'))
        self._mid_pane_layout.addWidget(self._mid_mid_pane)

        self._selected_list = QListView(model=QStandardItemModel(0, 0))
        self._mid_pane_layout.addWidget(self._selected_list)

        # add the ok/cancle buttons, connect ok/cancle actions
        self._action_buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        self._layout.addWidget(self._action_buttons)
        self._action_buttons.accepted.connect(self.accept)
        self._action_buttons.rejected.connect(self.reject)

        async def init_profiles():
            await self._profile_handler.aget_profiles(pub_ks=all_items,
                                                      create_missing=True)
            n_rows = len(all_items)
            p: Profile

            # self._sort_proxy.setSourceModel(None)
            for r in range(n_rows):
                pub_k = all_items[r]
                p = self._profile_handler.get_profile(pub_k)
                if p:
                    n_item = QStandardItem(p.display_name())
                    n_item.setData({
                        'profile': p
                    })

                    self._list_model.setItem(r, 0, n_item)

            self._sort_proxy.sort(0)
            # self._list_model.sort(0)

        if self._profile_handler:
            asyncio.create_task(init_profiles())


    @asyncSlot()
    async def _search_changed(self):
        search_text = self._select_text.text()
        self._sort_proxy.filter_str = search_text
        # force filter to happen
        self._sort_proxy.setFilterKeyColumn(0)

    @asyncSlot()
    async def _list_selection_changed(self):
        the_selection = self._list_view.selectionModel().selection()
        self._add_sel_but.setEnabled(the_selection.count())


    @asyncSlot()
    async def _move_selection_selected(self):
        dest_r = self._selected_list.model().rowCount()

        the_selection = self._list_view.selectionModel().selectedIndexes()
        to_delete = []

        # this is shit, we must be doing something wrong or not understanding
        # somehting about how selection works.... when select from ctrl-a
        # we seem to get row * row selection when we should only have row * col(0)
        # all works fine selecting by mouse WTF?!
        did_row = set()

        if the_selection:
            for idx in the_selection:
                sel_row = idx.row()
                if sel_row not in did_row:
                    item = self._list_model.item(idx.row(),0)
                    src_r = self._sort_proxy.mapToSource(idx).row()
                    item = self._list_model.takeItem(src_r,0)

                    self._selected_list.model().setItem(dest_r, 0, item)
                    dest_r += 1
                    to_delete.append(src_r)
                    did_row.add(sel_row)

            # now delete the empty rows, probably better way of doing this....
            to_delete.sort(reverse=True)
            for r in to_delete:
                self._list_model.removeRow(r)

    # this can probably abstacted out to model with data or something similar...
    def _init_list_model(self, pub_keys: [str]) -> QStandardItemModel:
        n_rows = len(pub_keys*5)
        ret = QStandardItemModel(1, n_rows)

        for r in range(len(pub_keys)):
            pub_k = pub_keys[r]
            # create a new item and add it to the model
            n_item = QStandardItem(pub_k)

            ret.setItem(r, 0, n_item)

        return ret

    @asyncClose
    async def closeEvent(self, event):
        self.finished.emit(0)

    @property
    def selected_keys(self):
        sel_model = self._selected_list.model()
        return [
            sel_model.item(idx).data()['profile'].public_key
            for idx in range(sel_model.rowCount())
        ]


async def main():
    def close_future(future, loop):
        loop.call_later(10, future.cancel)
        future.cancel()

    loop = asyncio.get_event_loop()
    future = asyncio.Future()

    app = QApplication.instance()

    # from PySide2.QtCore import QUrl
    # view = QWebEngineView()
    # view.load(QUrl("https://qt-project.org/"))
    # view.resize(1024, 750)
    # view.show()


    if hasattr(app, "aboutToQuit"):
        getattr(app, "aboutToQuit").connect(
            partial(close_future, future, loop)
        )

    mainWindow = TestWindow()
    mainWindow.show()

    await future
    return True


if __name__ == "__main__":
    logging.getLogger().setLevel(logging.DEBUG)
    try:
        qasync.run(main())
    except asyncio.exceptions.CancelledError:
        sys.exit(0)
my_tester.py
monty888/newty
import asyncio
import functools
import sys

import aiohttp
from monstr.event.event import Event
from monstr.client.client import Client

# from PyQt5.QtWidgets import (
from PySide2.QtWidgets import (
    QWidget,
    QLabel,
    QLineEdit,
    QTextEdit,
    QPushButton,
    QVBoxLayout,
)

import qasync
from qasync import asyncSlot, asyncClose, QApplication


class MainWindow(QWidget):
    """Main window."""

    _DEF_URL = "ws://localhost:8081"
    """str: Default URL."""

    _SESSION_TIMEOUT = 1.0
    """float: Session timeout."""

    def __init__(self):
        super().__init__()

        self.setLayout(QVBoxLayout())

        self.lblStatus = QLabel("Idle", self)
        self.layout().addWidget(self.lblStatus)

        self.editUrl = QLineEdit(self._DEF_URL, self)
        self.layout().addWidget(self.editUrl)

        self.editResponse = QTextEdit("", self)
        self.layout().addWidget(self.editResponse)

        self.btnFetch = QPushButton("Fetch", self)
        self.btnFetch.clicked.connect(self.on_btnFetch_clicked)
        self.layout().addWidget(self.btnFetch)

        self.session = aiohttp.ClientSession(
            loop=asyncio.get_event_loop(),
            timeout=aiohttp.ClientTimeout(total=self._SESSION_TIMEOUT),
        )

    @asyncClose
    async def closeEvent(self, event):
        await self.session.close()

    @asyncSlot()
    async def on_btnFetch_clicked(self):
        self.btnFetch.setEnabled(False)
        self.lblStatus.setText("Fetching...")

        try:
            # self.editResponse.setHtml('<b>test</b>test<div style="color:red">very nice</div>')
            async with Client(self.editUrl.text()) as c:
                evts = await c.query(filters={})
                txt_arr = []
                c_evt: Event
                for c_evt in evts:
                    txt_arr.append(str(c_evt.event_data()))
                self.editResponse.setText('\n'.join(txt_arr))
            # async with self.session.get(self.editUrl.text()) as r:
            #     self.editResponse.setText(await r.text())
        except Exception as exc:
            self.lblStatus.setText("Error: {}".format(exc))
        else:
            self.lblStatus.setText("Finished!")
        finally:
            self.btnFetch.setEnabled(True)


async def main():
    def close_future(future, loop):
        loop.call_later(10, future.cancel)
        future.cancel()

    loop = asyncio.get_event_loop()
    future = asyncio.Future()

    app = QApplication.instance()
    if hasattr(app, "aboutToQuit"):
        getattr(app, "aboutToQuit").connect(
            functools.partial(close_future, future, loop)
        )

    mainWindow = MainWindow()
    mainWindow.show()

    await future
    return True


if __name__ == "__main__":
    try:
        qasync.run(main())
    except asyncio.exceptions.CancelledError:
        sys.exit(0)
pyside_basic1.py
monty888/newty