|

Ένα Παράδειγμα MVC σε C++ και ncurses

Το ncurses ούτε το αγαπώ ούτε το μισώ. Κάποιες φορές είναι δύσκολο να το κάνεις να δουλέψει αξιοπρεπώς και απρόσκοπτα, αλλά παρέχει ένα πλαίσιο για interface δύο διαστάσεων χωρίς γραφικά. Σε αυτό το άρθρο θα εξερευνήσουμε έναν τρόπο να το χρησιμοποιήσουμε σε ένα MVC πλαίσιο. Προφανώς θα επικεντρώσω το ενδιαφέρον μου περισσότερο στο κομμάτι του V, αλλά ας ξεκινήσουμε με ένα απλό μοντέλο:

Το μοντέλο

Για τις ανάγκες του παραδείγματός μας, θα χρησιμποποιήσουμε μια “μακέτα” βάσης δεδομένων:

struct personData {
	string fname,lname;
	int salary;
};

typedef map<int,personData> personCollection;

class SomeDB {

	private:
	
		personCollection persons={
			{1,{"Judah","Knight",1510}},
			{2,{"Drew","Guerra",590}},
			{3,{"Brooke","Arroyo",820}},
			{4,{"Dustin","Terry",1270}},
			{5,{"Isiah","Willis",1760}},
			{6,{"Julie","Mays",900}},
			{7,{"Mariyah","Holt",1240}},
			{8,{"Alexia","Payne",750}},
		};
	
	public:
		personCollection getPersons();
		void deletePerson(int);
		void updatePersonSalary(int,int);
	
};

Όπως φαίνεται παραπάνω, υλοποιούμε ένα απλό dataset από άτομα και τους αντίστοιχους μισθούς τους. Θέλουμε να μπορούμε να ανακαλέσουμε ολόκληρο το dataset και, για το παράδειγμά μας, απλά να διαγράφουμε ένα άτομο ή να αλλάζουμε τον μισθό του. Αυτές οι τρεις μέθοδοι είναι αρκετά εύκολες:

personCollection SomeDB::getPersons() { return persons; }
void SomeDB::deletePerson(int p) { persons.erase(p); }
void SomeDB::updatePersonSalary(int p,int s) { persons[p].salary=s; }

Η View

Στον κόσμο των αντικειμένων, κάποια από αυτά χρειάζεται να μπορούμε να τα απεικονίσουμε (render). Κάθε αντικείμενο view είναι αυτού του είδους. Ένα αντικείμενο Renderable πρέπει να υλοποιεί μια μέθοδο render() η οποία θα το απεικονίζει μέσα σε ένα συγκεκριμένο πλαίσιο (context):

template<class CtxT>
class Renderable {

	protected:
	
		CtxT *context;
		
		virtual void render()=0;
		
		~Renderable() { };
		
	public:

		Renderable(CtxT *ctx): context(ctx) { }
		
		using ctx_t=CtxT;
		
};

Μία View είναι ένα αντικείμενο Renderable, το οποίο μπορεί η εφαρμογή να το εμφανίζει/κρύβει. Θα πρέπει επίσης να έχουμε κάποιον τρόπο να ελέγχουμε αν έχει εμφανιστεί ή όχι. Το παρακάτω υλοποιεί αυτές τις προδιαγραφές:

template<class CtxT>
class View: public Renderable<CtxT> {

	using Renderable<CtxT>::Renderable;

	private:
		bool shown=false;
		
	protected:
		bool showing() { return shown; }
		
	public:
		virtual void show() { 
			shown=true; 
			this->render();
		}

		virtual void hide() { shown=false; }
		
};

Σχετικά με το ncurses τώρα, κάθε εισαγωγή δεδομένων από το χρήστη θεωρείται ότι προέρχεται από το πληκτρολόγιο. Αυτή είναι η μόνη υπόθεση που μπορούμε να κάνουμε σε αυτό το επίπεδο, αφού δεν ξέρουμε ακόμα τίποτα σχετικά με τον τρόπο απεικόνισης της κάθε view. Θέλω όλες οι views του ncurses να αναγνωρίζουν το ^D ως ένα γενικό πλήκτρο “εξόδου”, παρόλο που η ίδια η διαδικασία εξόδου θα υλοποιείται από τον controller. Επιπλέον, ο controller θα είναι υπεύθυνος για τη διαχείριση όλων των άλλων πλήκτρων. Αυτό σημαίνει ότι πρέπει να παρέχουμε στον controller έναν τρόπο να συνδέει συναρτήσεις σε αυτά τα δύο γεγονότα:

