Фреймворк называется qooxdoo. Произносится «куксду» (кому удобнее английская транстрипция: ['kuksdu:]).
На Хабре было несколько попыток написать про этот фреймворк, но все они свелись к новостям о выходе новой версии или к парам абзацев в статьях типа «смотрите каких фреймворков понаписали». Я несколько лет работаю с qooxdoo и мне хотелось бы восполнить этот пробел.
Вкратце о том, что это за зверь и с чем его едят. Больше всего фреймворк «похож» на ExtJS. Слово «похож» не совсем корректное, в данном случае, но я затрудняюсь подобрать более подходящее. Разработка проекта началась в недрах компании 1&1 Internet AG. Первая публичная версия 0.1 вышла в 2005 году. Текущая актуальная версия 4.1, про нее и будем вести речь. Некоторые моменты позволяют мне сказать, что разработчики вдохновлялись Qt при создании своего детища. Основная изначальная задумка разработчиков дать возможность разрабатывать веб приложения людям без знания HTML, CSS и DOM модели. С помощью qooxdoo это возможно. Новичок, которому требуется написать, например, админку в виде single page application (далее SPA) и который не знает ни одного HTML тега, а про CSS вообще никогда не слышал, действительно, сможет это сделать. Это не означает, что знания HTML, CSS и DOM модели вдруг резко стали не нужны. Просто, поначалу, можно обойтись без них. Что будет особенно интересно, например, разработчикам десктопных приложений, которым потребовалось что-то сделать в вебе.
В конце статьи вы можете найти немного полезных ссылок. В частности, там есть ссылки на разнообразные демо и примеры реального использования фреймворка в продакшене.
Просто так рассказывать о фреймворке скушно и неинтересно. К тому же, разработчики это уже и так сделали. Поэтому я решил сделать какой-нибудь простенький пример для демонстрации возможностей фреймворка. Многие знают о проекте http://todomvc.com/. Вот и мы с вами сделаем что-то максимально похожее с использованием qooxdoo. Справедливости ради, разработчики уже сделали демо todo листа, но это не совсем то, что нам нужно.
Итак, приступим.
Следует оговориться, что рассматриваться будет именно SPA (Desktop в терминологии qooxdoo). Для начала необходимо загрузить qooxdoo sdk. Сделать это можно по этой ссылке. SDK содержит ряд утилит, которые позволяют сгенерировать шаблон приложения и собрать отладочную и релизную версию, собрать автоматическую докуентацию, туты и т.д. Ознакомиться с документацией по тулчейну можно тут.
Для создания шаблона приложения мы запустим:
create-application.py --name=todos
После этой операции мы получим следующий каркас приложения:
Приложение сгенерируется не пустым. Оно будет иметь кнопку, по нажатию на которую будет выводиться alert.
Основной файл Application.js будет содержать следующий код:
/**
* This is the main application class of your custom application "todos"
*
* @asset(todos/*)
*/
qx.Class.define("todos.Application", {
extend : qx.application.Standalone,
members : {
/**
* This method contains the initial application code and gets called
* during startup of the application
*
* @lint ignoreDeprecated(alert)
*/
main : function() {
// Call super class
this.base(arguments);
// Enable logging in debug variant
if (qx.core.Environment.get("qx.debug")) {
// support native logging capabilities, e.g. Firebug for Firefox
qx.log.appender.Native;
// support additional cross-browser console. Press F7 to toggle visibility
qx.log.appender.Console;
}
/*
-------------------------------------------------------------------------
Below is your actual application code...
-------------------------------------------------------------------------
*/
// Create a button
var button1 = new qx.ui.form.Button("First Button", "todos/test.png");
// Document is the application root
var doc = this.getRoot();
// Add button to document at fixed coordinates
doc.add(button1, {left: 100, top: 50});
// Add an event listener
button1.addListener("execute", function(e) {
alert("Hello World!");
});
}
}
});
Для того, чтобы увидеть задумку авторов, нам нужно будет собрать дебажную или продакшн версию приложения.
Первый вариант получится, если перейти в папку проекта и запустить:
./generate.py source
второй можно получить после запуска:
./generate.py build
После этого грузим в браузере соответствующий index.html файл и видим вот такую картинку:
На кнопку можно нажимать, а можно не нажимать.
Для нетерпеливых сразу даю ссылку на github с готовым вариантом, с которым можно играться. Для того, чтобы получилось, кроме исходников с гитхаба необходимо скачать SDK и прописать в файле config.json корректный путь «QOOXDOO_PATH». После чего необходимо собрать требуемую версию, как описано выше.
Ну а мы рассмотрим процесс создания приложения последовательно, в его естественном виде.
Для начала мы создадим заготовку для виджета окна для нашего todo листа и безжалостно удалим из Application.js все что там нам нагенерировал генератор. Получится у нас следущее.
Window.js
qx.Class.define("todos.Window", {
extend : qx.ui.window.Window,
construct: function(){
this.base(arguments);
this.set({
caption: "todos",
width: 480,
height: 640,
allowMinimize: false,
allowMaximize: false,
allowClose: false
});
this.addListenerOnce("appear", function(){
this.center();
}, this);
}
});
Application.js
/**
* @asset(todos/*)
*/
qx.Class.define("todos.Application", {
extend : qx.application.Standalone,
members : {
main : function() {
// Call super class
this.base(arguments);
var wnd = new todos.Window;
wnd.show();
}
}
});
После сборки мы увидим вот такую красоту:
Пора наполнить ее смыслом. Нам будут необходимы следующие элементы: тулбар, запись todo листа и элемент добавления записи в лист. Запись todo листа является повторяющимся элементом, оформим его в виде отдельного виджета. Тулбар и элемент добавления записи в лист можно сделать как отдельными виджетами, что позволит их использовать повторно, так и частью Window. Тулбар сделаем отдельным виджетом, а элемент добавления записи оставим частью Window, чтобы показать, что можно и так и так. Сделаем все вышеописанное и наполним виджеты жизнью.
ToDo.js
qx.Class.define("todos.ToDo", {
extend: qx.ui.core.Widget,
events : {
remove : "qx.event.type.Event"
},
properties: {
completed: {
init: false,
check: "Boolean",
event: "completedChanged"
},
appearance: {
refine: true,
init: "todo"
}
},
construct: function(text){
this.base(arguments);
var grid = new qx.ui.layout.Grid;
grid.setColumnWidth(0, 20);
grid.setColumnFlex(1, 1);
grid.setColumnWidth(2, 20);
grid.setColumnAlign(0, "center", "middle");
grid.setColumnAlign(1, "left", "middle");
grid.setColumnAlign(2, "center", "middle");
this._setLayout(grid);
this._add(this.getChildControl("checkbox"), {row: 0, column: 0});
this._add(this.getChildControl("text-container"), {row: 0, column: 1});
this._add(this.getChildControl("icon"), {row: 0, column: 2});
this.getChildControl("label").setValue(text);
this.addListener("mouseover", function(){this.getChildControl("icon").show();}, this);
this.addListener("mouseout", function(){this.getChildControl("icon").hide();}, this);
this.getChildControl("icon").hide();
this.getChildControl("text-container").addListener("dblclick", this.__editToDo, this);
},
members : {
// overridden
_createChildControlImpl: function(id) {
var control;
switch(id) {
case "checkbox":
control = new qx.ui.form.CheckBox;
this.bind("completed", control, "value");
control.bind("value", this, "completed");
break;
case "text-container":
control = new qx.ui.container.Composite(new qx.ui.layout.HBox);
control.add(this.getChildControl("label"), {flex: 1});
break;
case "label":
control = new qx.ui.basic.Label;
control.bind("value", control, "toolTipText");
break;
case "textfield":
control = new qx.ui.form.TextField;
control.addListener("keypress", function(event){
var key = event.getKeyIdentifier();
switch(key) {
case "Enter":
this.__editComplete();
break;
case "Escape":
this.__editCancel();
break;
}
}, this);
control.addListener("blur", this.__editComplete, this);
break;
case "icon":
control = new qx.ui.basic.Image("todos/icon-remove-circle.png");
control.addListener("click", function(){
this.fireEvent("remove");
}, this);
break;
}
return control || this.base(arguments, id);
},
__editToDo : function() {
var tc = this.getChildControl("text-container");
var tf = this.getChildControl("textfield");
tc.removeAll();
tc.add(tf, {flex: 1});
tf.setValue(this.getChildControl("label").getValue());
tf.focus();
tf.activate();
},
__editComplete : function() {
this.getChildControl("label").setValue(this.getChildControl("textfield").getValue());
this.__editCancel();
},
__editCancel : function() {
var tc = this.getChildControl("text-container");
tc.removeAll();
tc.add(this.getChildControl("label"), {flex: 1});
}
}
});
StatusBar.js
qx.Class.define("todos.StatusBar", {
extend: qx.ui.core.Widget,
events: {
removeCompleted: "qx.event.type.Event"
},
properties: {
todos: {
init: [],
check: "Array"
},
filter: {
init: "all",
check: ["all", "active", "completed"],
event: "filterChanged"
}
},
construct: function() {
this.base(arguments);
var grid = new qx.ui.layout.Grid;
grid.setColumnWidth(0, 100);
grid.setColumnFlex(1, 1);
grid.setColumnWidth(2, 130);
grid.setColumnAlign(0, "left", "middle");
grid.setColumnAlign(1, "center", "middle");
grid.setColumnAlign(2, "right", "middle");
grid.setRowHeight(0, 26);
this._setLayout(grid);
this._add(this.getChildControl("info"), {row: 0, column: 0});
this._add(this.getChildControl("filter"), {row: 0, column: 1});
this._add(this.getChildControl("remove-completed-button"), {row: 0, column: 2});
this.update();
},
destruct: function() {
this.__rgFilter.dispose();
},
members : {
__rgFilter: null,
update: function() {
var todosCount = this.getTodos().length;
var itemsLeft = this.getTodos().filter(function(item){return !item.getCompleted();}).length;
this.getChildControl("info").setValue("<b>"+itemsLeft+"</b> items left");
if (itemsLeft === todosCount) {
this.getChildControl("remove-completed-button").exclude();
} else {
this.getChildControl("remove-completed-button").setLabel("Clear completed ("+(todosCount-itemsLeft)+")");
this.getChildControl("remove-completed-button").show();
}
},
// overridden
_createChildControlImpl: function(id) {
var control;
switch(id) {
case "info":
control = new qx.ui.basic.Label;
control.setRich(true);
break;
case "filter":
control = new qx.ui.container.Composite(new qx.ui.layout.HBox);
control.add(this.getChildControl("rb-filter-all"));
control.add(this.getChildControl("rb-filter-active"));
control.add(this.getChildControl("rb-filter-completed"));
this.__rgFilter = new qx.ui.form.RadioGroup(
this.getChildControl("rb-filter-all"),
this.getChildControl("rb-filter-active"),
this.getChildControl("rb-filter-completed")
);
this.__rgFilter.addListener("changeSelection", this.__onFilterChanged, this);
break;
case "rb-filter-all":
control = new qx.ui.form.RadioButton("All");
control.setUserData("value", "all");
break;
case "rb-filter-active":
control = new qx.ui.form.RadioButton("Active");
control.setUserData("value", "active");
break;
case "rb-filter-completed":
control = new qx.ui.form.RadioButton("Completed");
control.setUserData("value", "completed");
break;
case "remove-completed-button":
control = new qx.ui.form.Button;
control.addListener("execute", function(){
this.fireEvent("removeCompleted");
}, this);
break;
}
return control || this.base(arguments, id);
},
__onFilterChanged : function(event) {
this.setFilter(event.getData()[0].getUserData("value"));
}
}
});
Window.js
qx.Class.define("todos.Window", {
extend: qx.ui.window.Window,
properties: {
appearance: {
refine: true,
init: "todo-window"
},
todos: {
init: [],
check: "Array",
event: "todosChanged"
},
filter: {
init: "all",
check: ["all", "active", "completed"],
apply: "__applyFilter"
}
},
construct: function(){
this.base(arguments);
this.set({
caption: "todos",
width: 480,
height: 640,
allowMinimize: false,
allowMaximize: false,
allowClose: false
});
this.setLayout(new qx.ui.layout.VBox(2));
this.add(this.getChildControl("todo-writer"));
this.add(this.getChildControl("todos-scroll"), {flex: 1});
this.add(this.getChildControl("statusbar"));
this.addListenerOnce("appear", function(){
this.center();
}, this);
},
destruct : function() {
var todoItems = this.getTodos();
for (var i= 0, l=todoItems.length; i<l; i++) {
todoItems[i].dispose();
}
},
members : {
// overridden
_createChildControlImpl: function(id) {
var control;
switch(id) {
case "todo-writer":
var grid = new qx.ui.layout.Grid;
grid.setColumnWidth(0, 20);
grid.setColumnFlex(1, 1);
grid.setColumnAlign(0, "center", "middle");
grid.setColumnAlign(1, "left", "middle");
control = new qx.ui.container.Composite(grid);
control.add(this.getChildControl("checkbox"), {row: 0, column: 0});
control.add(this.getChildControl("textfield"), {row: 0, column: 1});
break;
case "checkbox":
control = new qx.ui.form.CheckBox;
control.addListener("changeValue", this.__onCheckAllChanged, this);
break;
case "textfield":
control = new qx.ui.form.TextField;
control.setPlaceholder("What needs to be done?");
control.addListener("keydown", this.__onWriterTextFieldKeydown, this);
break;
case "todos-scroll":
control = new qx.ui.container.Scroll;
control.add(this.getChildControl("todos-container"));
break;
case "todos-container":
control = new qx.ui.container.Composite(new qx.ui.layout.VBox(1));
break;
case "statusbar":
control = new todos.StatusBar;
control.bind("filter", this, "filter");
this.bind("todos", control, "todos");
control.addListener("removeCompleted", this.__onRemoveCompleted, this);
break;
}
return control || this.base(arguments, id);
},
__onWriterTextFieldKeydown : function(event) {
var key = event.getKeyIdentifier();
switch(key) {
case "Enter":
var value = event.getTarget().getValue();
if (value) {
event.getTarget().setValue("");
var todo = new todos.ToDo(value);
this.getTodos().push(todo);
todo.addListenerOnce("remove", this.__onTodoRemove, this);
todo.addListener("completedChanged", this.__onTodoCompletedChanged, this);
this.__updateTodoList();
this.getChildControl("statusbar").update();
var cbAll = this.getChildControl("checkbox");
cbAll.removeListener("changeValue", this.__onCheckAllChanged, this);
cbAll.setValue(false);
cbAll.addListener("changeValue", this.__onCheckAllChanged, this);
}
break;
case "Escape":
event.getTarget().setValue("");
break;
}
},
__updateTodoList : function() {
var toList;
switch(this.getFilter()) {
case "all":
toList = this.getTodos();
break;
case "active":
toList = this.getTodos().filter(function(item){return !item.getCompleted();});
break;
case "completed":
toList = this.getTodos().filter(function(item){return item.getCompleted();});
break;
}
var container = this.getChildControl("todos-container");
container.removeAll();
toList.forEach(function(item){
container.add(item);
});
},
__applyFilter : function() {
this.__updateTodoList();
},
__onTodoRemove : function(event) {
var todo = event.getTarget();
this.setTodos(this.getTodos().filter(function(item){return item !== todo;}));
this.getChildControl("todos-container").remove(todo);
todo.dispose();
this.getChildControl("statusbar").update();
},
__onTodoCompletedChanged : function() {
var cbAll = this.getChildControl("checkbox");
cbAll.removeListener("changeValue", this.__onCheckAllChanged, this);
cbAll.setValue(this.getTodos().length === this.getTodos().filter(function(item){return item.getCompleted();}).length);
cbAll.addListener("changeValue", this.__onCheckAllChanged, this);
this.__updateTodoList();
this.getChildControl("statusbar").update();
},
__onCheckAllChanged : function(event) {
var value = event.getData();
this.getTodos().forEach(function(todo){
todo.removeListener("completedChanged", this.__onTodoCompletedChanged, this);
todo.setCompleted(value);
todo.addListener("completedChanged", this.__onTodoCompletedChanged, this);
}, this);
this.__updateTodoList();
this.getChildControl("statusbar").update();
},
__onRemoveCompleted : function() {
var completed = this.getTodos().filter(function(item){return item.getCompleted();});
this.setTodos(this.getTodos().filter(function(item){return !item.getCompleted();}));
completed.forEach(function(todo){
this.getChildControl("todos-container").remove(todo);
todo.dispose();
}, this);
this.getChildControl("statusbar").update();
this.getChildControl("checkbox").setValue(false);
}
}
});
На этом этапе мы получили вполне себе функционально законченное приложение. Есть только один нюанс, оно страшно, как атомная война:
Попробуем привести его к пристойному виду. Оговорюсь сразу, дизайнер из меня, как из козла балерина, поэтому задача максимум для меня добиться, чтобы наш todo лист выглядел просто аккуратно, без изысков.
За внешний вид приложения в qooxdoo отвечают темы. Фреймворк поставляется с 4 темами. Темы можно расширять, переписывать и т.д. Тема в qooxdoo имеет 5 составляющих и определяется таким образом:
qx.Theme.define("todos.theme.Theme", {
meta : {
color : todos.theme.Color,
decoration : todos.theme.Decoration,
font : todos.theme.Font,
icon : qx.theme.icon.Tango,
appearance : todos.theme.Appearance
}
});
Подробнее про темы можно почитать тут.
Итак, сделаем следующие изменения:
Appearance.js
/**
* * @asset(qx/icon/Tango/*
*/
qx.Theme.define("todos.theme.Appearance", {
extend : qx.theme.simple.Appearance,
appearances : {
"todo-window" : {
include : "window",
alias : "window",
style : function(){
return {
contentPadding: 0
};
}
},
"checkbox": {
alias : "atom",
style : function(states) {
var icon;
if (states.checked) {
icon = "todos/checked.png";
} else if (states.undetermined) {
icon = qx.theme.simple.Image.URLS["todos/undetermined.png"];
} else {
icon = qx.theme.simple.Image.URLS["blank"];
}
return {
icon: icon,
gap: 8,
cursor: "pointer"
}
}
},
"radiobutton": {
style : function(states) {
return {
icon : null,
font : states.checked ? "bold" : "default",
textColor : states.checked ? "green" : "black",
cursor: "pointer"
}
}
},
"checkbox/icon" : {
style : function(states) {
return {
decorator : "checkbox",
width : 16,
height : 16,
backgroundColor : "white"
}
}
},
"todo-window/checkbox" : "checkbox",
"todo-window/textfield" : "textfield",
"todo-window/todos-scroll" : "scrollarea",
"todo-window/todo-writer" : {
style : function() {
return {
padding : [2, 2, 0, 0]
};
}
},
"todo-window/statusbar" : {
style : function() {
return {
padding : [ 2, 6],
decorator : "statusbar",
minHeight : 32,
height : 32
};
}
},
"todo-window/statusbar/info" : "label",
"todo-window/statusbar/rb-filter-all" : "radiobutton",
"todo-window/statusbar/rb-filter-active" : "radiobutton",
"todo-window/statusbar/rb-filter-completed" : "radiobutton",
"todo-window/statusbar/remove-completed-button" : {
include : "button",
alias : "button",
style : function() {
return {
width : 150,
allowGrowX : false
};
}
},
"todo/label" : {
include : "label",
alias : "label",
style : function(states) {
return {
font : (states.completed ? "line-through" : "default"),
textColor : (states.completed ? "light-gray" : "black"),
cursor : "text"
};
}
},
"todo/icon" : {
style : function() {
return {
cursor : "pointer"
};
}
},
"todo/text-container" : {
style : function() {
return {
allowGrowY : false
};
}
},
"todo/checkbox" : "checkbox"
}
});
Color.js
qx.Theme.define("todos.theme.Color",
{
extend : qx.theme.simple.Color,
colors :
{
"light-gray" : "#BBBBBB",
"border-checkbox": "#B6B6B6"
}
});
Decoration.js
qx.Theme.define("todos.theme.Decoration", {
extend : qx.theme.simple.Decoration,
decorations : {
"statusbar" : {
style : {
backgroundColor : "background",
width: [2, 0, 0, 0],
color : "window-border-inner"
}
},
"checkbox" : {
decorator : [
qx.ui.decoration.MBorderRadius,
qx.ui.decoration.MSingleBorder
],
style : {
radius : 3,
width : 1,
color : "border-checkbox"
}
}
}
});
Font.js
qx.Theme.define("todos.theme.Font",
{
extend : qx.theme.simple.Font,
fonts :
{
"line-through" :
{
size : 13,
family : ["arial", "sans-serif"],
decoration : "line-through"
}
}
});
После этого наш TODO лист будет выглядеть так:
На этом пока можно закончить. Я не затронул огромное количество вопросов, но это просто невозмозможно в рамках одной статьи. Хотелось познакомить с фреймворком на примере небольшой задачи, как можно меньше углубляясь в детали. Подробнее можно почитать по приведенным ссылкам. Обо всех ошибках и опечатках прошу писать в личку. Спасибо за внимание.
Полезные ссылки:
Домашняя страница qooxdoo: http://qooxdoo.org/
Страница загрузки SDK: http://qooxdoo.org/downloads
Разнообразные демо: http://qooxdoo.org/demos
Примеры использования: http://qooxdoo.org/community/real_life_examples
SPA туториал: http://manual.qooxdoo.org/current/pages/desktop/tutorials/tutorial-part-1.html
Код примера на гитхабе: https://github.com/VasisualyLokhankin/todolist_qooxdoo
Комментарии (9)
ilusha_sergeevich
03.04.2015 06:05+1Воу. Я его впервые увидел приблизительно в тоже время когда узнал про Prototype и jQuery. Очень удивлен и рад, что он еще жив и развивается, все таки проделана титаническая работа.
dunmaksim
03.04.2015 12:35-1Пытался освоить этот фреймворк, а потом подумал: «Зачем оно мне надо?»
- много сложного кода для простых вещей
- высокий порог вхождения
- «страшный» дизайн
В итоге остановился пока на Angular + Bootstrap для фронта и Django для бэка.
immaculate
03.04.2015 16:49Все подобные фреймворки:
— очень уродливо выглядят
— глючные и медленные
— неудобные и неинтуитивные, так как веб — не десктоп, а десктоп — не веб
Лучше уж напрячься и сделать нормальный веб-интерфейс, чем терпеть уродливое подобие десктопного приложения в браузере.
alist
05.04.2015 22:52+1Есть разные парадигмы UI, которые так или иначе становились популярными в разное время.
Например, сейчас мы привыкли к тому, что если я, скажем, хочу себе аватарку поменять, я кликаю по самой аватарке, А вот лет 7-8 назад «интуитивно» было бы иметь рядом с аватаркой кнопочку «поменять аватарку». Qooxdoo — как раз из той эпохи, когда для выполнения действий мы использовали дополнительные UI-контролы рядом с данными, а не манипулировали самими данными.
Это не хорошо и не плохо, потому что всякому UI есть свое применение (тот же Vim популярен сегодня, несмотря на то, что консольный). Но многие из тех, кто начал разрабатывать пользовательские интерфейсы, скажем, года три-четыре назад, посмотрят на Qooxdoo, Dojo, или Ext JS с недоумением, тк не привыкли видеть в современных интерфейсах такой control-heavy-подход.
Плюс, как я погляжу, такой: пишем все на JS, a за CSS и HTML отвечает фреймворк. Наверняка, какие-то админки, или UI для внутреннего пользования на этом можно быстро сделать.
Например, я — научный сотрудник где-то там. Мне нужен UI, чтоб задавать параметры какой-то симуляции. Я умею кодить алгоритмы и переводить формулы из математики в код. Чем мне разбираться с веб-разработкой и делать все, как советуют хипстеры, я лучше возьму эту штуку и накидаю UI за несколько часов. Уж синтаксис JS и API этой штуки по примерам можно освоить довольно быстро, а с какими-то неприятными для новичка особенностями JS (типа variable hoisting and scoping или this) я вряд ли столкнусь.
Лет 15 назад такой UI люди бы на Java, Delphi или VisualBasic сделали. А сегодня вот такая штука есть.
AndersonDunai
Как по мне — очередной типичный фрэймворк.
И инструкции у них всех чем-то похожи:
— Шаг 1. Ставим библиотеку через npm.
— Шаг 2. Генерируем каркас приложения. Всего 10 (!) строк для «Hello World!» — смотрите, какой у нас простой фрэймворк!
— Шаг 3. Делает простейший TODO-list. 5000 нелогичных строк write-only кода с кучей костылей и хаков непонятно с чем и зачем.
Но за статью всё равно спасибо.
kibitzer
Вместе с ext js они были пионерами среди ui фреймворков для RIA. Поэтому все остальные — «очередные типичные»… :-)
Мне в нём всегда не нравилось то, что интерфейс полностью надо конструировать методами в бизнес-логике, возможно сейчас что-то и поменялось, но сомнительно. Были попытки у сообщества прикрутить для описания xml, но заглохли.
Dolios Автор
Ну не 5000, вы утрируете :)
Вся прелесть qooxdoo и extjs познается, когда приложенька разрастается до нескольких сотен «классов»/файлов. Hello World, действительно, выглядит как стрельба из пушки по воробьям.
>очередной типичный фрэймворк
Я только 2 таких знаю.
ildus
Ещё всегда был dojo, тоже неплохой фреймворк с кучей виджетов
Dolios Автор
Спасибо, посмотрю.