Files

330 lines
10 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name RequestCard
extends Control
## Карточка запроса клиента 400×64. Светлая карточка, три слота файлов.
## TCP-запрос в ожидании соединяется кликом по самой карточке; X отклоняет,
## двойной клик по файлу отсоединяет его. attached-файл помечается зелёной
## галочкой, пустой слот — пунктиром и мигающим «ожидается».
signal connect_pressed(id: String)
signal reject_pressed(id: String)
signal file_dropped(request_id: String, slot_index: int, payload: Dictionary)
signal detach_pressed(request_id: String, slot_index: int)
const CARD := Vector2(400, 64)
const SLOT_X := 40.0
const SLOT_STEP := 72.0
const SLOT_Y := 36.0
const SLOT_W := 68.0
const SLOT_H := 20.0
var id := ""
var client_name := ""
var client_id := ""
var protocol := "TCP"
var slots: Array = []
var slot_count := 3
var time_left := 30.0
var time_total := 60.0
var progress := 0.0
var avatar_muted := false
var connected := false
## Запрос-угроза (фишинг): приглушённый аватар и бейдж «???».
var is_attack := false
var attack_kind := ""
var attack_id := ""
var hovered := false:
set(v):
hovered = v
queue_redraw()
var _reject: Button90
var _blink := 0.0
var _blink_on := true
var _hover_slot := -1
var _drop_slot := -1
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_PASS
custom_minimum_size = CARD
if size == Vector2.ZERO:
size = CARD
mouse_default_cursor_shape = Control.CURSOR_ARROW
mouse_entered.connect(func() -> void: hovered = true)
mouse_exited.connect(func() -> void:
hovered = false
_hover_slot = -1)
_reject = Button90.new()
_reject.name = "RejectButton"
_reject.icon = Button90.Icon.X
_reject.position = Vector2(386, 38)
_reject.size = Vector2(14, 14)
_reject.tooltip_text = "Отклонить запрос"
add_child(_reject)
_reject.pressed.connect(func() -> void: reject_pressed.emit(id))
set_process(true)
func setup(p_id: String, p_client: String, p_client_id: String, p_proto: String,
p_slots: Array, p_left: float, p_total: float, p_progress: float = 0.0,
p_is_attack: bool = false, p_attack_kind: String = "", p_attack_id: String = "",
p_avatar_muted: bool = false) -> void:
id = p_id
client_name = p_client
client_id = p_client_id
protocol = p_proto
slots = p_slots
slot_count = maxi(p_slots.size(), 1)
time_left = p_left
time_total = p_total
progress = p_progress
is_attack = p_is_attack
attack_kind = p_attack_kind
attack_id = p_attack_id
avatar_muted = p_avatar_muted
_update_interaction()
if is_attack and attack_kind == "phishing":
tooltip_text = "ПОДОЗРИТЕЛЬНЫЙ ЗАПРОС · %s" % client_id
else:
tooltip_text = "%s · %s" % [client_name, client_id]
custom_minimum_size = Vector2(CARD.x, 0)
queue_redraw()
## Клик по карточке (не по слоту и не по X) соединяет TCP-запрос.
func _clickable() -> bool:
return not is_attack and protocol == "TCP" and not connected
func _update_interaction() -> void:
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if _clickable() \
else Control.CURSOR_ARROW
func ratio() -> float:
if time_total <= 0.0:
return 0.0
return clampf(time_left / time_total, 0.0, 1.0)
func _process(delta: float) -> void:
_blink += delta
var on := int(_blink * 2.0) % 2 == 0
if on != _blink_on:
_blink_on = on
queue_redraw()
func _slot_rect(i: int) -> Rect2:
return Rect2(SLOT_X + i * SLOT_STEP, SLOT_Y, SLOT_W, SLOT_H)
func _icon_rect(i: int) -> Rect2:
return Rect2(SLOT_X + i * SLOT_STEP + 1, SLOT_Y + 1, 18, 18)
func _draw() -> void:
var r := Rect2(Vector2.ZERO, size)
var f := Theme90.ui()
var bg := Theme90.WHITE if hovered else Theme90.PAPER_HI
var border := Theme90.ACCENT_LIGHT if hovered else Theme90.BORDER
draw_style_box(Theme90.box(bg, border, 4, 1,
Color(0, 0, 0, 0.11) if hovered else Theme90.SHADOW), r)
_draw_avatar(Rect2(8, 8, 24, 24))
Theme90.text(self, f, Vector2(40, 4), Theme90.fit(f, client_name, 12, 146), 12,
Theme90.INK, 146)
Theme90.text(self, f, Vector2(40, 19), Theme90.fit(f, client_id, 10, 148), 10,
Theme90.SHADE, 148)
# протокол — маленький цветной чип
var chip := Rect2(190, 7, 34, 13)
if is_attack and attack_kind == "phishing":
draw_style_box(Theme90.box(Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, 0.12),
Color(0, 0, 0, 0), 3, 0), chip)
Theme90.text_vcenter(self, f, chip, "???", 10, Theme90.ERROR, -1,
HORIZONTAL_ALIGNMENT_CENTER)
else:
var pcol := Theme90.NAVY if protocol == "TCP" else Theme90.WARN
draw_style_box(Theme90.box(Color(pcol.r, pcol.g, pcol.b, 0.12),
Color(0, 0, 0, 0), 3, 0), chip)
Theme90.text_vcenter(self, f, chip, protocol, 10, pcol, -1,
HORIZONTAL_ALIGNMENT_CENTER)
for i in slot_count:
_draw_slot(i, f)
# таймер / прогресс отправки
var tr := Rect2(300, 4, 92, 12)
var label := "ОТПРАВКА" if progress > 0.0 else "ОСТАЛОСЬ"
var value := "%d%%" % int(roundf(progress * 100.0)) if progress > 0.0 \
else "%d с" % int(ceilf(time_left))
Theme90.text(self, f, Vector2(tr.position.x, tr.position.y), label, 10, Theme90.SHADE)
var vw := f.get_string_size(value, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
Theme90.text(self, f, Vector2(tr.end.x - vw, tr.position.y), value, 10,
_time_color())
var bar := Rect2(300, 20, 92, 4)
var fill := progress if progress > 0.0 else ratio()
var bcol := Theme90.ACCENT if progress > 0.0 else _time_color()
Theme90.draw_meter(self, bar, fill, bcol)
# маленькая подсказка-статус в правом нижнем углу (не кнопка)
var hint := ""
var hint_color := Theme90.ACCENT
if is_attack:
hint = ""
elif progress > 0.0:
hint = "ОТПРАВКА…"
elif protocol != "TCP":
hint = "UDP · АВТО"
hint_color = Theme90.WARN
elif connected:
hint = "ПОДКЛЮЧЁН"
hint_color = Theme90.SUCCESS
else:
hint = "КЛИК — СОЕДИНИТЬСЯ"
if not hint.is_empty():
Theme90.text_vcenter(self, f, Rect2(288, 42, 88, 16), hint, 8,
hint_color, -1, HORIZONTAL_ALIGNMENT_RIGHT)
if hovered and _drop_slot < 0:
draw_style_box(Theme90.box(Color(0, 0, 0, 0), Theme90.ACCENT_LIGHT, 4, 1),
r.grow(-1.0))
if _drop_slot >= 0:
Theme90.draw_dashed_box(self, Rect2(2, 2, size.x - 4, size.y - 4),
Theme90.ACCENT, 4.0, 1.2)
func _time_color() -> Color:
if ratio() < 0.2:
return Theme90.ERROR
if ratio() < 0.5:
return Theme90.WARN
return Theme90.SUCCESS
func _draw_avatar(r: Rect2) -> void:
var bg := Theme90.PAPER_LO if avatar_muted else Theme90.ACCENT
var fg := Theme90.SHADE if avatar_muted else Theme90.WHITE
draw_style_box(Theme90.box(bg, Color(0, 0, 0, 0), 5, 0), r)
var f := Theme90.ui()
Theme90.text(self, f, Vector2(r.position.x, r.position.y + 5), _monogram(), 14, fg,
r.size.x, HORIZONTAL_ALIGNMENT_CENTER)
draw_circle(Vector2(r.end.x - 4, r.end.y - 4), 3.0, Theme90.PAPER_HI)
draw_circle(Vector2(r.end.x - 4, r.end.y - 4), 2.0,
Theme90.SHADE if avatar_muted else Theme90.SUCCESS)
func _monogram() -> String:
if is_attack:
return "?"
var parts := client_name.replace("«", " ").replace("»", " ").split(" ", false)
if parts.is_empty():
return "?"
var last := str(parts[parts.size() - 1])
if last.length() == 0:
return "?"
return last.substr(0, 1).to_upper()
func _draw_slot(i: int, f: Font) -> void:
var cell := _slot_rect(i)
var ir := _icon_rect(i)
var data: Variant = slots[i] if i < slots.size() else null
var drop := _drop_slot == i
if data == null:
Theme90.draw_dashed_box(self, cell, Color(Theme90.SHADE.r, Theme90.SHADE.g,
Theme90.SHADE.b, 0.55), 2.5, 1.0)
Theme90.text(self, f, Vector2(cell.position.x + 5, cell.position.y + 4), "+", 12,
Theme90.SHADE)
if _blink_on:
Theme90.text(self, f, Vector2(cell.position.x + 15, cell.position.y + 5),
"ожидается", 10, Theme90.WARN)
if drop:
Theme90.draw_dashed_box(self, cell.grow(1), Theme90.ACCENT, 2.5, 1.4)
return
var d: Dictionary = data
var t := str(d.get("type", "text"))
var col := FileCard.type_color(t)
draw_style_box(Theme90.box(Theme90.WHITE, Color(col.r, col.g, col.b, 0.5), 3, 1),
cell)
Theme90.draw_doc_icon(self, ir, t, col)
var nm := "%s.%s" % [str(d.get("name", "")), str(d.get("ext", ""))]
Theme90.text(self, f, Vector2(cell.position.x + 22, cell.position.y + 5),
Theme90.fit(f, nm, 10, SLOT_W - 24.0), 10, Theme90.INK, SLOT_W - 24.0)
if bool(d.get("attached", false)):
Theme90.draw_check_badge(self, Vector2(cell.position.x + 17, cell.position.y + 2),
4.5)
# подсказка «отсоединить» на наведении
if _hover_slot == i or drop:
var xr := Rect2(cell.end.x - 12, cell.position.y + 1, 11, 11)
draw_circle(xr.get_center(), 5.5, Color(Theme90.ERROR.r, Theme90.ERROR.g,
Theme90.ERROR.b, 0.92))
Theme90.draw_x(self, xr, Theme90.WHITE, 1.0)
if drop:
Theme90.draw_dashed_box(self, cell.grow(1), Theme90.ACCENT, 2.5, 1.4)
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
var prev := _hover_slot
_hover_slot = _slot_at(event.position)
if prev != _hover_slot:
queue_redraw()
elif event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
var i := _slot_at(event.position)
if i >= 0:
# Слоты (и X отсоединения) не должны запускать соединение.
if event.double_click and i < slots.size() and slots[i] != null:
detach_pressed.emit(id, i)
accept_event()
return
accept_event()
if _clickable():
connect_pressed.emit(id)
func _slot_at(pos: Vector2) -> int:
for i in slot_count:
if _slot_rect(i).has_point(pos):
return i
return -1
func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
if not (data is Dictionary):
return false
if data.get("kind", "") != "file":
return false
var i := _slot_at(at_position)
if i < 0:
i = _first_empty()
_drop_slot = i
queue_redraw()
return true
func _drop_data(at_position: Vector2, data: Variant) -> void:
var i := _slot_at(at_position)
if i < 0:
i = _first_empty()
_drop_slot = -1
queue_redraw()
file_dropped.emit(id, i, data)
func _first_empty() -> int:
for i in slot_count:
if i >= slots.size() or slots[i] == null:
return i
return slot_count - 1
func _notification(what: int) -> void:
if what == NOTIFICATION_DRAG_END:
_drop_slot = -1
queue_redraw()