class NCursesCtx {

	public:
		NCursesCtx();
		~NCursesCtx();
		
};

NCursesCtx::NCursesCtx() {
	initscr(); 
	cbreak();		
	keypad(stdscr,TRUE);
	noecho();
	curs_set(0);
}

NCursesCtx::~NCursesCtx() { endwin(); }

class NCursesView: public View<NCursesCtx> {

	using View::View;

	protected:

		function<void(int)> onUnhandledKey;
		function<void()> onExit;

		virtual void handleKey(int);
		
	public:
		void setOnExit(function<void()>);
		void setOnUnhandledKey(function<void(int)>);
		
};

void NCursesView::handleKey(int k) {
	if("^D"==string(keyname(k))) onExit(); else onUnhandledKey(k);
}

void NCursesView::setOnExit(function<void()> f) { onExit=f; }
void NCursesView::setOnUnhandledKey(function<void(int)> f) { onUnhandledKey=f; }

Τώρα ας το κάνουμε πιο συγκεκριμένο. Η παραπάνω κλάση θα μπορούσε να αφορά οποιοδήποτε είδος view στο ncurses: ένα κουμπί, ένα πλαίσιο, ένα checkbox, ένα παράθυρο διαλόγου ή ολόκληρη την οθόνη. Το δικό μας παράδειγμα σχετίζεται με την τελευταία περίπτωση, οπότε ας δούμε πως υλοποιείται κάτι τέτοιο:

class NCursesScreen: public NCursesView {

	using NCursesView::NCursesView;

	protected:

		int width,height;

		virtual void produceContent()=0;
		void render() override;
		void handleKey(int) override;
		
	public:

		void show() override;
		void hide() override;
		
};

void NCursesScreen::render() {
	getmaxyx(stdscr,height,width);
	erase();
	produceContent();
	refresh();
}	

void NCursesScreen::handleKey(int k) { if(KEY_RESIZE==k) render(); else NCursesView::handleKey(k); }

void NCursesScreen::show() {
	View::show();
	while(showing()) handleKey(getch());
}

void NCursesScreen::hide() { 
	View::hide();
	clear(); 
}

Αφού τώρα ξέρουμε ότι έχουμε να κάνουμε με το stdscr, προκειμένου να κρύψουμε (hide()) τη view πρέπει επίσης να καθαρίσουμε την οθόνη. Ακόμα δεν γνωρίζουμε το περιεχόμενό της, ξέρουμε όμως ότι, για να την απεικονίσουμε (render()), πρέπει πρωτα να καθαρίσουμε την οθόνη και τελικά να την ανανεώσουμε (refresh()). Οι απόγονοι αυτής της κλάσης θα πρέπει να υλοποιούν μια μέθοδο produceContent() και, για να τους παρέχουμε ένα πλαίσιο λειτουργίας, πρώτα διαβάζουμε το ύψος και το πλάτος της οθόνης.

Όταν η view εμφανίζεται, μπαίνει σε ένα loop όπου διαχειρίζεται την είσοδο δεδομένων από τον χρήστη μέχρι να την κρύψουμε. Μιλώντας για δεδομένα του χρήστη, θέλω να αναγνωρίζω πότε η οθόνη αλλάζει μέγεθος και να απεικονίζω τη view εκ νέου, ώστε το περιεχόμενο να προσαρμόζεται στις νέες διαστάσεις. Πέρα από αυτό, όλα τα υπόλοιπα δεδομένα προωθούνται στην πατρική handleKey().

