openTARS / app /tars_qt /conversa.py
ProjectOpenTARS4's picture
Mirror of openTARS 5.2 Ranger (revision 2026110101)
2695555 verified
Raw History Blame Contribute Delete
28.7 kB
"""As peças da conversa: balão da pessoa, resposta da IA (texto, ferramentas, raciocínio), cartão de
etapas e cartão de aprovação. Tudo igual ao mockup (Main). A lógica fica fora: aqui só se desenha."""
import re
from pathlib import Path
from urllib.parse import unquote
from PySide6.QtCore import QEvent, QSize, Qt, QTimer, Signal
from PySide6.QtGui import QFont, QFontMetrics, QGuiApplication, QImage, QImageReader, QPixmap
from PySide6.QtWidgets import (QFrame, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy,
QToolButton, QVBoxLayout, QWidget)
import tars_i18n
from tars_i18n import t
from . import icones, tema
from .tema import CORES, Marcador, aplicar_papel, rotulo
LARGURA_COLUNA = 780
LARGURA_BOLHA = 520
def _botao(texto, papel, ao_clicar=None):
b = QPushButton(texto)
b.setProperty("papel", papel)
b.setCursor(Qt.PointingHandCursor)
if ao_clicar:
b.clicked.connect(ao_clicar)
return b
def _rotulo_selecionavel(texto="", papel="mensagem-ia", markdown=False):
r = QLabel()
r.setProperty("papel", papel)
r.setWordWrap(True)
r.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse)
r.setOpenExternalLinks(False) # texto da IA (ou de uma página) não abre file:// nem esquema estranho
r.linkActivated.connect(abrir_link_seguro)
r.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
r.setTextFormat(Qt.MarkdownText if markdown else Qt.PlainText)
r.setText(texto)
return r
def abrir_link_seguro(endereco):
"""Só abre http(s) e mailto; qualquer outro esquema (file:, smb:, javascript:, um protocolo do sistema) é ignorado."""
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
url = QUrl(str(endereco))
if url.scheme().lower() in ("http", "https", "mailto"):
QDesktopServices.openUrl(url)
# ------------------------------------------------------------
# A pessoa
# ------------------------------------------------------------
class Bolha(QFrame):
def __init__(self, texto):
super().__init__()
self.setProperty("papel", "bolha")
lay = QVBoxLayout(self)
lay.setContentsMargins(16, 12, 16, 12)
self.rotulo = _rotulo_selecionavel(texto, "mensagem-ia")
lay.addWidget(self.rotulo)
self.setMaximumWidth(LARGURA_BOLHA)
self._ajustar()
def _ajustar(self):
"""O QLabel com quebra de linha dentro de um balão alinhado à direita não pede altura para a largura
que realmente terá: a 2ª linha ficava cortada. Mede o texto e fixa o tamanho."""
r = self.rotulo
r.ensurePolished()
maximo = LARGURA_BOLHA - 32
caixa = r.fontMetrics().boundingRect(0, 0, maximo, 100000, int(Qt.TextWordWrap | Qt.AlignLeft), r.text())
r.setFixedSize(min(maximo, caixa.width() + 6), caixa.height() + 4)
def showEvent(self, e):
super().showEvent(e)
self._ajustar()
def texto(self):
return self.rotulo.text()
# ------------------------------------------------------------
# A IA
# ------------------------------------------------------------
# A cerca começa a linha ("```ls```" no meio da frase não é bloco), igual ao Tk.
_RE_BLOCO_CODIGO = re.compile(r"^```([\w+#.-]*)[^\n]*\n(.*?)(?:```|\Z)", re.DOTALL | re.MULTILINE)
LINHAS_MAXIMAS_CODIGO = 24
def partir_em_blocos(texto):
"""[("texto", "..."), ("codigo", "...", "linguagem"), ...] na ordem em que aparecem; bloco de código vazio some."""
saida, fim = [], 0
for m in _RE_BLOCO_CODIGO.finditer(texto):
antes = texto[fim:m.start()]
if antes.strip():
saida.append(("texto", antes.strip("\n")))
codigo = m.group(2).rstrip("\n")
if codigo.strip():
saida.append(("codigo", codigo, m.group(1).lower()))
fim = m.end()
resto = texto[fim:]
if resto.strip():
saida.append(("texto", resto.strip("\n")))
juntos = []
for p in saida: # um bloco vazio entre dois textos os deixa vizinhos: vira um texto só
if p[0] == "texto" and juntos and juntos[-1][0] == "texto":
juntos[-1] = ("texto", juntos[-1][1] + "\n\n" + p[1])
else:
juntos.append(p)
return _com_imagens(juntos)
# Imagem que o modelo "manda": `![texto](/caminho/foto.png)`. Só arquivo local, já conferido: nada é baixado de
# endereço da internet (uma imagem remota seria um pixel de rastreio) e o que não passa vira link comum.
_RE_IMAGEM = re.compile(r"!\[([^\]\n]*)\]\(\s*(<[^>\n]+>|[^)\n]+?)(?:\s+\"[^\"\n]*\")?\s*\)")
EXTENSOES_IMAGEM = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
BYTES_MAXIMOS_IMAGEM = 25 * 1024 * 1024
PIXELS_MAXIMOS_IMAGEM = 100_000_000
TAMANHO_MAXIMO_IMAGEM = QSize(640, 480)
def caminho_de_imagem(destino):
"""O arquivo de imagem local a que `destino` aponta, ou None (endereço da internet, data:, caminho relativo,
arquivo que não existe, grande demais ou de outro tipo)."""
d = destino.strip()
if d.startswith("<") and d.endswith(">"):
d = d[1:-1]
if d.lower().startswith("file://"):
d = d[7:]
elif re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", d) and not re.match(r"^[a-zA-Z]:[\\/]", d):
return None # http:, data:, ftp:... (C:\ é caminho, não esquema)
try:
p = Path(unquote(d)).expanduser()
if not p.is_absolute():
return None
p = p.resolve()
if p.suffix.lower() in EXTENSOES_IMAGEM and p.is_file() and p.stat().st_size <= BYTES_MAXIMOS_IMAGEM:
return p
except (OSError, ValueError):
pass
return None
def _neutralizar_imagens(texto):
"""Imagem que não é arquivo local válido vira link comum: o Markdown do Qt não tenta carregá-la."""
def troca(m):
if caminho_de_imagem(m.group(2)):
return m.group(0)
return f"[{m.group(1) or 'imagem'}]({m.group(2)})"
return _RE_IMAGEM.sub(troca, texto)
def _com_imagens(pedacos):
"""Parte cada trecho de texto nas imagens locais que ele cita: ("imagem", Path, texto alternativo)."""
saida = []
for p in pedacos:
if p[0] != "texto":
saida.append(p)
continue
texto, fim, achou = _neutralizar_imagens(p[1]), 0, False
for m in _RE_IMAGEM.finditer(texto):
achou = True
antes = texto[fim:m.start()]
if antes.strip():
saida.append(("texto", antes.strip("\n")))
saida.append(("imagem", caminho_de_imagem(m.group(2)), m.group(1)))
fim = m.end()
if not achou:
saida.append(("texto", texto))
elif texto[fim:].strip():
saida.append(("texto", texto[fim:].strip("\n")))
return saida
class ImagemIA(QLabel):
"""Uma imagem que o modelo mandou, reduzida para caber na coluna (a leitura já nasce no tamanho final)."""
def __init__(self, caminho, alt=""):
super().__init__()
self.caminho = Path(caminho)
leitor = QImageReader(str(self.caminho))
leitor.setAutoTransform(True)
tam = leitor.size()
img = QImage()
if tam.isValid() and 0 < tam.width() * tam.height() <= PIXELS_MAXIMOS_IMAGEM:
if tam.width() > TAMANHO_MAXIMO_IMAGEM.width() or tam.height() > TAMANHO_MAXIMO_IMAGEM.height():
leitor.setScaledSize(tam.scaled(TAMANHO_MAXIMO_IMAGEM, Qt.KeepAspectRatio))
img = leitor.read()
nome = tema.seguro(alt or self.caminho.name)
self.setAccessibleName(nome)
self.setToolTip(self.caminho.name)
if img.isNull(): # não abriu: mostra o nome no lugar
self.setProperty("papel", "aviso-linha")
self.setText(nome)
else:
self.setPixmap(QPixmap.fromImage(img))
self.setFixedSize(img.size())
class _CodigoSemRoda(QPlainTextEdit):
"""A roda do mouse rola a conversa, não o bloco (que não tem rolagem vertical própria até 24 linhas)."""
def wheelEvent(self, e):
if self.verticalScrollBar().maximum() > 0:
super().wheelEvent(e)
else:
e.ignore()
class CaixaCodigo(QFrame):
"""Janelinha de código dentro da resposta, como no Tk: nome da linguagem, botão Copiar e o código em fonte fixa."""
def __init__(self, codigo, linguagem=""):
super().__init__()
self.setProperty("papel", "bloco-codigo")
self.codigo = codigo
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
topo = QFrame()
topo.setProperty("papel", "bloco-codigo-topo")
linha = QHBoxLayout(topo)
linha.setContentsMargins(10, 4, 8, 4)
self.titulo = QLabel(linguagem or t("gui.codigo"))
self.titulo.setProperty("papel", "bloco-codigo-lingua")
linha.addWidget(self.titulo)
linha.addStretch(1)
self.botao = _botao(t("gui.copiar"), "copiar-codigo", self.copiar)
linha.addWidget(self.botao)
lay.addWidget(topo)
self.texto = _CodigoSemRoda()
self.texto.setProperty("papel", "bloco-codigo-texto")
self.texto.setReadOnly(True)
self.texto.setLineWrapMode(QPlainTextEdit.NoWrap)
self.texto.setFrameShape(QFrame.NoFrame)
self.texto.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.texto.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
fonte = QFont(tema.FONTE_MONO)
fonte.setPixelSize(13)
self.texto.setFont(fonte)
self.texto.setPlainText(codigo)
self.texto.setTabStopDistance(QFontMetrics(fonte).horizontalAdvance(" ") * 4)
linhas = max(1, min(codigo.count("\n") + 1, LINHAS_MAXIMAS_CODIGO))
self._altura = QFontMetrics(fonte).lineSpacing() * linhas + 18
self.texto.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.texto.setFixedHeight(self._altura)
lay.addWidget(self.texto)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
self.texto.horizontalScrollBar().rangeChanged.connect(lambda *_: self._ajustar_altura())
def _ajustar_altura(self):
"""Linha mais larga que a caixa: a barra horizontal aparece e ganha o seu espaço (sem ela, nada sobra embaixo)."""
barra = self.texto.horizontalScrollBar()
extra = 14 if barra.maximum() > 0 else 0
if self.texto.height() != self._altura + extra:
self.texto.setFixedHeight(self._altura + extra)
def copiar(self):
QGuiApplication.clipboard().setText(self.codigo)
self.botao.setText(tema.seguro(t("gui.copiado")) + " ✓")
QTimer.singleShot(1600, lambda: self.botao.setText(t("gui.copiar")))
class BlocoTexto(QWidget):
"""A resposta, em Markdown, que cresce enquanto chega (redesenha no máximo 20 vezes por segundo)."""
def __init__(self):
super().__init__()
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
self._lay = lay
self.rotulo = _rotulo_selecionavel("", "mensagem-ia", markdown=True)
self._pecas = []
# Enquanto a resposta chega ela é texto simples: Markdown reinterpretado 20x por segundo sobre um texto
# pela metade (cerca de código aberta, tabela cortada) é o ponto mais frágil do Qt e relaiouta tudo a cada
# tique. O Markdown de verdade entra uma vez só, em concluir().
self.rotulo.setTextFormat(Qt.PlainText)
lay.addWidget(self.rotulo)
self._texto = ""
self._sujo = False
self._timer = QTimer(self)
self._timer.setInterval(50)
self._timer.timeout.connect(self._atualizar)
def acrescentar(self, trecho):
self._texto += trecho
self._sujo = True
if not self._timer.isActive():
self._timer.start()
def _atualizar(self):
if self._sujo:
self.rotulo.setText(self._texto)
self._sujo = False
else:
self._timer.stop()
def concluir(self):
self._timer.stop()
self._sujo = False
pedacos = partir_em_blocos(self._texto)
if not any(p[0] in ("codigo", "imagem") for p in pedacos):
self.rotulo.setTextFormat(Qt.MarkdownText)
self.rotulo.setText(_neutralizar_imagens(self._texto))
return
# Tem código ou imagem: o texto vira uma peça por trecho, cada cerca uma janelinha com Copiar e cada
# imagem local uma figura.
self._lay.removeWidget(self.rotulo)
self.rotulo.hide()
self._lay.setSpacing(10)
for p in pedacos:
if p[0] == "texto":
peca = _rotulo_selecionavel(p[1], "mensagem-ia", markdown=True)
elif p[0] == "imagem":
peca = ImagemIA(p[1], p[2])
else:
peca = CaixaCodigo(p[1], p[2])
self._lay.addWidget(peca, 0, Qt.AlignLeft) if p[0] == "imagem" else self._lay.addWidget(peca)
self._pecas.append(peca)
def texto(self):
return self._texto
class BlocoRaciocinio(QWidget):
"""O raciocínio do modelo, recolhido por padrão ("Mostrar o raciocínio")."""
def __init__(self):
super().__init__()
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(4)
self.botao = QToolButton()
self.botao.setProperty("papel", "mais")
self.botao.setCursor(Qt.PointingHandCursor)
self.botao.setCheckable(True)
self.botao.toggled.connect(self._alternar)
self.corpo = _rotulo_selecionavel("", "pensamento")
self.corpo.hide()
lay.addWidget(self.botao, 0, Qt.AlignLeft)
lay.addWidget(self.corpo)
self._texto = ""
self._atualizar_rotulo()
def acrescentar(self, trecho):
self._texto += trecho
self.corpo.setText(self._texto)
def texto(self):
return self._texto
def _alternar(self, aberto):
self.corpo.setVisible(aberto)
self._atualizar_rotulo()
def _atualizar_rotulo(self):
self.botao.setText(("↓ " if self.botao.isChecked() else "› ") + t("jp.raciocinio") + "…")
class LinhaFerramenta(QWidget):
""""› abrindo app calculadora ✓ 0,12 s", em letra de máquina."""
def __init__(self, nome, resumo=""):
super().__init__()
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(8)
self.nome = QLabel("› " + tema.seguro(nome))
self.nome.setProperty("papel", "ferramenta")
self.resumo = QLabel(tema.seguro(resumo))
self.resumo.setProperty("papel", "ferramenta")
self.resumo.setStyleSheet(f"color: {CORES['mudo']};")
self.fim = QLabel("")
self.fim.setProperty("papel", "ferramenta-ok")
lay.addWidget(self.nome)
lay.addWidget(self.resumo, 1)
lay.addWidget(self.fim)
self.sucesso = None
def concluir(self, sucesso, segundos):
self.sucesso = bool(sucesso)
if sucesso:
self.fim.setText(f"✓ {segundos:.2f}s".replace(".", ",") if tars_i18n.idioma_atual().startswith("pt") else f"✓ {segundos:.2f}s")
else:
self.fim.setText("× " + t("msg.falhou"))
self.fim.setStyleSheet("color: #FF9B9B;")
class CartaoEtapas(QFrame):
""""Etapas 2 de 4" com uma bolinha por etapa; a que espera a pessoa ganha a etiqueta "espera por você"."""
def __init__(self, etapas):
super().__init__()
self.setProperty("papel", "cartao-etapas")
self._linhas = []
raiz = QVBoxLayout(self)
raiz.setContentsMargins(0, 6, 0, 6)
raiz.setSpacing(0)
cab = QHBoxLayout()
cab.setContentsMargins(16, 10, 16, 8)
self.titulo = rotulo(t("jp.etapas"), "kicker")
self.contagem = QLabel("")
self.contagem.setProperty("papel", "mono")
cab.addWidget(self.titulo)
cab.addStretch(1)
cab.addWidget(self.contagem)
raiz.addLayout(cab)
for texto in etapas:
linha = QFrame()
linha.setProperty("papel", "linha-etapa")
h = QHBoxLayout(linha)
h.setContentsMargins(16, 8, 16, 8)
h.setSpacing(12)
marcador = Marcador("pendente", 22)
r = QLabel(texto)
r.setWordWrap(True)
r.setProperty("papel", "passo")
espera = QLabel(t("jp.espera_voce"))
espera.setProperty("papel", "espera")
espera.hide()
h.addWidget(marcador, 0, Qt.AlignTop)
h.addWidget(r, 1)
h.addWidget(espera, 0, Qt.AlignTop)
raiz.addWidget(linha)
self._linhas.append((linha, marcador, r, espera))
self.feitas = 0
self.esperando = False
self._pintar()
def total(self):
return len(self._linhas)
def definir_feitas(self, n):
self.feitas = max(0, min(int(n), self.total()))
self._pintar()
def concluir_tudo(self):
self.esperando = False
self.definir_feitas(self.total())
def esperar_pessoa(self, sim):
self.esperando = bool(sim)
self._pintar()
def _pintar(self):
for i, (linha, marcador, texto, espera) in enumerate(self._linhas):
feita = i < self.feitas
ativa = i == self.feitas and self.feitas < self.total()
marcador.definir("feito" if feita else "ativo" if ativa else "pendente")
aplicar_papel(texto, "passo" if feita else "passo-ativo" if ativa else "passo-futuro")
linha.setProperty("ativa", bool(ativa))
linha.style().unpolish(linha)
linha.style().polish(linha)
espera.setVisible(bool(ativa and self.esperando))
self.contagem.setText(t("jp.etapas_contagem", feitas=self.feitas, total=self.total()))
class CartaoAprovacao(QFrame):
""""Posso fazer isto?": o comando, e Permitir / Negar. Depois de responder vira uma linha só."""
respondeu = Signal(bool)
LINHAS_VISIVEIS = 3
def __init__(self, comando, etiqueta=None, pergunta=None):
super().__init__()
self.setProperty("papel", "cartao-aprovacao")
self.comando = comando
self.resposta = None
self._linhas = [l for l in comando.splitlines() if l.strip()] or [comando]
lay = QVBoxLayout(self)
lay.setContentsMargins(18, 16, 18, 16)
lay.setSpacing(0)
topo = QHBoxLayout()
topo.setSpacing(10)
self.etiqueta = QLabel((etiqueta or t("jp.aprov_etiqueta")).upper())
self.etiqueta.setProperty("papel", "etiqueta")
self.pergunta = QLabel(pergunta or t("jp.aprov_pergunta"))
self.pergunta.setStyleSheet("font-weight: 600; font-size: 15px;")
self.pergunta.setWordWrap(True)
topo.addWidget(self.etiqueta, 0, Qt.AlignTop)
topo.addWidget(self.pergunta, 1)
lay.addLayout(topo)
lay.addSpacing(12)
self.caixa = QFrame()
self.caixa.setProperty("papel", "comando")
cl = QVBoxLayout(self.caixa)
cl.setContentsMargins(14, 10, 14, 10)
self.texto = QLabel("")
self.texto.setProperty("papel", "mono-claro")
self.texto.setTextFormat(Qt.PlainText)
self.texto.setWordWrap(True)
self.texto.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.mais = QLabel("")
self.mais.setProperty("papel", "mono")
cl.addWidget(self.texto)
cl.addWidget(self.mais)
lay.addWidget(self.caixa)
lay.addSpacing(14)
self.botoes = QWidget()
bl = QHBoxLayout(self.botoes)
bl.setContentsMargins(0, 0, 0, 0)
bl.setSpacing(10)
self.b_permitir = _botao(t("jp.permitir"), "primario", lambda: self.responder(True))
self.b_permitir.setMinimumHeight(44)
self.b_negar = _botao(t("jp.negar"), "contorno", lambda: self.responder(False))
self.b_negar.setMinimumHeight(44)
self.b_lista = _botao(t("jp.ver_lista"), "contorno", self._expandir)
self.b_lista.setMinimumHeight(44)
bl.addWidget(self.b_permitir)
bl.addWidget(self.b_lista)
bl.addWidget(self.b_negar)
bl.addStretch(1)
lay.addWidget(self.botoes)
self.resultado = QLabel("")
self.resultado.setProperty("papel", "nota")
self.resultado.hide()
lay.addWidget(self.resultado)
self._expandido = False
self._mostrar()
self.b_negar.setFocus()
def _mostrar(self):
cortar = len(self._linhas) > self.LINHAS_VISIVEIS and not self._expandido
visiveis = self._linhas[:self.LINHAS_VISIVEIS] if cortar else self._linhas
self.texto.setText("\n".join(visiveis))
self.mais.setText(t("jp.e_mais", n=len(self._linhas) - self.LINHAS_VISIVEIS) if cortar else "")
self.mais.setVisible(cortar)
self.b_lista.setVisible(cortar)
def _expandir(self):
self._expandido = True
self._mostrar()
def responder(self, ok):
if self.resposta is not None:
return
self.resposta = bool(ok)
self.botoes.hide()
self.resultado.setText(t("jp.aprov_permitido") if ok else t("jp.aprov_negado"))
self.resultado.show()
self.respondeu.emit(self.resposta)
class MensagemIA(QWidget):
"""O avatar (monolito) e, ao lado, os blocos da resposta, na ordem em que chegam."""
def __init__(self):
super().__init__()
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(14)
lay.addWidget(tema.svg_widget(tema.monolito_svg(), 32), 0, Qt.AlignTop)
self.coluna = QVBoxLayout()
self.coluna.setSpacing(14)
lay.addLayout(self.coluna, 1)
self.blocos = []
self._texto = None
self._pensamento = None
self._ferramentas = []
self.etapas = None
# -- o que chega --------------------------------------------------------
def _adicionar(self, widget):
self.coluna.addWidget(widget)
self.blocos.append(widget)
return widget
def texto_atual(self):
if self._texto is None:
self._texto = self._adicionar(BlocoTexto())
return self._texto
def raciocinio_atual(self):
if self._pensamento is None:
self._pensamento = self._adicionar(BlocoRaciocinio())
return self._pensamento
def acrescentar_texto(self, trecho):
self.texto_atual().acrescentar(trecho)
def acrescentar_raciocinio(self, trecho):
self.raciocinio_atual().acrescentar(trecho)
def fim_do_trecho(self):
"""Terminou um trecho de fala: o próximo (depois de uma ferramenta) vira outro bloco."""
if self._texto is not None:
self._texto.concluir()
self._texto = None
self._pensamento = None
def virar_raciocinio(self):
"""O que parecia resposta era raciocínio (o modelo pensou em voz alta): troca o bloco."""
if self._texto is None:
return
texto = self._texto.texto()
self._texto._timer.stop()
self.coluna.removeWidget(self._texto)
self.blocos.remove(self._texto)
self._texto.deleteLater()
self._texto = None
self.raciocinio_atual().acrescentar(texto)
def ferramenta(self, nome, resumo=""):
self.fim_do_trecho()
linha = self._adicionar(LinhaFerramenta(nome, resumo))
self._ferramentas.append(linha)
return linha
def ferramenta_fim(self, sucesso, segundos):
pendentes = [l for l in self._ferramentas if l.sucesso is None]
if pendentes:
pendentes[-1].concluir(sucesso, segundos)
def aviso(self, texto, erro=False):
r = QLabel(tema.seguro(texto))
r.setProperty("papel", "erro-linha" if erro else "aviso-linha")
r.setWordWrap(True)
return self._adicionar(r)
def definir_etapas(self, etapas):
self.etapas = self._adicionar(CartaoEtapas(etapas))
return self.etapas
def aprovacao(self, comando):
self.fim_do_trecho()
cartao = self._adicionar(CartaoAprovacao(comando))
if self.etapas is not None:
self.etapas.esperar_pessoa(True)
cartao.respondeu.connect(lambda _ok: self.etapas.esperar_pessoa(False))
return cartao
def texto_completo(self):
return "\n".join(b.texto() for b in self.blocos if isinstance(b, BlocoTexto))
# ------------------------------------------------------------
# A área rolável
# ------------------------------------------------------------
class AreaConversa(QScrollArea):
"""Coluna central de até 780 px, rolando. Fica colada no fim enquanto a resposta chega,
a menos que a pessoa tenha subido pra ler."""
def __init__(self):
super().__init__()
self.setWidgetResizable(True)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setFrameShape(QFrame.NoFrame)
miolo = QWidget()
miolo.setObjectName("principal")
horizontal = QHBoxLayout(miolo)
horizontal.setContentsMargins(0, 0, 0, 0)
self.coluna_w = QWidget()
self.coluna_w.setMaximumWidth(LARGURA_COLUNA)
self.coluna = QVBoxLayout(self.coluna_w)
self.coluna.setContentsMargins(32, 28, 32, 24)
self.coluna.setSpacing(22)
self.coluna.addStretch(1)
horizontal.addStretch(1)
horizontal.addWidget(self.coluna_w, 1000)
horizontal.addStretch(1)
self.setWidget(miolo)
self._miolo = miolo
self._ajuste = QTimer(self) # 5.2: a altura certa do miolo (ver _ajustar_altura), uma vez por rajada
self._ajuste.setSingleShot(True)
self._ajuste.setInterval(30)
self._ajuste.timeout.connect(self._ajustar_altura)
self.coluna_w.installEventFilter(self)
self._colar = True
self.verticalScrollBar().valueChanged.connect(self._ao_rolar)
self.verticalScrollBar().rangeChanged.connect(self._ao_crescer)
self.itens = []
self.vazio = None
def eventFilter(self, objeto, evento):
if objeto is self.coluna_w and evento.type() in (QEvent.LayoutRequest, QEvent.Resize):
self._ajuste.start()
return super().eventFilter(objeto, evento)
def _ajustar_altura(self):
"""A rolagem ia além da última mensagem (centenas de pixels de vazio): o miolo pega a altura do `sizeHint` dos
blocos de texto com quebra de linha, que supõe uma largura qualquer e passa da altura que eles realmente ocupam. A
altura certa é a da coluna na largura de verdade (heightForWidth), que termina na última mensagem."""
lay = self.coluna_w.layout()
largura = self.coluna_w.width()
if lay is None or largura <= 0:
return
altura = lay.heightForWidth(largura) if lay.hasHeightForWidth() else lay.sizeHint().height()
altura = max(altura, self.viewport().height()) # nunca menor que a janela (o fundo continua o do miolo)
if self._miolo.height() != altura or self._miolo.minimumHeight() != altura:
self._miolo.setFixedHeight(altura)
def resizeEvent(self, evento):
super().resizeEvent(evento)
self._ajuste.start()
def _ao_rolar(self, valor):
barra = self.verticalScrollBar()
self._colar = valor >= barra.maximum() - 24
def _ao_crescer(self, _minimo, maximo):
if self._colar:
self.verticalScrollBar().setValue(maximo)
def _inserir(self, widget, alinhamento=None):
self.coluna.insertWidget(self.coluna.count() - 1, widget, 0, alinhamento or Qt.Alignment())
self.itens.append(widget)
return widget
def adicionar_pessoa(self, texto):
self._colar = True
return self._inserir(Bolha(texto), Qt.AlignRight)
def adicionar_ia(self):
m = MensagemIA()
self._inserir(m)
return m
def adicionar_widget(self, widget):
return self._inserir(widget)
def limpar(self):
for w in self.itens:
self.coluna.removeWidget(w)
w.hide()
w.setParent(None)
w.deleteLater()
self.itens = []
self._colar = True
def rolar_ao_fim(self):
self._colar = True
QTimer.singleShot(0, lambda: self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()))