Νομίζω ότι κάναμε αρκετή προεργασία για να φτάσουμε στη view του παραδείγματός μας. Σε αυτό το βήμα πρέπει να παρέχουμε μια υλοποίηση της produceContent() και μια νέα handleKey() για την είσοδο δεδομένων που αφορούν συγκεκριμένα αυτήν. Αλλά πρώτα ας δούμε την επικοινωνία με τον controller. Από τη μία πρέπει να υπάρχει κάποιος τρόπος να τον ειδοποιούμε όταν ο χρήστης θέλει να διαγράψει ένα άτομο ή να τροποποιήσει τον μισθό του, κι από την άλλη αυτός πρέπει να μπορεί να παρέχει στη view μια ενημερωμένη έκδοση του personCollection:

class SomeView: public NCursesScreen {

	using NCursesScreen::NCursesScreen;

	private:
  
		personCollection list;
		vector<int> lines;
		int curLine=0;
  
	protected:
  
		function<void(int,int)> onUpdateSalary;
		function<void(int)> onDeletePerson;

	public:
  
		void setList(personCollection);
		void setOnUpdateSalary(function<void(int,int)>);
		void setOnDeletePerson(function<void(int)>);
		
};

void SomeView::setOnUpdateSalary(function<void(int,int)> f) { onUpdateSalary=f; }
void SomeView::setOnDeletePerson(function<void(int)> f) { onDeletePerson=f; }

void SomeView::setList(personCollection l) { 
	list=l; 
	lines.clear();
	for(auto &p:list) lines.push_back(p.first);
	curLine=min(curLine,(int)lines.size()-1);
}

Οπότε, πέρα από τους δύο νέους event handlers και τις μεθόδους για να τους αρχικοποιούμε, η setList() ενημερώνει την εσωτερική personCollection με ένα νέο αντίγραφο. Ακόμα, χρτειαζόμαστε έναν εσωτερικό vector που θα αναπαριστά ποια γραμμή στη view αντιστοιχεί με ποιου ατόμου το id, και ένα πεδίο curLine που θα κρατά την τρέχουσα επιλεγμένη γραμμή.

Οκ, ας παράγουμε τώρα το περιεχόμενό μας, που θα είναι μια λίστα ατόμων στην οποία θα μπορεί ο χρήστης να περιηγείται, και μερικές οδηγίες κάτω από αυτήν. Θέλουμε να τα εκτυπώνουμε όλα αυτά περίπου στο κέντρο της οθόνης:

class SomeView: public NCursesScreen {

  	/* ... */
  
	protected:
		void produceContent() override;
		
  	/* ... */
  
};

void SomeView::produceContent() {

	int line=(height-14)/2,col=(width-50)/2;

	attron(A_BOLD);
	mvprintw(line++,col,"  First Name\tLast Name\tSalary  ");
	mvprintw(line++,col,"  ----------\t----------\t------  ");
	attroff(A_BOLD);

	attron(A_DIM);
	int i=0;
	for(auto &rec:list) {
		if(i==curLine) attron(A_REVERSE);
		personData p=list[rec.first];
		mvprintw(line+i++,col,"  %-10s\t%-10s\t%6d  ",p.fname.c_str(),p.lname.c_str(),p.salary);
		attroff(A_REVERSE);
	}
	attroff(A_DIM);
	
	line+=9;
	col+=3;
	mvprintw(line++,col,"[UP]/[DOWN] Select Person");
	mvprintw(line++,col,"[+]/[-] Increase/decrease Salary");
	mvprintw(line++,col,"[SHIFT][DEL] Delete");

	mvprintw(line++,col,"[q] Quit");

}

Όπως βλέπουμε στις τελευταίες τέσσερις γραμμές, ο χρήστης μπορεί να περιηγείται στη λίστα με τα βελάκια, να αυξάνει ή να μειώνει το μισθό ενός ατόμου με τα [+]/[-], να διαγράφει ένα άτομο με [SHIFT][DEL] ή να βγαίνει από την εφαρμογή πατώντας [q]. Το αποτέλεσμα είναι κάτι σαν αυτό:

Το τελευταίο πράγμα που έχουμε να καλύψουμε είναι η handleKey():

class SomeView: public NCursesScreen {

	/* ... */

	protected:
		void handleKey(int) override;

	/* ... */
		
};

void SomeView::handleKey(int k) {

	int pid=lines[curLine];
	personData p=list[pid];
	
	switch(k) {
	
		case 'q':
			hide();
			break;

		case KEY_UP:
			if(!lines.size()) break;
			if(curLine) curLine--;
			render();
			break;

		case KEY_DOWN:
			if(!lines.size()) break;
			if(curLine<lines.size()-1) curLine++;
			render();
			break;

		case '+':
			if(!lines.size()||(p.salary>99980)) break;
			onUpdateSalary(pid,p.salary+10);
			render();
			break;

		case '-':
			if(!lines.size()||(p.salary<10)) break;
			onUpdateSalary(pid,p.salary-10);
			render();
			break;

		case KEY_SDC:
			if(!lines.size()) break;
			onDeletePerson(pid);
			render();
			break;

		default: NCursesScreen::handleKey(k);

	}

}

Εδώ μπορούμε να δούμε πως η view μας:

  • διαχειρίζεται δεδομένα που αφορούν την ίδια (βελάκια και [q])
  • σηματοδοτεί τον controller για τη διαχείριση του μοντέλου ([+], [-] και [SHIFT][DEL])
  • προωθεί τα υπόλοιπα δεδομένα πιο ψηλά στην ιεραρχία των κλάσεων

Ο Controller

Σε μια εφαρμογή MVC, ενώ το μοντέλο και η view είναι άσχετα αντικείμενα μεταξύ τους, ο controller είναι η “κόλλα” που τα ενώνει. Ο δικός μας controller θα πρέπει να παρέχει τέσσερις event handlers καθώς και ένα σημείο εισόδου για την εφαρμογή:

class SomeController {

	private:

  		NCursesCtx ctx;
		
		SomeDB data;
		SomeView *view;
		
		void viewExits();
		void viewDelegatesKey(int);
		void viewUpdatesSalary(int,int);
		void viewDeletesPerson(int);

	public:
		SomeController();
		void showView();
		
};

Στον ορισμό της κλάσης παραπάνω, έχουμε τέσσερις μεθόδους που θα χειρίζονται τα σήματα από τη view, κάπως έτσι:

void SomeController::viewExits() { view->hide(); }
void SomeController::viewDelegatesKey(int k) { beep(); }

void SomeController::viewUpdatesSalary(int p,int s) { 
	data.updatePersonSalary(p,s); 
	view->setList(data.getPersons());
}

void SomeController::viewDeletesPerson(int p) { 
	data.deletePerson(p); 
	view->setList(data.getPersons());
}

Η έξοδος από τη view σημαίνει ότι απλά θα την κρύψουμε (hide()), το οποίο τελικά θα τερματίσει το εσωτερικό loop εισόδου δεδομένων της, ενώ στην περόπτωση άκυρης εισόδου παίζουμε ένα beep(). Οι άλλες δυο μέθοδοι εκτελούν τις ζητούμενες πράξεις στο επίπεδο δεδομένων και ενημερώνουν τη view με μια νέα λίστα.

Περα από αυτά, ο constructor δημιουργεί τη view και αντιστοιχεί τα σήματά της με τους event handlers, ο destructor τη διαγράφει και η showView() είναι το σημείο εισόδου, όπου απλά αρχικοποιούμε τη view και τη δείχνουμε:

SomeController::SomeController() { 
	view=new SomeView(&ctx);
	view->setOnExit(bind(&SomeController::viewExits,this));
	view->setOnUnhandledKey(bind(&SomeController::viewDelegatesKey,this,_1));
	view->setOnUpdateSalary(bind(&SomeController::viewUpdatesSalary,this,_1,_2));
	view->setOnDeletePerson(bind(&SomeController::viewDeletesPerson,this,_1));
}

SomeController::~SomeController() { delete view; }

void SomeController::showView() { 
	view->setList(data.getPersons());
	view->show(); 
}

Τελος, στη main() θα πούμε στον controller να δείξει τη view:

int main() { SomeController().showView(); }

Αφήστε μια απάντηση

Η ηλ. διεύθυνση σας δεν δημοσιεύεται. Τα υποχρεωτικά πεδία σημειώνονται με *